330 lines
12 KiB
Go
330 lines
12 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
func boolPtr(b bool) *bool { return &b }
|
|
|
|
// fakeObs implements sdk.ObservationGetter against an in-memory map.
|
|
type fakeObs struct {
|
|
store map[string]any
|
|
}
|
|
|
|
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
|
v, ok := f.store[key]
|
|
if !ok {
|
|
return errFake("missing observation: " + key)
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(raw, dest)
|
|
}
|
|
|
|
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type errFake string
|
|
|
|
func (e errFake) Error() string { return string(e) }
|
|
|
|
// ── DNS rules ────────────────────────────────────────────────────────────────
|
|
|
|
func TestCheckDNSQueryFailed(t *testing.T) {
|
|
if got := checkDNSQueryFailed(&EmailKeyData{}, nil); got != nil {
|
|
t.Errorf("expected no issue, got %+v", got)
|
|
}
|
|
got := checkDNSQueryFailed(&EmailKeyData{DNSQueryError: "timeout"}, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("expected one crit issue, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckDNSNoRecord(t *testing.T) {
|
|
// nil DNSAnswerPresent ⇒ no judgement.
|
|
if got := checkDNSNoRecord(&EmailKeyData{}, nil); got != nil {
|
|
t.Errorf("expected no issue when present is nil, got %+v", got)
|
|
}
|
|
// Present=true ⇒ no issue.
|
|
if got := checkDNSNoRecord(&EmailKeyData{DNSAnswerPresent: boolPtr(true)}, nil); got != nil {
|
|
t.Errorf("expected no issue when present, got %+v", got)
|
|
}
|
|
// Present=false ⇒ crit.
|
|
got := checkDNSNoRecord(&EmailKeyData{Kind: KindSMIMEA, QueriedOwner: "x", DNSAnswerPresent: boolPtr(false)}, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit || !strings.Contains(got[0].Message, "SMIMEA") {
|
|
t.Errorf("unexpected: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckDNSSECNotValidated_Severity(t *testing.T) {
|
|
d := &EmailKeyData{DNSSECSecure: boolPtr(false)}
|
|
// Default: requireDNSSEC=true ⇒ crit.
|
|
got := checkDNSSECNotValidated(d, sdk.CheckerOptions{})
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("default should be crit, got %+v", got)
|
|
}
|
|
// Override to false ⇒ warn.
|
|
got = checkDNSSECNotValidated(d, sdk.CheckerOptions{OptionRequireDNSSEC: false})
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
|
|
t.Errorf("opt-off should be warn, got %+v", got)
|
|
}
|
|
// Secure ⇒ no issue.
|
|
got = checkDNSSECNotValidated(&EmailKeyData{DNSSECSecure: boolPtr(true)}, nil)
|
|
if got != nil {
|
|
t.Errorf("expected no issue, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckOwnerHashMismatch(t *testing.T) {
|
|
d := &EmailKeyData{Username: "alice", ExpectedOwnerPrefix: "abc", ObservedOwnerPrefix: "abc"}
|
|
if got := checkOwnerHashMismatch(d, nil); got != nil {
|
|
t.Errorf("matching prefixes should not issue, got %+v", got)
|
|
}
|
|
d.ObservedOwnerPrefix = "ABC" // case-insensitive
|
|
if got := checkOwnerHashMismatch(d, nil); got != nil {
|
|
t.Errorf("case-insensitive match should not issue, got %+v", got)
|
|
}
|
|
d.ObservedOwnerPrefix = "xyz"
|
|
got := checkOwnerHashMismatch(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("mismatch should crit, got %+v", got)
|
|
}
|
|
// Either prefix empty ⇒ skip silently.
|
|
d.ObservedOwnerPrefix = ""
|
|
if got := checkOwnerHashMismatch(d, nil); got != nil {
|
|
t.Errorf("empty observed should skip, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// ── PGP rules ────────────────────────────────────────────────────────────────
|
|
|
|
func TestCheckPGPParseError(t *testing.T) {
|
|
got := checkPGPParseError(&EmailKeyData{}, nil)
|
|
if len(got) != 1 || !strings.Contains(got[0].Message, "no OPENPGPKEY") {
|
|
t.Errorf("expected no-record issue, got %+v", got)
|
|
}
|
|
got = checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{ParseError: "boom"}}, nil)
|
|
if len(got) != 1 || got[0].Message != "boom" {
|
|
t.Errorf("expected parse-error issue, got %+v", got)
|
|
}
|
|
if got := checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{}}, nil); got != nil {
|
|
t.Errorf("expected no issue, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckPGPPrimaryExpired(t *testing.T) {
|
|
past := time.Now().Add(-1 * time.Hour)
|
|
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: past}}
|
|
got := checkPGPPrimaryExpired(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("expected crit, got %+v", got)
|
|
}
|
|
d.OpenPGP.ExpiresAt = time.Now().Add(time.Hour)
|
|
if got := checkPGPPrimaryExpired(d, nil); got != nil {
|
|
t.Errorf("future expiry should not issue, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckPGPPrimaryExpiring(t *testing.T) {
|
|
soon := time.Now().Add(10 * 24 * time.Hour)
|
|
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: soon}}
|
|
// Default 30-day window ⇒ warn.
|
|
got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{})
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
|
|
t.Errorf("expected warn, got %+v", got)
|
|
}
|
|
// Already expired ⇒ this rule does not fire (the expired rule does).
|
|
d.OpenPGP.ExpiresAt = time.Now().Add(-time.Hour)
|
|
if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}); got != nil {
|
|
t.Errorf("expired key should not trigger expiring rule, got %+v", got)
|
|
}
|
|
// Disable via warnDays=0 ⇒ no issue.
|
|
d.OpenPGP.ExpiresAt = soon
|
|
if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{OptionCertExpiryWarnDays: float64(0)}); got != nil {
|
|
t.Errorf("warnDays=0 should disable, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckPGPWeakKeySize(t *testing.T) {
|
|
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{PrimaryAlgorithm: "RSA", PrimaryBits: 1024}}
|
|
got := checkPGPWeakKeySize(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("1024-bit RSA should be crit, got %+v", got)
|
|
}
|
|
d.OpenPGP.PrimaryBits = 2048
|
|
got = checkPGPWeakKeySize(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
|
|
t.Errorf("2048-bit RSA should be warn, got %+v", got)
|
|
}
|
|
d.OpenPGP.PrimaryBits = 4096
|
|
if got := checkPGPWeakKeySize(d, nil); got != nil {
|
|
t.Errorf("4096-bit RSA should pass, got %+v", got)
|
|
}
|
|
// Non-RSA ⇒ skip.
|
|
d.OpenPGP.PrimaryAlgorithm = "Ed25519"
|
|
d.OpenPGP.PrimaryBits = 256
|
|
if got := checkPGPWeakKeySize(d, nil); got != nil {
|
|
t.Errorf("Ed25519 should skip, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckPGPRecordTooLarge(t *testing.T) {
|
|
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{RawSize: pgpMaxRecordBytes + 1}}
|
|
got := checkPGPRecordTooLarge(d, nil)
|
|
if len(got) != 1 {
|
|
t.Errorf("expected one issue, got %+v", got)
|
|
}
|
|
d.OpenPGP.RawSize = pgpMaxRecordBytes
|
|
if got := checkPGPRecordTooLarge(d, nil); got != nil {
|
|
t.Errorf("at-limit should pass, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckPGPUIDMismatch(t *testing.T) {
|
|
d := &EmailKeyData{Username: "alice", OpenPGP: &OpenPGPInfo{MatchesUsername: boolPtr(false)}}
|
|
got := checkPGPUIDMismatch(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusInfo {
|
|
t.Errorf("expected info issue, got %+v", got)
|
|
}
|
|
d.OpenPGP.MatchesUsername = boolPtr(true)
|
|
if got := checkPGPUIDMismatch(d, nil); got != nil {
|
|
t.Errorf("matching should pass, got %+v", got)
|
|
}
|
|
d.OpenPGP.MatchesUsername = nil
|
|
if got := checkPGPUIDMismatch(d, nil); got != nil {
|
|
t.Errorf("nil should skip, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// ── SMIMEA rules ─────────────────────────────────────────────────────────────
|
|
|
|
func TestCheckSMIMEAFieldRanges(t *testing.T) {
|
|
if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 4}}, nil); len(got) != 1 {
|
|
t.Errorf("usage=4 should issue, got %+v", got)
|
|
}
|
|
if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 3}}, nil); got != nil {
|
|
t.Errorf("usage=3 should pass, got %+v", got)
|
|
}
|
|
if got := checkSMIMEABadSelector(&EmailKeyData{SMIMEA: &SMIMEAInfo{Selector: 2}}, nil); len(got) != 1 {
|
|
t.Errorf("selector=2 should issue")
|
|
}
|
|
if got := checkSMIMEABadMatchType(&EmailKeyData{SMIMEA: &SMIMEAInfo{MatchingType: 3}}, nil); len(got) != 1 {
|
|
t.Errorf("matching=3 should issue")
|
|
}
|
|
}
|
|
|
|
func TestCheckSMIMEACertExpired(t *testing.T) {
|
|
past := time.Now().Add(-time.Hour)
|
|
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{NotAfter: past}}}
|
|
got := checkSMIMEACertExpired(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("expected crit, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckSMIMEANoEmailProtect_Severity(t *testing.T) {
|
|
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{}}}
|
|
// Default true ⇒ crit.
|
|
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); len(got) != 1 || got[0].Severity != sdk.StatusCrit {
|
|
t.Errorf("default crit, got %+v", got)
|
|
}
|
|
// Off ⇒ warn.
|
|
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{OptionRequireEmailProtection: false}); got[0].Severity != sdk.StatusWarn {
|
|
t.Errorf("opt-off should warn, got %+v", got)
|
|
}
|
|
// Has EKU ⇒ no issue.
|
|
d.SMIMEA.Certificate.HasEmailProtectionEKU = true
|
|
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); got != nil {
|
|
t.Errorf("EKU present should pass, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckSMIMEAWeakSigAlgorithm(t *testing.T) {
|
|
for _, algo := range []string{"MD5-RSA", "SHA1-RSA"} {
|
|
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: algo}}}
|
|
if got := checkSMIMEAWeakSigAlgorithm(d, nil); len(got) != 1 {
|
|
t.Errorf("%s should issue", algo)
|
|
}
|
|
}
|
|
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: "SHA256-RSA"}}}
|
|
if got := checkSMIMEAWeakSigAlgorithm(d, nil); got != nil {
|
|
t.Errorf("SHA256-RSA should pass, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckSMIMEAEmailMismatch(t *testing.T) {
|
|
d := &EmailKeyData{Username: "alice", SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{
|
|
EmailAddresses: []string{"bob@example.com"},
|
|
EmailMatchesUsername: boolPtr(false),
|
|
}}}
|
|
got := checkSMIMEAEmailMismatch(d, nil)
|
|
if len(got) != 1 || got[0].Severity != sdk.StatusInfo {
|
|
t.Errorf("expected info, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// ── Rule.Evaluate plumbing ───────────────────────────────────────────────────
|
|
|
|
func TestRuleEvaluate_OKPath(t *testing.T) {
|
|
obs := &fakeObs{store: map[string]any{
|
|
ObservationKey: &EmailKeyData{Kind: KindOpenPGPKey, QueriedOwner: "x.example.com.", DNSSECSecure: boolPtr(true)},
|
|
}}
|
|
for _, r := range allRules {
|
|
if r.name != RuleDNSSECNotValidated {
|
|
continue
|
|
}
|
|
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
|
|
if len(states) != 1 || states[0].Status != sdk.StatusOK || states[0].Code != RuleDNSSECNotValidated {
|
|
t.Fatalf("expected single OK state, got %+v", states)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuleEvaluate_KindFiltering(t *testing.T) {
|
|
obs := &fakeObs{store: map[string]any{
|
|
ObservationKey: &EmailKeyData{Kind: KindSMIMEA},
|
|
}}
|
|
for _, r := range allRules {
|
|
if r.name != RulePGPParseError {
|
|
continue
|
|
}
|
|
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
|
|
if len(states) != 1 || states[0].Status != sdk.StatusUnknown {
|
|
t.Fatalf("PGP rule on SMIMEA kind should yield single Unknown state, got %+v", states)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuleEvaluate_MissingObservation(t *testing.T) {
|
|
obs := &fakeObs{store: map[string]any{}}
|
|
r := allRules[0]
|
|
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
|
|
if len(states) != 1 || states[0].Status != sdk.StatusError {
|
|
t.Fatalf("expected single Error state, got %+v", states)
|
|
}
|
|
}
|
|
|
|
func TestRulesUniqueNames(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for _, r := range allRules {
|
|
if seen[r.name] {
|
|
t.Errorf("duplicate rule name: %s", r.name)
|
|
}
|
|
seen[r.name] = true
|
|
if r.check == nil {
|
|
t.Errorf("rule %s has nil check func", r.name)
|
|
}
|
|
if r.okMessage == "" {
|
|
t.Errorf("rule %s has empty okMessage", r.name)
|
|
}
|
|
}
|
|
}
|