diff --git a/api/schemas.yaml b/api/schemas.yaml index 53aa297..df0b416 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -873,24 +873,6 @@ components: type: string description: DKIM record content example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." - key_type: - type: string - description: "Key type from k= tag (e.g. rsa, ed25519); defaults to rsa if absent" - example: "rsa" - hash_algorithms: - type: array - items: - type: string - description: "Acceptable hash algorithms from h= tag; empty means all accepted (RFC 6376 default: sha256)" - example: ["sha256"] - signing_algorithm: - type: string - description: "Algorithm used in DKIM-Signature a= tag (e.g. rsa-sha256, ed25519-sha256)" - example: "rsa-sha256" - key_size: - type: integer - description: "Public key size in bits (RSA: 1024/2048/4096; Ed25519: always 256)" - example: 2048 valid: type: boolean description: Whether the DKIM record is valid @@ -909,10 +891,6 @@ components: type: string description: DMARC record content example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - domain: - type: string - description: Domain at which the DMARC record was found (may differ from From domain when organizational domain fallback was used) - example: "example.com" policy: type: string enum: [none, quarantine, reject, unknown] @@ -923,38 +901,12 @@ components: enum: [none, quarantine, reject, unknown] description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy example: "quarantine" - nonexistent_subdomain_policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent (DMARCbis) - example: "reject" percentage: type: integer minimum: 0 maximum: 100 - description: "Percentage of messages subjected to filtering (pct tag, default 100). DEPRECATED in DMARCbis: use test_mode (t=y) instead." + description: Percentage of messages subjected to filtering (pct tag, default 100) example: 100 - test_mode: - type: boolean - description: "DMARCbis t= tag: when true (t=y), receivers downgrade effective policy one level (reject→quarantine, quarantine→none). Replaces the deprecated pct= tag for testing." - example: false - psd: - type: string - enum: [y, n, u] - description: "DMARCbis psd= tag: y=this is a Public Suffix Domain, n=this is an Organizational Domain boundary, u=unknown (default, use DNS Tree Walk to determine)" - example: "u" - deprecated_pct: - type: boolean - description: "Whether the deprecated pct= tag was found in the record (pct is removed in DMARCbis; migrate to t=y for testing mode)" - example: false - deprecated_rf: - type: boolean - description: "Whether the deprecated rf= tag was found in the record (rf is removed in DMARCbis; failure report formats are now defined separately)" - example: false - deprecated_ri: - type: boolean - description: "Whether the deprecated ri= tag was found in the record (ri is removed in DMARCbis; aggregate reporting interval is now fixed at ≥24 hours)" - example: false spf_alignment: type: string enum: [relaxed, strict] diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index c704c56..d8336a5 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -202,9 +202,6 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze if dns.DmarcRecord.SubdomainPolicy != nil { fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) } - if dns.DmarcRecord.NonexistentSubdomainPolicy != nil { - fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy) - } fmt.Fprintln(writer) if dns.DmarcRecord.Record != nil { fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index bd8880d..da31b1c 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -174,7 +174,9 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut score += 12 * a.calculateXGoogleDKIMScore(results) / 100 // Penalty-only: X-Aligned-From (up to -5 points on failure) - score += 5 * a.calculateXAlignedFromScore(results) / 100 + if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { + score += 5 * (xAlignedScore - 100) / 100 + } // Ensure score doesn't exceed 100 if score > 100 { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 0b17bf0..44c1abb 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -47,7 +47,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: model.AuthResultResultPass, }, }, - expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30 + expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 }, { name: "SPF and DKIM only", @@ -59,7 +59,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: model.AuthResultResultPass}, }, }, - expectedScore: 60, // SPF=30 + DKIM=30 + expectedScore: 48, // SPF=25 + DKIM=23 }, { name: "SPF fail, DKIM pass", @@ -71,7 +71,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: model.AuthResultResultPass}, }, }, - expectedScore: 30, // SPF=0 + DKIM=30 + expectedScore: 23, // SPF=0 + DKIM=23 }, { name: "SPF softfail", @@ -80,7 +80,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: model.AuthResultResultSoftfail, }, }, - expectedScore: 5, // 30 * 17 / 100 = 5 + expectedScore: 4, }, { name: "No authentication", @@ -97,7 +97,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: model.AuthResultResultPass, }, }, - expectedScore: 40, // SPF (30) + BIMI (10) + expectedScore: 35, // SPF (25) + BIMI (10) }, } diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index 45c2e2e..ec1571c 100644 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -51,16 +51,16 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.Authe if results.XAlignedFrom != nil { switch results.XAlignedFrom.Result { case model.AuthResultResultPass: - // pass: no impact - return 0 + // pass: positive contribution + return 100 case model.AuthResultResultFail: // fail: negative contribution - return -100 + return 0 default: // neutral, none, etc.: no impact return 0 } } - return 0 + return 100 } diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index ee90c0d..1ea6d1c 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -92,18 +92,18 @@ func TestCalculateXAlignedFromScore(t *testing.T) { expectedScore int }{ { - name: "pass result gives no penalty", + name: "pass result gives positive score", result: &model.AuthResult{ Result: model.AuthResultResultPass, }, - expectedScore: 0, + expectedScore: 100, }, { - name: "fail result gives full penalty", + name: "fail result gives zero score", result: &model.AuthResult{ Result: model.AuthResultResultFail, }, - expectedScore: -100, + expectedScore: 0, }, { name: "neutral result gives zero score", diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 6bc7c39..3210dd1 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -106,7 +106,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head // Check DKIM records by parsing DKIM-Signature headers directly for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { - dkimRecord := d.checkDKIMRecord(sig) + dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) if dkimRecord != nil { if results.DkimRecords == nil { results.DkimRecords = new([]model.DKIMRecord) diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 115e347..2ae03cb 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -23,8 +23,6 @@ package analyzer import ( "context" - "crypto/x509" - "encoding/base64" "fmt" "strings" @@ -32,18 +30,17 @@ import ( "git.happydns.org/happyDeliver/internal/utils" ) -// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header. +// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. type DKIMHeader struct { - Domain string - Selector string - Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256) + Domain string + Selector string } -// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values. +// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. func parseDKIMSignatures(signatures []string) []DKIMHeader { var results []DKIMHeader for _, sig := range signatures { - var domain, selector, algorithm string + var domain, selector string for _, part := range strings.Split(sig, ";") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { @@ -56,61 +53,19 @@ func parseDKIMSignatures(signatures []string) []DKIMHeader { domain = val case "s": selector = val - case "a": - algorithm = val } } if domain != "" && selector != "" { - results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm}) + results = append(results, DKIMHeader{Domain: domain, Selector: selector}) } } return results } -// parseDKIMTags splits a DKIM DNS record into a tag→value map. -func parseDKIMTags(record string) map[string]string { - tags := make(map[string]string) - for _, part := range strings.Split(record, ";") { - kv := strings.SplitN(strings.TrimSpace(part), "=", 2) - if len(kv) != 2 { - continue - } - tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) - } - return tags -} - -// parseKeySize derives the public key bit length from a base64-encoded DER public key. -// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256. -func parseKeySize(keyType, p string) *int { - switch strings.ToLower(keyType) { - case "ed25519": - return utils.PtrTo(256) - case "rsa", "": - der, err := base64.StdEncoding.DecodeString(p) - if err != nil { - // Try without padding - der, err = base64.RawStdEncoding.DecodeString(p) - if err != nil { - return nil - } - } - pub, err := x509.ParsePKIXPublicKey(der) - if err != nil { - return nil - } - if rsaPub, ok := pub.(interface{ Size() int }); ok { - bits := rsaPub.Size() * 8 - return &bits - } - return nil - } - return nil -} - -// checkDKIMRecord looks up and validates DKIM record for a domain and selector. -func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord { - dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain) +// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord { + // DKIM records are at: selector._domainkey.domain + dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() @@ -118,83 +73,53 @@ func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) if err != nil { return &model.DKIMRecord{ - Selector: h.Selector, - Domain: h.Domain, - SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + Selector: selector, + Domain: domain, + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), } } if len(txtRecords) == 0 { return &model.DKIMRecord{ - Selector: h.Selector, - Domain: h.Domain, - SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), - Valid: false, - Error: utils.PtrTo("No DKIM record found"), + Selector: selector, + Domain: domain, + Valid: false, + Error: utils.PtrTo("No DKIM record found"), } } // Concatenate all TXT record parts (DKIM can be split) dkimRecord := strings.Join(txtRecords, "") + // Basic validation - should contain "v=DKIM1" and "p=" (public key) if !d.validateDKIM(dkimRecord) { return &model.DKIMRecord{ - Selector: h.Selector, - Domain: h.Domain, - Record: utils.PtrTo(dkimRecord), - SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), - Valid: false, - Error: utils.PtrTo("DKIM record appears malformed"), + Selector: selector, + Domain: domain, + Record: utils.PtrTo(dkimRecord), + Valid: false, + Error: utils.PtrTo("DKIM record appears malformed"), } } - tags := parseDKIMTags(dkimRecord) - - keyType := tags["k"] - if keyType == "" { - keyType = "rsa" // RFC 6376 default - } - - var hashAlgorithms []string - if h, ok := tags["h"]; ok && h != "" { - for _, alg := range strings.Split(h, ":") { - if a := strings.TrimSpace(alg); a != "" { - hashAlgorithms = append(hashAlgorithms, a) - } - } - } - if hashAlgorithms == nil { - hashAlgorithms = []string{} - } - return &model.DKIMRecord{ - Selector: h.Selector, - Domain: h.Domain, - Record: &dkimRecord, - KeyType: utils.PtrTo(keyType), - HashAlgorithms: &hashAlgorithms, - SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), - KeySize: parseKeySize(keyType, tags["p"]), - Valid: true, + Selector: selector, + Domain: domain, + Record: &dkimRecord, + Valid: true, } } -func signingAlgorithmPtr(a string) *string { - if a == "" { - return nil - } - return &a -} - -// validateDKIM performs basic DKIM record validation. +// validateDKIM performs basic DKIM record validation func (d *DNSAnalyzer) validateDKIM(record string) bool { + // Should contain p= tag (public key) if !strings.Contains(record, "p=") { return false } - // If v= is present, it must be DKIM1 + // Often contains v=DKIM1 but not required + // If v= is present, it should be DKIM1 if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { return false } @@ -203,57 +128,21 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool { } func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) { - if results.DkimRecords == nil || len(*results.DkimRecords) == 0 { - return 0 - } - - hasValid := false - for _, dkim := range *results.DkimRecords { - if dkim.Valid { - hasValid = true - break - } - } - - if !hasValid { - return 25 - } - - score = 100 - - // Apply security penalties on the best valid record - for _, dkim := range *results.DkimRecords { - if !dkim.Valid { - continue - } - - // SHA-1 signing is deprecated (RFC 8301) - if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") { - if score > 60 { - score = 60 + // DKIM provides strong email authentication + if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { + hasValidDKIM := false + for _, dkim := range *results.DkimRecords { + if dkim.Valid { + hasValidDKIM = true + break } } - - // Key size penalties apply only to RSA - keyType := "" - if dkim.KeyType != nil { - keyType = strings.ToLower(*dkim.KeyType) + if hasValidDKIM { + score += 100 + } else { + // Partial credit if DKIM record exists but has issues + score += 25 } - if keyType == "rsa" || keyType == "" { - if dkim.KeySize != nil { - switch { - case *dkim.KeySize < 1024: - if score > 25 { - score = 25 - } - case *dkim.KeySize < 2048: - if score > 75 { - score = 75 - } - } - } - } - // Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty. } return diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go index 40e28a5..45da53c 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -22,10 +22,6 @@ package analyzer import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/base64" "testing" "time" ) @@ -51,56 +47,56 @@ func TestParseDKIMSignatures(t *testing.T) { signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`, }, - expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}}, }, { name: "Microsoft 365 style", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`, }, - expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}}, }, { name: "Tab-folded multiline (Postfix-style)", signatures: []string{ "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==", }, - expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}}, }, { name: "Space-folded multiline (RFC-style)", signatures: []string{ "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==", }, - expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}}, }, { name: "d= and s= on separate continuation lines", signatures: []string{ "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==", }, - expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}}, }, { name: "No space after semicolons", signatures: []string{ `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, }, - expected: []DKIMHeader{{Domain: "example.net", Selector: "mail", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}}, }, { name: "Multiple spaces after semicolons", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, }, - expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}}, }, { name: "Ed25519 signature (RFC 8463)", signatures: []string{ "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==", }, - expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}}, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}}, }, { name: "Multiple signatures (ESP double-signing)", @@ -109,8 +105,8 @@ func TestParseDKIMSignatures(t *testing.T) { `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, }, expected: []DKIMHeader{ - {Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"}, - {Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"}, + {Domain: "mydomain.com", Selector: "mail"}, + {Domain: "sendib.com", Selector: "mail"}, }, }, { @@ -120,8 +116,8 @@ func TestParseDKIMSignatures(t *testing.T) { `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, }, expected: []DKIMHeader{ - {Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}, - {Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}, + {Domain: "football.example.com", Selector: "brisbane"}, + {Domain: "football.example.com", Selector: "test"}, }, }, { @@ -131,8 +127,8 @@ func TestParseDKIMSignatures(t *testing.T) { `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, }, expected: []DKIMHeader{ - {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"}, - {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"}, + {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, }, }, { @@ -140,56 +136,56 @@ func TestParseDKIMSignatures(t *testing.T) { signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`, }, - expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}}, }, { name: "Deeply nested subdomain", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`, }, - expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}}, }, { name: "Selector with hyphens (Microsoft 365 custom domain style)", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`, }, - expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}}, }, { name: "Selector with dots", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, }, - expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}}, }, { name: "Single-character selector", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, }, - expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}}, }, { name: "Postmark-style timestamp selector, s= before d=", signatures: []string{ `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, }, - expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm", Algorithm: "rsa-sha1"}}, + expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}}, }, { name: "d= and s= at the very end", signatures: []string{ `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, }, - expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}}, }, { name: "Full tag set", signatures: []string{ `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`, }, - expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}}, }, { name: "Missing d= tag", @@ -220,8 +216,8 @@ func TestParseDKIMSignatures(t *testing.T) { `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`, }, expected: []DKIMHeader{ - {Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"}, - {Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"}, + {Domain: "good.com", Selector: "sel1"}, + {Domain: "also-good.com", Selector: "sel2"}, }, }, } @@ -239,9 +235,6 @@ func TestParseDKIMSignatures(t *testing.T) { if result[i].Selector != tt.expected[i].Selector { t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) } - if result[i].Algorithm != tt.expected[i].Algorithm { - t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm) - } } }) } @@ -291,119 +284,3 @@ func TestValidateDKIM(t *testing.T) { }) } } - -func TestParseDKIMTags(t *testing.T) { - tests := []struct { - name string - record string - wantTags map[string]string - }{ - { - name: "standard RSA record", - record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256", - wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"}, - }, - { - name: "ed25519 record", - record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS", - wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"}, - }, - { - name: "missing k= defaults", - record: "v=DKIM1; p=MIIBI", - wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"}, - }, - { - name: "empty record", - record: "", - wantTags: map[string]string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseDKIMTags(tt.record) - for key, want := range tt.wantTags { - if got[key] != want { - t.Errorf("tag %q = %q, want %q", key, got[key], want) - } - } - }) - } -} - -func TestParseKeySize(t *testing.T) { - // Generate a real RSA key for testing - rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024) - rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048) - - der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey) - der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey) - - p1024 := base64.StdEncoding.EncodeToString(der1024) - p2048 := base64.StdEncoding.EncodeToString(der2048) - - tests := []struct { - name string - keyType string - p string - want *int - }{ - { - name: "RSA 1024", - keyType: "rsa", - p: p1024, - want: intPtr(1024), - }, - { - name: "RSA 2048", - keyType: "rsa", - p: p2048, - want: intPtr(2048), - }, - { - name: "Ed25519 always 256", - keyType: "ed25519", - p: "11qYAYKxCrfVS", - want: intPtr(256), - }, - { - name: "Unknown key type", - keyType: "unknown", - p: "somedata", - want: nil, - }, - { - name: "Invalid RSA base64", - keyType: "rsa", - p: "!!!not-base64!!!", - want: nil, - }, - { - name: "Empty k= defaults to RSA", - keyType: "", - p: p2048, - want: intPtr(2048), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := parseKeySize(tt.keyType, tt.p) - if tt.want == nil { - if got != nil { - t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got) - } - return - } - if got == nil { - t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want) - } - if *got != *tt.want { - t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want) - } - }) - } -} - -func intPtr(v int) *int { return &v } diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index b89500b..686fb0b 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -24,290 +24,233 @@ package analyzer import ( "context" "fmt" - "net" - "strconv" + "regexp" "strings" "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) -var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2} +// checkmodel.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { + // DMARC records are at: _dmarc.domain + dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) -// lookupDMARCAt queries _dmarc. and returns the raw DMARC1 TXT record. -// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred. -func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() - txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain)) - if lookupErr != nil { - if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound { - return "", true, nil - } - return "", false, lookupErr - } - - for _, txt := range txtRecords { - if strings.HasPrefix(txt, "v=DMARC1") { - return txt, false, nil - } - } - return "", true, nil -} - -// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model. -func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { - tags := parseDKIMTags(rawRecord) - - // Policy - policy := "unknown" - switch tags["p"] { - case "none", "quarantine", "reject": - policy = tags["p"] - } - - // SPF alignment (default: relaxed) - spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) - if tags["aspf"] == "s" { - spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict) - } - - // DKIM alignment (default: relaxed) - dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) - if tags["adkim"] == "s" { - dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict) - } - - // Subdomain policy - var subdomainPolicy *model.DMARCRecordSubdomainPolicy - switch tags["sp"] { - case "none", "quarantine", "reject": - subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"])) - } - - // Non-existent subdomain policy (DMARCbis np=) - var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy - switch tags["np"] { - case "none", "quarantine", "reject": - nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"])) - } - - // Percentage (pct=, deprecated in DMARCbis) - var percentage *int - if pctStr, ok := tags["pct"]; ok { - if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 { - percentage = &pct - } - } - - // Test mode (DMARCbis t=) - var testMode *bool - if t, ok := tags["t"]; ok { - v := t == "y" - testMode = &v - } - - // PSD (DMARCbis psd=) - var psd *model.DMARCRecordPsd - switch tags["psd"] { - case "y", "n", "u": - psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"])) - } - - rec := &model.DMARCRecord{ - Domain: &foundDomain, - Record: &rawRecord, - Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - NonexistentSubdomainPolicy: nonexistentSubdomainPolicy, - Percentage: percentage, - TestMode: testMode, - Psd: psd, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - } - if percentage != nil { - rec.DeprecatedPct = utils.PtrTo(true) - } - if _, ok := tags["rf"]; ok { - rec.DeprecatedRf = utils.PtrTo(true) - } - if _, ok := tags["ri"]; ok { - rec.DeprecatedRi = utils.PtrTo(true) - } - - if !d.validateDMARC(rawRecord) { - rec.Valid = false - rec.Error = utils.PtrTo("DMARC record appears malformed") - return rec - } - - rec.Valid = true - return rec -} - -// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10). -// It queries _dmarc. and walks up the label hierarchy until a valid DMARC -// record is found or all labels are exhausted. Maximum 8 DNS queries per message. -// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label -// suffix before resuming normally (to stay within the 8-query budget). -// Single-label (TLD) records are only accepted when they carry psd=y. -func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) { - labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".") - n := len(labels) - - for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 { - current := strings.Join(labels[i:], ".") - - raw, notFound, lookupErr := d.lookupDMARCAt(current) - if lookupErr != nil { - return "", "", lookupErr - } - if !notFound { - // Single-label (TLD) records are only used when the record explicitly opts in. - if !strings.Contains(current, ".") { - if d.extractDMARCPSDValue(raw) != "y" { - break - } - } - return raw, current, nil - } - - // DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the - // 7-label suffix for the next query rather than stepping one label at a time. - if i == 0 && n >= 8 { - i = n - 8 // the outer i++ will land at n-7 (7 labels from the right) - } - } - - return "", "", nil -} - -// checkDMARCRecord looks up and validates the DMARC record for a domain using -// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the -// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC -// experimental fallback. -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { - raw, foundDomain, err := d.walkDNSForDMARC(domain) + txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) if err != nil { return &model.DMARCRecord{ Valid: false, Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } - if foundDomain == "" { + + // Find DMARC record (starts with "v=DMARC1") + var dmarcRecord string + for _, txt := range txtRecords { + if strings.HasPrefix(txt, "v=DMARC1") { + dmarcRecord = txt + break + } + } + + if dmarcRecord == "" { return &model.DMARCRecord{ Valid: false, Error: utils.PtrTo("No DMARC record found"), } } - return d.parseDMARCRecord(foundDomain, raw) -} -// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent. -// Used during DNS Tree Walk before full record parsing. -func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string { - v := parseDKIMTags(record)["psd"] - switch v { - case "y", "n", "u": - return v + // Extract policy + policy := d.extractDMARCPolicy(dmarcRecord) + + // Extract subdomain policy + subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord) + + // Extract percentage + percentage := d.extractDMARCPercentage(dmarcRecord) + + // Extract alignment modes + spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord) + + // Basic validation + if !d.validateDMARC(dmarcRecord) { + return &model.DMARCRecord{ + Record: &dmarcRecord, + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: utils.PtrTo("DMARC record appears malformed"), + } + } + + return &model.DMARCRecord{ + Record: &dmarcRecord, + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, } - return "" } -// validateDMARC performs basic DMARC record validation. -// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid -// rua= but no p= is treated as p=none and considered valid. +// extractDMARCPolicy extracts the policy from a DMARC record +func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { + // Look for p=none, p=quarantine, or p=reject + re := regexp.MustCompile(`p=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "unknown" +} + +// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment { + // Look for aspf=s (strict) or aspf=r (relaxed) + re := regexp.MustCompile(`aspf=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict) + } + return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) + } + // Default is relaxed if not specified + return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) +} + +// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record +// Returns "relaxed" (default) or "strict" +func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment { + // Look for adkim=s (strict) or adkim=r (relaxed) + re := regexp.MustCompile(`adkim=(r|s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + if matches[1] == "s" { + return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict) + } + return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) + } + // Default is relaxed if not specified + return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) +} + +// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record +// Returns the sp tag value or nil if not specified (defaults to main policy) +func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy { + // Look for sp=none, sp=quarantine, or sp=reject + re := regexp.MustCompile(`sp=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1])) + } + // If sp is not specified, it defaults to the main policy (p tag) + // Return nil to indicate it's using the default + return nil +} + +// extractDMARCPercentage extracts the percentage from a DMARC record +// Returns the pct tag value or nil if not specified (defaults to 100) +func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { + // Look for pct= + re := regexp.MustCompile(`pct=(\d+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + // Convert string to int + var pct int + fmt.Sscanf(matches[1], "%d", &pct) + // Validate range (0-100) + if pct >= 0 && pct <= 100 { + return &pct + } + } + // Default is 100 if not specified + return nil +} + +// validateDMARC performs basic DMARC record validation func (d *DNSAnalyzer) validateDMARC(record string) bool { + // Must start with v=DMARC1 if !strings.HasPrefix(record, "v=DMARC1") { return false } - // p= absent is allowed in DMARCbis when rua= is present (treated as p=none). + // Must have a policy tag if !strings.Contains(record, "p=") { - return strings.Contains(record, "rua=") + return false } return true } func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) { - if results.DmarcRecord == nil { - return - } + // DMARC ties SPF and DKIM together and provides policy + if results.DmarcRecord != nil { + if results.DmarcRecord.Valid { + score += 50 + // Bonus points for stricter policies + if results.DmarcRecord.Policy != nil { + switch *results.DmarcRecord.Policy { + case "reject": + // Strictest policy - full points already awarded + score += 25 + case "quarantine": + // Good policy - no deduction + case "none": + // Weakest policy - deduct 5 points + score -= 25 + } + } + // Bonus points for strict alignment modes (2 points each) + if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict { + score += 5 + } + if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict { + score += 5 + } + // Subdomain policy scoring (sp tag) + // +3 for stricter or equal subdomain policy, -3 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + mainPolicy := string(*results.DmarcRecord.Policy) + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) - if !results.DmarcRecord.Valid { - if results.DmarcRecord.Record != nil { - // Partial credit if a DMARC record exists but has issues + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + mainStrength := policyStrength[mainPolicy] + subStrength := policyStrength[subPolicy] + + if subStrength >= mainStrength { + // Subdomain policy is equal or stricter + score += 15 + } else { + // Subdomain policy is weaker + score -= 15 + } + } else { + // No sp tag means subdomains inherit main policy (good default) + score += 15 + } + // Percentage scoring (pct tag) + // Apply the percentage on the current score + if results.DmarcRecord.Percentage != nil { + pct := *results.DmarcRecord.Percentage + + score = score * pct / 100 + } + } else if results.DmarcRecord.Record != nil { + // Partial credit if DMARC record exists but has issues score += 20 } - return - } - - score += 50 - - // Determine effective policy: DMARCbis t=y downgrades policy one level. - effectivePolicy := "none" - if results.DmarcRecord.Policy != nil { - effectivePolicy = string(*results.DmarcRecord.Policy) - } - testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode - if testMode { - switch effectivePolicy { - case "reject": - effectivePolicy = "quarantine" - case "quarantine": - effectivePolicy = "none" - } - } - - // Bonus/penalty for policy strength - switch effectivePolicy { - case "reject": - score += 25 - case "none": - score -= 25 - } - - // Bonus points for strict alignment modes - if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict { - score += 5 - } - if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict { - score += 5 - } - - // Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker - if results.DmarcRecord.SubdomainPolicy != nil { - subPolicy := string(*results.DmarcRecord.SubdomainPolicy) - if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] { - score += 15 - } else { - score -= 15 - } - } else { - score += 15 // inherits main policy — good default - } - - // Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker - effectiveSubPolicy := effectivePolicy - if results.DmarcRecord.SubdomainPolicy != nil { - effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy) - } - if results.DmarcRecord.NonexistentSubdomainPolicy == nil { - score += 15 // inherits subdomain/main policy — good default - } else if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] { - score += 15 - } else { - score -= 15 - } - - // pct= scaling (deprecated in DMARCbis, kept for backward compatibility). - // pct=0 is an anti-pattern: score it as zero enforcement. - if results.DmarcRecord.Percentage != nil { - pct := *results.DmarcRecord.Percentage - score = score * pct / 100 } return diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 5c34a32..93f4511 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -22,206 +22,14 @@ package analyzer import ( - "context" - "fmt" - "net" "testing" "time" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) -// mockDNSResolver maps domain names to TXT records for testing. -// An entry with value nil means NXDOMAIN; an error value triggers a DNS error. -type mockDNSResolver struct { - txt map[string][]string - err map[string]error -} - -func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) { - if err, ok := m.err[name]; ok { - return nil, err - } - if records, ok := m.txt[name]; ok { - return records, nil - } - return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} -} - -func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) { - return nil, nil -} -func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) { - return nil, nil -} -func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) { - return nil, nil -} - -func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer { - if errMap == nil { - errMap = map[string]error{} - } - return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap}) -} - -func TestCheckDMARCRecordFallback(t *testing.T) { - const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - const subRecord = "v=DMARC1; p=reject" - const psdRecord = "v=DMARC1; p=none; psd=y" - - tests := []struct { - name string - domain string - txt map[string][]string - errMap map[string]error - wantValid bool - wantDomain *string - wantErrSubst string - }{ - { - name: "exact domain has DMARC record — no fallback", - domain: "mail.example.com", - txt: map[string][]string{ - "_dmarc.mail.example.com": {subRecord}, - "_dmarc.example.com": {orgRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("mail.example.com"), - }, - { - name: "exact domain NXDOMAIN — tree walk reaches org domain", - domain: "mail.example.com", - txt: map[string][]string{ - "_dmarc.example.com": {orgRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("example.com"), - }, - { - name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain", - domain: "mail.example.com", - txt: map[string][]string{ - "_dmarc.mail.example.com": {"some-other-txt"}, - "_dmarc.example.com": {orgRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("example.com"), - }, - { - name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk", - domain: "mail.example.com", - txt: map[string][]string{ - "_dmarc.com": {psdRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("com"), - }, - { - name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk", - domain: "mail.example.com", - txt: map[string][]string{ - "_dmarc.com": {"v=DMARC1; p=none"}, - }, - wantValid: false, - wantErrSubst: "No DMARC record found", - }, - { - name: "no record at any level", - domain: "mail.example.com", - txt: map[string][]string{}, - wantValid: false, - wantErrSubst: "No DMARC record found", - }, - { - name: "DNS error on exact domain — error returned", - domain: "mail.example.com", - errMap: map[string]error{ - "_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"), - }, - wantValid: false, - wantErrSubst: "SERVFAIL", - }, - { - name: "domain already at org level — found immediately", - domain: "example.com", - txt: map[string][]string{ - "_dmarc.example.com": {orgRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("example.com"), - }, - { - name: "deep subdomain — tree walk finds record two levels up", - domain: "a.b.example.com", - txt: map[string][]string{ - "_dmarc.example.com": {orgRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("example.com"), - }, - { - name: "8-label domain — shortcut to 7-label suffix on miss", - domain: "a.b.c.d.e.f.example.com", - txt: map[string][]string{ - "_dmarc.b.c.d.e.f.example.com": {orgRecord}, - }, - wantValid: true, - wantDomain: utils.PtrTo("b.c.d.e.f.example.com"), - }, - { - name: "psd=n record stops tree walk at that level", - domain: "mail.sub.example.com", - txt: map[string][]string{ - "_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"}, - }, - wantValid: true, - wantDomain: utils.PtrTo("sub.example.com"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - analyzer := newMockAnalyzer(tt.txt, tt.errMap) - result := analyzer.checkDMARCRecord(tt.domain) - - if result.Valid != tt.wantValid { - t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid) - } - if tt.wantDomain != nil { - if result.Domain == nil { - t.Fatalf("Domain = nil, want %q", *tt.wantDomain) - } - if *result.Domain != *tt.wantDomain { - t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain) - } - } - if tt.wantErrSubst != "" { - if result.Error == nil { - t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst) - } - if !contains(*result.Error, tt.wantErrSubst) { - t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst) - } - } - }) - } -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) -} - -func containsStr(s, sub string) bool { - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} - -func TestParseDMARCRecordPolicy(t *testing.T) { +func TestExtractDMARCPolicy(t *testing.T) { tests := []struct { name string record string @@ -253,135 +61,9 @@ func TestParseDMARCRecordPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rec := analyzer.parseDMARCRecord("example.com", tt.record) - if rec.Policy == nil { - t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record) - } - if string(*rec.Policy) != tt.expectedPolicy { - t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy) - } - }) - } -} - -func TestParseDMARCRecordTestMode(t *testing.T) { - tests := []struct { - name string - record string - wantMode *bool - }{ - { - name: "t=y sets test mode", - record: "v=DMARC1; p=reject; t=y", - wantMode: utils.PtrTo(true), - }, - { - name: "t=n explicitly disables test mode", - record: "v=DMARC1; p=reject; t=n", - wantMode: utils.PtrTo(false), - }, - { - name: "absent t tag returns nil", - record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", - wantMode: nil, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode - if tt.wantMode == nil { - if result != nil { - t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result) - } - } else { - if result == nil { - t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode) - } - if *result != *tt.wantMode { - t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode) - } - } - }) - } -} - -func TestParseDMARCRecordPSD(t *testing.T) { - tests := []struct { - name string - record string - wantPSD *string - }{ - { - name: "psd=y marks Public Suffix Domain", - record: "v=DMARC1; p=none; psd=y", - wantPSD: utils.PtrTo("y"), - }, - { - name: "psd=n marks Org Domain boundary", - record: "v=DMARC1; p=reject; psd=n", - wantPSD: utils.PtrTo("n"), - }, - { - name: "psd=u is explicit unknown", - record: "v=DMARC1; p=quarantine; psd=u", - wantPSD: utils.PtrTo("u"), - }, - { - name: "absent psd tag returns nil", - record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", - wantPSD: nil, - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDMARCRecord("example.com", tt.record).Psd - if tt.wantPSD == nil { - if result != nil { - t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result) - } - } else { - if result == nil { - t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD) - } - if string(*result) != *tt.wantPSD { - t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD) - } - } - }) - } -} - -func TestParseDMARCRecordDeprecatedTags(t *testing.T) { - tests := []struct { - name string - record string - wantRf bool - wantRi bool - }{ - {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false}, - {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true}, - {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false}, - {name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false}, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rec := analyzer.parseDMARCRecord("example.com", tt.record) - gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf - gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi - if gotRf != tt.wantRf { - t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf) - } - if gotRi != tt.wantRi { - t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi) + result := analyzer.extractDMARCPolicy(tt.record) + if result != tt.expectedPolicy { + t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) } }) } @@ -403,18 +85,13 @@ func TestValidateDMARC(t *testing.T) { record: "v=DMARC1; p=none", expected: true, }, - { - name: "DMARCbis: p= absent but rua= present is valid (treated as p=none)", - record: "v=DMARC1; rua=mailto:dmarc@example.com", - expected: true, - }, { name: "Invalid DMARC - no version", record: "p=quarantine", expected: false, }, { - name: "Invalid DMARC - no policy and no rua", + name: "Invalid DMARC - no policy", record: "v=DMARC1", expected: false, }, @@ -437,36 +114,41 @@ func TestValidateDMARC(t *testing.T) { } } -func TestParseDMARCRecordAlignment(t *testing.T) { +func TestExtractDMARCSPFAlignment(t *testing.T) { tests := []struct { - name string - record string - expectedSPF string - expectedDKIM string + name string + record string + expectedAlignment string }{ { - name: "SPF strict, DKIM relaxed", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedSPF: "strict", - expectedDKIM: "relaxed", + name: "SPF alignment - strict", + record: "v=DMARC1; p=quarantine; aspf=s", + expectedAlignment: "strict", }, { - name: "SPF relaxed explicit, DKIM strict", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedSPF: "relaxed", - expectedDKIM: "strict", + name: "SPF alignment - relaxed (explicit)", + record: "v=DMARC1; p=quarantine; aspf=r", + expectedAlignment: "relaxed", }, { - name: "Defaults when neither specified", - record: "v=DMARC1; p=quarantine", - expectedSPF: "relaxed", - expectedDKIM: "relaxed", + name: "SPF alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=quarantine", + expectedAlignment: "relaxed", }, { - name: "Both strict in complex record", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", - expectedSPF: "strict", - expectedDKIM: "strict", + name: "Both alignments specified - check SPF strict", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check SPF relaxed", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with SPF strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedAlignment: "strict", }, } @@ -474,53 +156,52 @@ func TestParseDMARCRecordAlignment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rec := analyzer.parseDMARCRecord("example.com", tt.record) - if rec.SpfAlignment == nil { - t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record) + result := analyzer.extractDMARCSPFAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) } - if string(*rec.SpfAlignment) != tt.expectedSPF { - t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF) - } - if rec.DkimAlignment == nil { - t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record) - } - if string(*rec.DkimAlignment) != tt.expectedDKIM { - t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM) + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) } }) } } -func TestParseDMARCRecordSubdomainPolicy(t *testing.T) { +func TestExtractDMARCDKIMAlignment(t *testing.T) { tests := []struct { - name string - record string - expectedSP *string - expectedNP *string + name string + record string + expectedAlignment string }{ { - name: "sp=none, no np", - record: "v=DMARC1; p=quarantine; sp=none", - expectedSP: utils.PtrTo("none"), - expectedNP: nil, + name: "DKIM alignment - strict", + record: "v=DMARC1; p=reject; adkim=s", + expectedAlignment: "strict", }, { - name: "sp=reject, np=reject", - record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", - expectedSP: utils.PtrTo("quarantine"), - expectedNP: utils.PtrTo("reject"), + name: "DKIM alignment - relaxed (explicit)", + record: "v=DMARC1; p=reject; adkim=r", + expectedAlignment: "relaxed", }, { - name: "No sp or np (both default)", - record: "v=DMARC1; p=quarantine", - expectedSP: nil, - expectedNP: nil, + name: "DKIM alignment - relaxed (default, not specified)", + record: "v=DMARC1; p=none", + expectedAlignment: "relaxed", }, { - name: "np=quarantine, no sp", - record: "v=DMARC1; p=reject; np=quarantine", - expectedSP: nil, - expectedNP: utils.PtrTo("quarantine"), + name: "Both alignments specified - check DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedAlignment: "strict", + }, + { + name: "Both alignments specified - check DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedAlignment: "relaxed", + }, + { + name: "Complex record with DKIM strict", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", + expectedAlignment: "strict", }, } @@ -528,63 +209,134 @@ func TestParseDMARCRecordSubdomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rec := analyzer.parseDMARCRecord("example.com", tt.record) - if tt.expectedSP == nil { - if rec.SubdomainPolicy != nil { - t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy) - } - } else { - if rec.SubdomainPolicy == nil { - t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP) - } - if string(*rec.SubdomainPolicy) != *tt.expectedSP { - t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP) - } + result := analyzer.extractDMARCDKIMAlignment(tt.record) + if result == nil { + t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) } - if tt.expectedNP == nil { - if rec.NonexistentSubdomainPolicy != nil { - t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy) + if string(*result) != tt.expectedAlignment { + t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) + } + }) + } +} + +func TestExtractDMARCSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Subdomain policy - none", + record: "v=DMARC1; p=quarantine; sp=none", + expectedPolicy: utils.PtrTo("none"), + }, + { + name: "Subdomain policy - quarantine", + record: "v=DMARC1; p=reject; sp=quarantine", + expectedPolicy: utils.PtrTo("quarantine"), + }, + { + name: "Subdomain policy - reject", + record: "v=DMARC1; p=quarantine; sp=reject", + expectedPolicy: utils.PtrTo("reject"), + }, + { + name: "No subdomain policy specified (defaults to main policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with subdomain policy", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: utils.PtrTo("quarantine"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) } } else { - if rec.NonexistentSubdomainPolicy == nil { - t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP) + if result == nil { + t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) } - if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP { - t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP) + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) } } }) } } -func TestParseDMARCRecordPercentage(t *testing.T) { +func TestExtractDMARCPercentage(t *testing.T) { tests := []struct { name string record string expectedPercentage *int }{ - {name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)}, - {name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)}, - {name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)}, - {name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil}, - {name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil}, + { + name: "Percentage - 100", + record: "v=DMARC1; p=quarantine; pct=100", + expectedPercentage: utils.PtrTo(100), + }, + { + name: "Percentage - 50", + record: "v=DMARC1; p=quarantine; pct=50", + expectedPercentage: utils.PtrTo(50), + }, + { + name: "Percentage - 25", + record: "v=DMARC1; p=reject; pct=25", + expectedPercentage: utils.PtrTo(25), + }, + { + name: "Percentage - 0", + record: "v=DMARC1; p=none; pct=0", + expectedPercentage: utils.PtrTo(0), + }, + { + name: "No percentage specified (defaults to 100)", + record: "v=DMARC1; p=quarantine", + expectedPercentage: nil, + }, + { + name: "Complex record with percentage", + record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", + expectedPercentage: utils.PtrTo(75), + }, + { + name: "Invalid percentage > 100 (ignored)", + record: "v=DMARC1; p=quarantine; pct=150", + expectedPercentage: nil, + }, + { + name: "Invalid percentage < 0 (ignored)", + record: "v=DMARC1; p=quarantine; pct=-10", + expectedPercentage: nil, + }, } analyzer := NewDNSAnalyzer(5 * time.Second) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage + result := analyzer.extractDMARCPercentage(tt.record) if tt.expectedPercentage == nil { if result != nil { - t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result) + t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage) + t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) } if *result != *tt.expectedPercentage { - t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage) + t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) } } }) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 6d7b547..f750742 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -388,7 +388,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults if domain != "" { alignment.FromDomain = &domain // Extract organizational domain - orgDomain := getOrganizationalDomain(domain) + orgDomain := h.getOrganizationalDomain(domain) alignment.FromOrgDomain = &orgDomain } } @@ -400,7 +400,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults if domain != "" { alignment.ReturnPathDomain = &domain // Extract organizational domain - orgDomain := getOrganizationalDomain(domain) + orgDomain := h.getOrganizationalDomain(domain) alignment.ReturnPathOrgDomain = &orgDomain } } @@ -411,7 +411,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults for _, dkim := range *authResults.Dkim { if dkim.Domain != nil && *dkim.Domain != "" { domain := *dkim.Domain - orgDomain := getOrganizationalDomain(domain) + orgDomain := h.getOrganizationalDomain(domain) dkimDomains = append(dkimDomains, model.DKIMDomainInfo{ Domain: domain, OrgDomain: orgDomain, @@ -542,7 +542,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { // getOrganizationalDomain extracts the organizational domain from a fully qualified domain name // using the Public Suffix List (PSL) to correctly handle multi-level TLDs. // For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk -func getOrganizationalDomain(domain string) string { +func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { domain = strings.ToLower(strings.TrimSpace(domain)) // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 31cccab..7dea559 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -318,7 +318,7 @@ func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bo return 100, "" } - if results.ListedCount <= 0 || scoringListCount <= 0 { + if results.ListedCount <= 0 { return 100, "A+" } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index f86f17b..8620038 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -291,38 +291,34 @@ func TestGetBlacklistScore(t *testing.T) { { name: "Listed on 1 RBL", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, - RelevantListedCount: 1, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, }, - expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational) + expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) }, { name: "Listed on 2 RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, - RelevantListedCount: 2, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, }, - expectedScore: 84, // 100 - 2*100/12 = 84 + expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) }, { name: "Listed on 3 RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 3, - RelevantListedCount: 3, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 3, }, - expectedScore: 75, // 100 - 3*100/12 = 75 + expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) }, { name: "Listed on 4+ RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, - RelevantListedCount: 4, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, }, - expectedScore: 67, // 100 - 4*100/12 = 67 + expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) }, } @@ -330,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateScore(tt.results, false) + score, _ := checker.CalculateScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } diff --git a/web/package-lock.json b/web/package-lock.json index 27e6fc1..c37ce8b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -31,7 +31,7 @@ "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", "vite": "^8.0.0", - "vitest": "^3.2.4" + "vitest": "^4.0.0" } }, "node_modules/@emnapi/core": { @@ -68,448 +68,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1176,395 +734,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1972,59 +1141,87 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -2032,28 +1229,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2249,43 +1443,16 @@ } } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2359,6 +1526,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2415,16 +1589,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2529,55 +1693,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3150,13 +2265,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3545,13 +2653,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3794,16 +2895,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -4086,58 +3177,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4257,25 +3296,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/svelte": { "version": "5.55.7", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", @@ -4441,30 +3467,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4669,104 +3675,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -4788,65 +3696,79 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4857,118 +3779,19 @@ }, "jsdom": { "optional": true - } - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true }, "vite": { - "optional": true + "optional": false } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, - "node_modules/vitest/node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5035,22 +3858,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index 90b545e..66b2c8c 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,7 @@ "typescript": "^6.0.0", "typescript-eslint": "^8.44.1", "vite": "^8.0.0", - "vitest": "^3.2.4" + "vitest": "^4.0.0" }, "dependencies": { "bootstrap": "^5.3.8", diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index e2b83f0..b7a3e7b 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -3,31 +3,15 @@ interface Props { dmarcRecord?: DmarcRecord; - fromDomain?: string; } - let { dmarcRecord, fromDomain }: Props = $props(); - - const isFallback = $derived( - !!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain, - ); - // A single-label domain (no dot) is a TLD/PSD level fallback - const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes(".")); + let { dmarcRecord }: Props = $props(); // Helper function to determine policy strength const policyStrength = (policy: string | undefined): number => { const strength: Record = { none: 0, quarantine: 1, reject: 2 }; return strength[policy || "none"] || 0; }; - - // Effective policy after applying DMARCbis t=y downgrade - const effectivePolicy = $derived((): string => { - const p = dmarcRecord?.policy ?? "none"; - if (!dmarcRecord?.test_mode) return p; - if (p === "reject") return "quarantine"; - if (p === "quarantine") return "none"; - return p; - }); {#if dmarcRecord} @@ -68,27 +52,6 @@ {/if} - - {#if isFallback} -
- Record found at: - {dmarcRecord.domain} -
- - No DMARC record exists for {fromDomain}. The record above was - inherited from - {#if isPsdFallback} - the Public Suffix Domain {dmarcRecord.domain} via the DMARCbis - DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment). - {:else} - the organizational domain {dmarcRecord.domain} via the - DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain - fallback). - {/if} -
-
- {/if} - {#if dmarcRecord.policy}
@@ -136,53 +99,6 @@
{/if} - - {#if dmarcRecord.test_mode} -
- Test Mode: - t=y (active) -
- - Test mode active — DMARCbis-compliant receivers will - downgrade the effective policy one level: - {#if dmarcRecord.policy === "reject"} - p=reject is applied as p=quarantine. - {:else if dmarcRecord.policy === "quarantine"} - p=quarantine is applied as p=none (no action taken). - {:else} - p=none is unaffected by test mode. - {/if} - Aggregate reports are still generated normally. - This tag replaces the deprecated pct= for gradual rollout. -
-
- {/if} - - - {#if dmarcRecord.psd === "y"} -
- Public Suffix Domain: - psd=y -
- - PSD declared — this domain is declared as a Public Suffix - Domain. DMARCbis-compliant receivers will apply this policy to subdomains - that have no DMARC record of their own when using the DNS Tree Walk algorithm. -
-
- {:else if dmarcRecord.psd === "n"} -
- Organizational Domain Boundary: - psd=n -
- - Org Domain declaredpsd=n explicitly declares - this as an Organizational Domain boundary. Subdomains with separate DNS - delegation will use their own independent DMARCbis Tree Walk. -
-
- {/if} - {#if dmarcRecord.subdomain_policy} {@const mainStrength = policyStrength(dmarcRecord.policy)} @@ -226,43 +142,7 @@ {/if} - - {#if dmarcRecord.nonexistent_subdomain_policy} - {@const effectiveSubStrength = policyStrength(dmarcRecord.subdomain_policy ?? dmarcRecord.policy)} - {@const npStrength = policyStrength(dmarcRecord.nonexistent_subdomain_policy)} -
- Non-Existent Subdomain Policy: - - {dmarcRecord.nonexistent_subdomain_policy} - - {#if npStrength >= effectiveSubStrength} -
- - Good configuration — non-existent subdomain policy is equal to or stricter - than the effective subdomain policy. -
- {:else} -
- - Weaker protection for non-existent subdomains — consider setting - np={dmarcRecord.subdomain_policy ?? dmarcRecord.policy} to match your subdomain policy. -
- {/if} -
- - The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis), - a draft RFC updating RFC 7489. Support may vary across mail receivers. -
-
- {/if} - - + {#if dmarcRecord.percentage !== undefined}
Enforcement Percentage: @@ -275,35 +155,25 @@ > {dmarcRecord.percentage}% -
- - Deprecated tag — the pct= tag is removed in - DMARCbis. Many receivers already ignore it. For gradual rollout, replace it - with t=y (test mode); for full enforcement, simply remove - pct= from your record. - {#if dmarcRecord.percentage === 0} -
pct=0 is an anti-pattern — it was widely misused - as a signal to bypass DMARC entirely, which is one reason the tag was - removed. Use t=y instead. - {/if} -
{#if dmarcRecord.percentage === 100}
Full enforcement — all messages are subject to DMARC policy. + This provides maximum protection.
- {:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50} + {:else if dmarcRecord.percentage >= 50}
Partial enforcement — only {dmarcRecord.percentage}% of - messages are subject to DMARC policy. Receivers ignoring pct= will apply - the full policy regardless. + messages are subject to DMARC policy. Consider increasing to + pct=100 once you've validated your configuration.
- {:else if dmarcRecord.percentage > 0} + {:else}
Low enforcement — only {dmarcRecord.percentage}% of - messages are protected. Receivers ignoring pct= will apply full policy. + messages are protected. Gradually increase to pct=100 for full + protection.
{/if}
@@ -389,30 +259,6 @@ {/if} - - {#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri} -
- - Deprecated tags detected — your record contains - {#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri} - rf= and ri= tags that are - {:else if dmarcRecord.deprecated_rf} - the rf= tag that is - {:else} - the ri= tag that is - {/if} - removed in DMARCbis. Modern receivers will ignore - {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}. - {#if dmarcRecord.deprecated_ri} - Aggregate reporting interval is now fixed at ≥ 24 hours regardless of - ri=. - {/if} - You can safely remove - {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"} - from your DMARC record. -
- {/if} - {#if dmarcRecord.error}
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 6dabe0b..b7997b0 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -165,10 +165,7 @@ {/if} - +