From 1516991057ba4a4b5a3b602a87d0d438020bc933 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 15:33:27 +0800 Subject: [PATCH 1/7] dmarc: implement RFC 7489 org-domain fallback and RFC 9091 PSD DMARC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DMARC lookup now follows the full RFC 7489 §6.6.3 fallback chain: exact From domain → organizational domain (eTLD+1 via PSL) → public suffix domain (RFC 9091, only when psd=y is present). DNS errors abort immediately without triggering fallback; NXDOMAIN and missing v=DMARC1 records do trigger it. The found domain is exposed in the new DMARCRecord.domain field for reporting purposes. Also promote getOrganizationalDomain to a package-level function so both HeaderAnalyzer and DNSAnalyzer can share it, and fix pre-existing rbl_test.go compilation errors and stale score expectations. Closes: https://git.nemunai.re/happyDomain/happyDeliver/issues/98 --- api/schemas.yaml | 4 + pkg/analyzer/dns_dmarc.go | 114 ++++++++---- pkg/analyzer/dns_dmarc_test.go | 167 +++++++++++++++++- pkg/analyzer/headers.go | 8 +- pkg/analyzer/rbl_test.go | 30 ++-- .../lib/components/DmarcRecordDisplay.svelte | 26 ++- web/src/lib/components/DnsRecordsCard.svelte | 5 +- 7 files changed, 296 insertions(+), 58 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index df0b416..fa908c4 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -891,6 +891,10 @@ 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] diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 686fb0b..913b98a 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -24,62 +24,50 @@ package analyzer import ( "context" "fmt" + "net" "regexp" "strings" + "golang.org/x/net/publicsuffix" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) -// 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, 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)), + 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 } - // Find DMARC record (starts with "v=DMARC1") - var dmarcRecord string for _, txt := range txtRecords { if strings.HasPrefix(txt, "v=DMARC1") { - dmarcRecord = txt - break + return txt, false, nil } } + return "", true, nil +} - if dmarcRecord == "" { +// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model. +func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { + policy := d.extractDMARCPolicy(rawRecord) + subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) + percentage := d.extractDMARCPercentage(rawRecord) + spfAlignment := d.extractDMARCSPFAlignment(rawRecord) + dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) + + if !d.validateDMARC(rawRecord) { return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo("No DMARC record found"), - } - } - - // 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, + Domain: &foundDomain, + Record: &rawRecord, Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), SubdomainPolicy: subdomainPolicy, Percentage: percentage, @@ -91,7 +79,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { } return &model.DMARCRecord{ - Record: &dmarcRecord, + Domain: &foundDomain, + Record: &rawRecord, Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), SubdomainPolicy: subdomainPolicy, Percentage: percentage, @@ -101,6 +90,55 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { } } +// checkDMARCRecord looks up and validates the DMARC record for a domain. +// It follows RFC 7489 §6.6.3 fallback to the Organizational Domain and +// RFC 9091 optional fallback to the Public Suffix Domain (only when psd=y). +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { + // Step 1: try exact domain (_dmarc.) + raw, notFound, err := d.lookupDMARCAt(domain) + if err != nil { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + if !notFound { + return d.parseDMARCRecord(domain, raw) + } + + // Step 2: RFC 7489 — fall back to Organizational Domain (eTLD+1) + orgDomain := getOrganizationalDomain(domain) + if orgDomain != domain { + raw, notFound, err = d.lookupDMARCAt(orgDomain) + if err != nil { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + } + } + if !notFound { + return d.parseDMARCRecord(orgDomain, raw) + } + } + + // Step 3: RFC 9091 — fall back to Public Suffix Domain when psd=y + psd, _ := publicsuffix.PublicSuffix(domain) + if psd != "" && psd != orgDomain { + raw, notFound, err = d.lookupDMARCAt(psd) + if err == nil && !notFound { + // Only apply PSD DMARC when the record explicitly opts in with psd=y + if strings.Contains(raw, "psd=y") { + return d.parseDMARCRecord(psd, raw) + } + } + } + + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo("No DMARC record found"), + } +} + // extractDMARCPolicy extracts the policy from a DMARC record func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { // Look for p=none, p=quarantine, or p=reject diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 93f4511..ed1c25d 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -22,13 +22,178 @@ 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 — falls back to 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 — falls back to 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 has psd=y — RFC 9091 fallback", + 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 — no record returned", + 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 — no fallback, 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 — no redundant fallback", + domain: "example.com", + txt: map[string][]string{ + "_dmarc.example.com": {orgRecord}, + }, + wantValid: true, + wantDomain: utils.PtrTo("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 TestExtractDMARCPolicy(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index f750742..6d7b547 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 := h.getOrganizationalDomain(domain) + orgDomain := 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 := h.getOrganizationalDomain(domain) + orgDomain := 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 := h.getOrganizationalDomain(domain) + orgDomain := 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 (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { +func 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_test.go b/pkg/analyzer/rbl_test.go index 8620038..f86f17b 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -291,34 +291,38 @@ func TestGetBlacklistScore(t *testing.T) { { name: "Listed on 1 RBL", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 1, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, + RelevantListedCount: 1, }, - expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) + expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational) }, { name: "Listed on 2 RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 2, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + RelevantListedCount: 2, }, - expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) + expectedScore: 84, // 100 - 2*100/12 = 84 }, { name: "Listed on 3 RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 3, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 3, + RelevantListedCount: 3, }, - expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) + expectedScore: 75, // 100 - 3*100/12 = 75 }, { name: "Listed on 4+ RBLs", results: &DNSListResults{ - IPsChecked: []string{"198.51.100.1"}, - ListedCount: 4, + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, + RelevantListedCount: 4, }, - expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) + expectedScore: 67, // 100 - 4*100/12 = 67 }, } @@ -326,7 +330,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateScore(tt.results) + score, _ := checker.CalculateScore(tt.results, false) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index b7a3e7b..6f3b4a3 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -3,9 +3,15 @@ interface Props { dmarcRecord?: DmarcRecord; + fromDomain?: string; } - let { dmarcRecord }: Props = $props(); + let { dmarcRecord, fromDomain }: Props = $props(); + + const isFallback = $derived( + !!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain, + ); + const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes(".")); // Helper function to determine policy strength const policyStrength = (policy: string | undefined): number => { @@ -52,6 +58,24 @@ {/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} per RFC 9091. + {:else} + the organizational domain {dmarcRecord.domain} per RFC 7489. + {/if} +
+
+ {/if} + {#if dmarcRecord.policy}
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index b7997b0..6dabe0b 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -165,7 +165,10 @@ {/if} - + From 3161e392e849af4fd68c7e2a21a2bdf610a896c5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 16:03:35 +0800 Subject: [PATCH 2/7] dmarc: add support for np= non-existent subdomain policy tag Implements parsing, scoring, CLI output, and UI display for the DMARC np= tag (DMARCbis draft-ietf-dmarc-dmarcbis), which controls policy for NXDOMAIN subdomains independently of sp=. The score deducts 15 points from the base and awards them back when np= is absent (good default) or its strength is equal to or stricter than the effective sp=/p= policy. --- api/schemas.yaml | 5 + internal/app/cli_analyzer.go | 3 + pkg/analyzer/dns_dmarc.go | 92 ++++++++++++------- pkg/analyzer/dns_dmarc_test.go | 54 +++++++++++ .../lib/components/DmarcRecordDisplay.svelte | 36 ++++++++ 5 files changed, 159 insertions(+), 31 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index fa908c4..025ddc8 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -905,6 +905,11 @@ 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 + example: "reject" percentage: type: integer minimum: 0 diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index d8336a5..c704c56 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -202,6 +202,9 @@ 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/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 913b98a..b327a04 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -60,33 +60,36 @@ func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { policy := d.extractDMARCPolicy(rawRecord) subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) + nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord) percentage := d.extractDMARCPercentage(rawRecord) spfAlignment := d.extractDMARCSPFAlignment(rawRecord) dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) if !d.validateDMARC(rawRecord) { return &model.DMARCRecord{ - Domain: &foundDomain, - Record: &rawRecord, - Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: false, - Error: utils.PtrTo("DMARC record appears malformed"), + Domain: &foundDomain, + Record: &rawRecord, + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + NonexistentSubdomainPolicy: nonexistentSubdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: false, + Error: utils.PtrTo("DMARC record appears malformed"), } } return &model.DMARCRecord{ - Domain: &foundDomain, - Record: &rawRecord, - Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: true, + Domain: &foundDomain, + Record: &rawRecord, + Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), + SubdomainPolicy: subdomainPolicy, + NonexistentSubdomainPolicy: nonexistentSubdomainPolicy, + Percentage: percentage, + SpfAlignment: spfAlignment, + DkimAlignment: dkimAlignment, + Valid: true, } } @@ -98,8 +101,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { raw, notFound, err := d.lookupDMARCAt(domain) if err != nil { return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } if !notFound { @@ -112,8 +115,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { raw, notFound, err = d.lookupDMARCAt(orgDomain) if err != nil { return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } if !notFound { @@ -134,8 +137,8 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { } return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo("No DMARC record found"), + Valid: false, + Error: utils.PtrTo("No DMARC record found"), } } @@ -196,6 +199,18 @@ func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRec return nil } +// extractDMARCNonexistentSubdomainPolicy extracts non-existent subdomain policy from a DMARC record. +// Returns the np tag value or nil if not specified (defaults to effective sp/p policy). +// The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis). +func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *model.DMARCRecordNonexistentSubdomainPolicy { + re := regexp.MustCompile(`np=(none|quarantine|reject)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(matches[1])) + } + 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 { @@ -244,26 +259,25 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) case "quarantine": // Good policy - no deduction case "none": - // Weakest policy - deduct 5 points + // Weakest policy - deduct 25 points score -= 25 } } - // Bonus points for strict alignment modes (2 points each) + // Bonus points for strict alignment modes (5 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 } + // Policy strength: none < quarantine < reject + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + mainPolicy := string(*results.DmarcRecord.Policy) + // Subdomain policy scoring (sp tag) - // +3 for stricter or equal subdomain policy, -3 for weaker + // +15 for stricter or equal subdomain policy, -15 for weaker if results.DmarcRecord.SubdomainPolicy != nil { - mainPolicy := string(*results.DmarcRecord.Policy) subPolicy := string(*results.DmarcRecord.SubdomainPolicy) - - // Policy strength: none < quarantine < reject - policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} - mainStrength := policyStrength[mainPolicy] subStrength := policyStrength[subPolicy] @@ -278,6 +292,22 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) // No sp tag means subdomains inherit main policy (good default) score += 15 } + // Non-existent subdomain policy scoring (np tag, DMARCbis) + // -15 from base; +15 back if absent (good default) or >= effective sp/p strength + score -= 15 + effectiveSubPolicy := mainPolicy + if results.DmarcRecord.SubdomainPolicy != nil { + effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy) + } + if results.DmarcRecord.NonexistentSubdomainPolicy == nil { + score += 15 + } else { + npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] + effectiveStrength := policyStrength[effectiveSubPolicy] + if npStrength >= effectiveStrength { + score += 15 + } + } // Percentage scoring (pct tag) // Apply the percentage on the current score if results.DmarcRecord.Percentage != nil { diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index ed1c25d..1455028 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -439,6 +439,60 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) { } } +func TestExtractDMARCNonexistentSubdomainPolicy(t *testing.T) { + tests := []struct { + name string + record string + expectedPolicy *string + }{ + { + name: "Non-existent subdomain policy - none", + record: "v=DMARC1; p=quarantine; np=none", + expectedPolicy: utils.PtrTo("none"), + }, + { + name: "Non-existent subdomain policy - quarantine", + record: "v=DMARC1; p=reject; np=quarantine", + expectedPolicy: utils.PtrTo("quarantine"), + }, + { + name: "Non-existent subdomain policy - reject", + record: "v=DMARC1; p=quarantine; np=reject", + expectedPolicy: utils.PtrTo("reject"), + }, + { + name: "No np tag (defaults to effective sp/p policy)", + record: "v=DMARC1; p=quarantine", + expectedPolicy: nil, + }, + { + name: "Complex record with np and sp tags", + record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", + expectedPolicy: utils.PtrTo("reject"), + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractDMARCNonexistentSubdomainPolicy(tt.record) + if tt.expectedPolicy == nil { + if result != nil { + t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %v, want nil", tt.record, result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCNonexistentSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + } + if string(*result) != *tt.expectedPolicy { + t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + } + } + }) + } +} + func TestExtractDMARCPercentage(t *testing.T) { tests := []struct { name string diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index 6f3b4a3..9b4d900 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -166,6 +166,42 @@
{/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}
From 369a13526f7c4b818e627d1b1a0924bd1554bbd3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 16:51:30 +0800 Subject: [PATCH 3/7] analyzer: correct auth scoring weights, x-aligned-from penalty, and RBL divide-by-zero --- pkg/analyzer/authentication.go | 4 +--- pkg/analyzer/authentication_test.go | 10 +++++----- pkg/analyzer/authentication_x_aligned_from.go | 8 ++++---- pkg/analyzer/authentication_x_aligned_from_test.go | 8 ++++---- pkg/analyzer/rbl.go | 2 +- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index da31b1c..bd8880d 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -174,9 +174,7 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut score += 12 * a.calculateXGoogleDKIMScore(results) / 100 // Penalty-only: X-Aligned-From (up to -5 points on failure) - if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { - score += 5 * (xAlignedScore - 100) / 100 - } + score += 5 * a.calculateXAlignedFromScore(results) / 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 44c1abb..0b17bf0 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: 73, // SPF=25 + DKIM=23 + DMARC=25 + expectedScore: 90, // SPF=30 + DKIM=30 + DMARC=30 }, { name: "SPF and DKIM only", @@ -59,7 +59,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: model.AuthResultResultPass}, }, }, - expectedScore: 48, // SPF=25 + DKIM=23 + expectedScore: 60, // SPF=30 + DKIM=30 }, { name: "SPF fail, DKIM pass", @@ -71,7 +71,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: model.AuthResultResultPass}, }, }, - expectedScore: 23, // SPF=0 + DKIM=23 + expectedScore: 30, // SPF=0 + DKIM=30 }, { name: "SPF softfail", @@ -80,7 +80,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: model.AuthResultResultSoftfail, }, }, - expectedScore: 4, + expectedScore: 5, // 30 * 17 / 100 = 5 }, { name: "No authentication", @@ -97,7 +97,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: model.AuthResultResultPass, }, }, - expectedScore: 35, // SPF (25) + BIMI (10) + expectedScore: 40, // SPF (30) + BIMI (10) }, } diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index ec1571c..45c2e2e 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: positive contribution - return 100 + // pass: no impact + return 0 case model.AuthResultResultFail: // fail: negative contribution - return 0 + return -100 default: // neutral, none, etc.: no impact return 0 } } - return 100 + return 0 } diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 1ea6d1c..ee90c0d 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 positive score", + name: "pass result gives no penalty", result: &model.AuthResult{ Result: model.AuthResultResultPass, }, - expectedScore: 100, + expectedScore: 0, }, { - name: "fail result gives zero score", + name: "fail result gives full penalty", result: &model.AuthResult{ Result: model.AuthResultResultFail, }, - expectedScore: 0, + expectedScore: -100, }, { name: "neutral result gives zero score", diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 7dea559..31cccab 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 { + if results.ListedCount <= 0 || scoringListCount <= 0 { return 100, "A+" } From 1b8627ef864d6727ac3d66d6ffa314f10d3dd8f1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 17:15:48 +0800 Subject: [PATCH 4/7] dkim: expose algorithm, hash list, and key size in DKIM record analysis Parse k=, h=, a= tags and derive RSA key bit-length from the public key so consumers can detect weak configurations (SHA-1, short keys). Scoring now penalises rsa-sha1 (cap 60), RSA <1024 bit (cap 25), and RSA <2048 bit (cap 75); Ed25519 receives no penalty. Fixes: https://git.nemunai.re/happyDomain/happyDeliver/issues/37 --- api/schemas.yaml | 18 +++ pkg/analyzer/dns.go | 2 +- pkg/analyzer/dns_dkim.go | 199 ++++++++++++++++++++++++++-------- pkg/analyzer/dns_dkim_test.go | 171 +++++++++++++++++++++++++---- 4 files changed, 321 insertions(+), 69 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index 025ddc8..0116246 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -873,6 +873,24 @@ 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 diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 3210dd1..6bc7c39 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.Domain, sig.Selector) + dkimRecord := d.checkDKIMRecord(sig) 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 2ae03cb..115e347 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -23,6 +23,8 @@ package analyzer import ( "context" + "crypto/x509" + "encoding/base64" "fmt" "strings" @@ -30,17 +32,18 @@ import ( "git.happydns.org/happyDeliver/internal/utils" ) -// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. +// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header. type DKIMHeader struct { - Domain string - Selector string + Domain string + Selector string + Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256) } -// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. +// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values. func parseDKIMSignatures(signatures []string) []DKIMHeader { var results []DKIMHeader for _, sig := range signatures { - var domain, selector string + var domain, selector, algorithm string for _, part := range strings.Split(sig, ";") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { @@ -53,19 +56,61 @@ 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}) + results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm}) } } return results } -// 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) +// 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) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() @@ -73,53 +118,83 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) if err != nil { return &model.DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), + Selector: h.Selector, + Domain: h.Domain, + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + Valid: false, + Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), } } if len(txtRecords) == 0 { return &model.DKIMRecord{ - Selector: selector, - Domain: domain, - Valid: false, - Error: utils.PtrTo("No DKIM record found"), + Selector: h.Selector, + Domain: h.Domain, + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + 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: selector, - Domain: domain, - Record: utils.PtrTo(dkimRecord), - Valid: false, - Error: utils.PtrTo("DKIM record appears malformed"), + Selector: h.Selector, + Domain: h.Domain, + Record: utils.PtrTo(dkimRecord), + SigningAlgorithm: signingAlgorithmPtr(h.Algorithm), + 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: selector, - Domain: domain, - Record: &dkimRecord, - Valid: true, + 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, } } -// validateDKIM performs basic DKIM record validation +func signingAlgorithmPtr(a string) *string { + if a == "" { + return nil + } + return &a +} + +// 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 } - // Often contains v=DKIM1 but not required - // If v= is present, it should be DKIM1 + // If v= is present, it must be DKIM1 if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { return false } @@ -128,21 +203,57 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool { } func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) { - // 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 + 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 } } - if hasValidDKIM { - score += 100 - } else { - // Partial credit if DKIM record exists but has issues - score += 25 + + // Key size penalties apply only to RSA + keyType := "" + if dkim.KeyType != nil { + keyType = strings.ToLower(*dkim.KeyType) } + 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 45da53c..40e28a5 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -22,6 +22,10 @@ package analyzer import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" "testing" "time" ) @@ -47,56 +51,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"}}, + expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "example.net", Selector: "mail", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}}, }, { name: "Multiple signatures (ESP double-signing)", @@ -105,8 +109,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"}, - {Domain: "sendib.com", Selector: "mail"}, + {Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"}, + {Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"}, }, }, { @@ -116,8 +120,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"}, - {Domain: "football.example.com", Selector: "test"}, + {Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}, + {Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}, }, }, { @@ -127,8 +131,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"}, - {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, + {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"}, }, }, { @@ -136,56 +140,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"}}, + expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm", Algorithm: "rsa-sha1"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing", Algorithm: "rsa-sha256"}}, }, { 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"}}, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}}, }, { name: "Missing d= tag", @@ -216,8 +220,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"}, - {Domain: "also-good.com", Selector: "sel2"}, + {Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"}, + {Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"}, }, }, } @@ -235,6 +239,9 @@ 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) + } } }) } @@ -284,3 +291,119 @@ 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 } From 809bca02e44b6a13e24e1871ffa13e4a0639ddde Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 20:40:38 +0800 Subject: [PATCH 5/7] dmarc: implement DMARCbis DNS Tree Walk and new tag support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace RFC 7489 PSL-based org-domain lookup and RFC 9091 PSD DMARC fallback with the DMARCbis DNS Tree Walk algorithm (max 8 queries, 8-label shortcut, TLD records require psd=y). Add parsing for the new t= (test mode), psd= (y/n/u), and deprecated tag detection (pct, rf, ri). Update validateDMARC to accept p=-absent records with rua= per DMARCbis §4.7. Score t=y by downgrading effective policy one level. Surface user-facing advisories in DmarcRecordDisplay: deprecation warnings for pct=/rf=/ri=, test mode explanation with per-policy impact, and PSD/org-domain boundary notices. --- api/schemas.yaml | 25 +- pkg/analyzer/dns_dmarc.go | 332 ++++++++++-------- pkg/analyzer/dns_dmarc_test.go | 170 ++++++++- .../lib/components/DmarcRecordDisplay.svelte | 114 +++++- 4 files changed, 482 insertions(+), 159 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index 0116246..53aa297 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -926,14 +926,35 @@ components: 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 + 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) + description: "Percentage of messages subjected to filtering (pct tag, default 100). DEPRECATED in DMARCbis: use test_mode (t=y) instead." 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/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index b327a04..28548ea 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -28,8 +28,6 @@ import ( "regexp" "strings" - "golang.org/x/net/publicsuffix" - "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) @@ -62,84 +60,102 @@ func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMA subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord) percentage := d.extractDMARCPercentage(rawRecord) + testMode := d.extractDMARCTestMode(rawRecord) + psd := d.extractDMARCPSD(rawRecord) spfAlignment := d.extractDMARCSPFAlignment(rawRecord) dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) + deprecatedPct := percentage != nil + deprecatedRf := d.hasDMARCTag(rawRecord, "rf") + deprecatedRi := d.hasDMARCTag(rawRecord, "ri") - if !d.validateDMARC(rawRecord) { - return &model.DMARCRecord{ - Domain: &foundDomain, - Record: &rawRecord, - Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)), - SubdomainPolicy: subdomainPolicy, - NonexistentSubdomainPolicy: nonexistentSubdomainPolicy, - Percentage: percentage, - SpfAlignment: spfAlignment, - DkimAlignment: dkimAlignment, - Valid: false, - Error: utils.PtrTo("DMARC record appears malformed"), - } - } - - return &model.DMARCRecord{ + 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, - Valid: true, } + if deprecatedPct { + rec.DeprecatedPct = utils.PtrTo(true) + } + if deprecatedRf { + rec.DeprecatedRf = utils.PtrTo(true) + } + if deprecatedRi { + 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 } -// checkDMARCRecord looks up and validates the DMARC record for a domain. -// It follows RFC 7489 §6.6.3 fallback to the Organizational Domain and -// RFC 9091 optional fallback to the Public Suffix Domain (only when psd=y). +// 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 { - // Step 1: try exact domain (_dmarc.) - raw, notFound, err := d.lookupDMARCAt(domain) + raw, foundDomain, err := d.walkDNSForDMARC(domain) if err != nil { return &model.DMARCRecord{ Valid: false, Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } - if !notFound { - return d.parseDMARCRecord(domain, raw) - } - - // Step 2: RFC 7489 — fall back to Organizational Domain (eTLD+1) - orgDomain := getOrganizationalDomain(domain) - if orgDomain != domain { - raw, notFound, err = d.lookupDMARCAt(orgDomain) - if err != nil { - return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), - } - } - if !notFound { - return d.parseDMARCRecord(orgDomain, raw) + if foundDomain == "" { + return &model.DMARCRecord{ + Valid: false, + Error: utils.PtrTo("No DMARC record found"), } } - - // Step 3: RFC 9091 — fall back to Public Suffix Domain when psd=y - psd, _ := publicsuffix.PublicSuffix(domain) - if psd != "" && psd != orgDomain { - raw, notFound, err = d.lookupDMARCAt(psd) - if err == nil && !notFound { - // Only apply PSD DMARC when the record explicitly opts in with psd=y - if strings.Contains(raw, "psd=y") { - return d.parseDMARCRecord(psd, raw) - } - } - } - - return &model.DMARCRecord{ - Valid: false, - Error: utils.PtrTo("No DMARC record found"), - } + return d.parseDMARCRecord(foundDomain, raw) } // extractDMARCPolicy extracts the policy from a DMARC record @@ -211,114 +227,156 @@ func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *mod return nil } -// extractDMARCPercentage extracts the percentage from a DMARC record -// Returns the pct tag value or nil if not specified (defaults to 100) +// extractDMARCPercentage extracts the percentage from a DMARC record. +// Returns the pct tag value or nil if not specified (defaults to 100). +// Note: pct= is deprecated in DMARCbis; use t= (test_mode) instead. 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 +// extractDMARCTestMode extracts the DMARCbis t= tag (test mode). +// Returns true for t=y, false for t=n, nil if absent (defaults to false / full enforcement). +func (d *DNSAnalyzer) extractDMARCTestMode(record string) *bool { + re := regexp.MustCompile(`(?:^|;)\s*t=(y|n)(?:;|$|\s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + v := matches[1] == "y" + return &v + } + return nil +} + +// extractDMARCPSD extracts the DMARCbis psd= tag value as a typed enum. +// Returns nil if the tag is absent (defaults to "u" / unknown). +func (d *DNSAnalyzer) extractDMARCPSD(record string) *model.DMARCRecordPsd { + v := d.extractDMARCPSDValue(record) + if v == "" { + return nil + } + return utils.PtrTo(model.DMARCRecordPsd(v)) +} + +// extractDMARCPSDValue returns the raw string value of psd= ("y", "n", "u") or "". +func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string { + re := regexp.MustCompile(`(?:^|;)\s*psd=(y|n|u)(?:;|$|\s)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return matches[1] + } + return "" +} + +// hasDMARCTag reports whether the given tag name appears in the record. +func (d *DNSAnalyzer) hasDMARCTag(record, tag string) bool { + re := regexp.MustCompile(`(?:^|;)\s*` + regexp.QuoteMeta(tag) + `=`) + return re.MatchString(record) +} + +// 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. func (d *DNSAnalyzer) validateDMARC(record string) bool { - // Must start with v=DMARC1 if !strings.HasPrefix(record, "v=DMARC1") { return false } - // Must have a policy tag + // p= absent is allowed in DMARCbis when rua= is present (treated as p=none). if !strings.Contains(record, "p=") { - return false + return strings.Contains(record, "rua=") } return true } func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) { - // 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 25 points - score -= 25 - } - } - // Bonus points for strict alignment modes (5 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 - } - // Policy strength: none < quarantine < reject - policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} - mainPolicy := string(*results.DmarcRecord.Policy) + if results.DmarcRecord == nil { + return + } - // Subdomain policy scoring (sp tag) - // +15 for stricter or equal subdomain policy, -15 for weaker - if results.DmarcRecord.SubdomainPolicy != nil { - subPolicy := string(*results.DmarcRecord.SubdomainPolicy) - 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 - } - // Non-existent subdomain policy scoring (np tag, DMARCbis) - // -15 from base; +15 back if absent (good default) or >= effective sp/p strength - score -= 15 - effectiveSubPolicy := mainPolicy - if results.DmarcRecord.SubdomainPolicy != nil { - effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy) - } - if results.DmarcRecord.NonexistentSubdomainPolicy == nil { - score += 15 - } else { - npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] - effectiveStrength := policyStrength[effectiveSubPolicy] - if npStrength >= effectiveStrength { - 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 + if !results.DmarcRecord.Valid { + if results.DmarcRecord.Record != nil { + // Partial credit if a 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 + } + + policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} + + // Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker + if results.DmarcRecord.SubdomainPolicy != nil { + subPolicy := string(*results.DmarcRecord.SubdomainPolicy) + if policyStrength[subPolicy] >= policyStrength[effectivePolicy] { + score += 15 + } else { + score -= 15 + } + } else { + score += 15 // inherits main policy — good default + } + + // Non-existent subdomain policy scoring (np tag, DMARCbis) + score -= 15 + 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 { + npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] + if npStrength >= policyStrength[effectiveSubPolicy] { + 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 1455028..46a3518 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -90,7 +90,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) { wantDomain: utils.PtrTo("mail.example.com"), }, { - name: "exact domain NXDOMAIN — falls back to org domain", + name: "exact domain NXDOMAIN — tree walk reaches org domain", domain: "mail.example.com", txt: map[string][]string{ "_dmarc.example.com": {orgRecord}, @@ -99,7 +99,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) { wantDomain: utils.PtrTo("example.com"), }, { - name: "exact domain has no v=DMARC1 TXT — falls back to org domain", + 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"}, @@ -109,7 +109,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) { wantDomain: utils.PtrTo("example.com"), }, { - name: "both exact and org NXDOMAIN but PSD has psd=y — RFC 9091 fallback", + 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}, @@ -118,7 +118,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) { wantDomain: utils.PtrTo("com"), }, { - name: "PSD record exists but no psd=y — no record returned", + 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"}, @@ -127,14 +127,14 @@ func TestCheckDMARCRecordFallback(t *testing.T) { wantErrSubst: "No DMARC record found", }, { - name: "no record at any level", - domain: "mail.example.com", - txt: map[string][]string{}, + 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 — no fallback, error returned", + name: "DNS error on exact domain — error returned", domain: "mail.example.com", errMap: map[string]error{ "_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"), @@ -143,7 +143,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) { wantErrSubst: "SERVFAIL", }, { - name: "domain already at org level — no redundant fallback", + name: "domain already at org level — found immediately", domain: "example.com", txt: map[string][]string{ "_dmarc.example.com": {orgRecord}, @@ -151,6 +151,33 @@ func TestCheckDMARCRecordFallback(t *testing.T) { 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 { @@ -234,6 +261,124 @@ func TestExtractDMARCPolicy(t *testing.T) { } } +func TestExtractDMARCTestMode(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.extractDMARCTestMode(tt.record) + if tt.wantMode == nil { + if result != nil { + t.Errorf("extractDMARCTestMode(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCTestMode(%q) = nil, want %v", tt.record, *tt.wantMode) + } + if *result != *tt.wantMode { + t.Errorf("extractDMARCTestMode(%q) = %v, want %v", tt.record, *result, *tt.wantMode) + } + } + }) + } +} + +func TestExtractDMARCPSD(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.extractDMARCPSD(tt.record) + if tt.wantPSD == nil { + if result != nil { + t.Errorf("extractDMARCPSD(%q) = %v, want nil", tt.record, *result) + } + } else { + if result == nil { + t.Fatalf("extractDMARCPSD(%q) = nil, want %q", tt.record, *tt.wantPSD) + } + if string(*result) != *tt.wantPSD { + t.Errorf("extractDMARCPSD(%q) = %q, want %q", tt.record, string(*result), *tt.wantPSD) + } + } + }) + } +} + +func TestHasDMARCTag(t *testing.T) { + tests := []struct { + name string + record string + tag string + want bool + }{ + {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", tag: "rf", want: true}, + {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", tag: "ri", want: true}, + {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", tag: "rf", want: false}, + {name: "ri tag absent", record: "v=DMARC1; p=quarantine", tag: "ri", want: false}, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.hasDMARCTag(tt.record, tt.tag) + if result != tt.want { + t.Errorf("hasDMARCTag(%q, %q) = %v, want %v", tt.record, tt.tag, result, tt.want) + } + }) + } +} + func TestValidateDMARC(t *testing.T) { tests := []struct { name string @@ -250,13 +395,18 @@ 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", + name: "Invalid DMARC - no policy and no rua", record: "v=DMARC1", expected: false, }, diff --git a/web/src/lib/components/DmarcRecordDisplay.svelte b/web/src/lib/components/DmarcRecordDisplay.svelte index 9b4d900..e2b83f0 100644 --- a/web/src/lib/components/DmarcRecordDisplay.svelte +++ b/web/src/lib/components/DmarcRecordDisplay.svelte @@ -11,6 +11,7 @@ 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(".")); // Helper function to determine policy strength @@ -18,6 +19,15 @@ 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,9 +78,12 @@ No DMARC record exists for {fromDomain}. The record above was inherited from {#if isPsdFallback} - the Public Suffix Domain {dmarcRecord.domain} per RFC 9091. + 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} per RFC 7489. + the organizational domain {dmarcRecord.domain} via the + DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain + fallback). {/if}
@@ -123,6 +136,53 @@ {/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)} @@ -202,7 +262,7 @@ {/if} - + {#if dmarcRecord.percentage !== undefined}
Enforcement Percentage: @@ -215,25 +275,35 @@ > {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 >= 50} + {:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
Partial enforcement — only {dmarcRecord.percentage}% of - messages are subject to DMARC policy. Consider increasing to - pct=100 once you've validated your configuration. + messages are subject to DMARC policy. Receivers ignoring pct= will apply + the full policy regardless.
- {:else} + {:else if dmarcRecord.percentage > 0}
Low enforcement — only {dmarcRecord.percentage}% of - messages are protected. Gradually increase to pct=100 for full - protection. + messages are protected. Receivers ignoring pct= will apply full policy.
{/if}
@@ -319,6 +389,30 @@ {/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}
From b3b1a094dec4e6d4a8d766918c246f99e4553e07 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 20:57:47 +0800 Subject: [PATCH 6/7] dmarc: refactor parseDMARCRecord to use shared tag parser and eliminate helper methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-field regex extractor methods with a single parseDKIMTags call, removing eight redundant private methods and unifying DMARC tag parsing with the existing DKIM tag parser. Tests are updated to drive through parseDMARCRecord instead of the removed helpers, and the NP scoring logic is corrected to award +15/−15 symmetrically like the SP scoring path. --- pkg/analyzer/dns_dmarc.go | 215 +++++++------------- pkg/analyzer/dns_dmarc_test.go | 349 +++++++++++---------------------- 2 files changed, 187 insertions(+), 377 deletions(-) diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 28548ea..b89500b 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -25,13 +25,15 @@ import ( "context" "fmt" "net" - "regexp" + "strconv" "strings" "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) +var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2} + // 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) { @@ -56,17 +58,62 @@ func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool // parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model. func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { - policy := d.extractDMARCPolicy(rawRecord) - subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) - nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord) - percentage := d.extractDMARCPercentage(rawRecord) - testMode := d.extractDMARCTestMode(rawRecord) - psd := d.extractDMARCPSD(rawRecord) - spfAlignment := d.extractDMARCSPFAlignment(rawRecord) - dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) - deprecatedPct := percentage != nil - deprecatedRf := d.hasDMARCTag(rawRecord, "rf") - deprecatedRi := d.hasDMARCTag(rawRecord, "ri") + 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, @@ -80,13 +127,13 @@ func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMA SpfAlignment: spfAlignment, DkimAlignment: dkimAlignment, } - if deprecatedPct { + if percentage != nil { rec.DeprecatedPct = utils.PtrTo(true) } - if deprecatedRf { + if _, ok := tags["rf"]; ok { rec.DeprecatedRf = utils.PtrTo(true) } - if deprecatedRi { + if _, ok := tags["ri"]; ok { rec.DeprecatedRi = utils.PtrTo(true) } @@ -158,129 +205,17 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { return d.parseDMARCRecord(foundDomain, raw) } -// 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 -} - -// extractDMARCNonexistentSubdomainPolicy extracts non-existent subdomain policy from a DMARC record. -// Returns the np tag value or nil if not specified (defaults to effective sp/p policy). -// The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis). -func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *model.DMARCRecordNonexistentSubdomainPolicy { - re := regexp.MustCompile(`np=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(matches[1])) - } - return nil -} - -// extractDMARCPercentage extracts the percentage from a DMARC record. -// Returns the pct tag value or nil if not specified (defaults to 100). -// Note: pct= is deprecated in DMARCbis; use t= (test_mode) instead. -func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { - re := regexp.MustCompile(`pct=(\d+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - var pct int - fmt.Sscanf(matches[1], "%d", &pct) - if pct >= 0 && pct <= 100 { - return &pct - } - } - return nil -} - -// extractDMARCTestMode extracts the DMARCbis t= tag (test mode). -// Returns true for t=y, false for t=n, nil if absent (defaults to false / full enforcement). -func (d *DNSAnalyzer) extractDMARCTestMode(record string) *bool { - re := regexp.MustCompile(`(?:^|;)\s*t=(y|n)(?:;|$|\s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - v := matches[1] == "y" - return &v - } - return nil -} - -// extractDMARCPSD extracts the DMARCbis psd= tag value as a typed enum. -// Returns nil if the tag is absent (defaults to "u" / unknown). -func (d *DNSAnalyzer) extractDMARCPSD(record string) *model.DMARCRecordPsd { - v := d.extractDMARCPSDValue(record) - if v == "" { - return nil - } - return utils.PtrTo(model.DMARCRecordPsd(v)) -} - -// extractDMARCPSDValue returns the raw string value of psd= ("y", "n", "u") or "". +// 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 { - re := regexp.MustCompile(`(?:^|;)\s*psd=(y|n|u)(?:;|$|\s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] + v := parseDKIMTags(record)["psd"] + switch v { + case "y", "n", "u": + return v } return "" } -// hasDMARCTag reports whether the given tag name appears in the record. -func (d *DNSAnalyzer) hasDMARCTag(record, tag string) bool { - re := regexp.MustCompile(`(?:^|;)\s*` + regexp.QuoteMeta(tag) + `=`) - return re.MatchString(record) -} - // 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. @@ -343,12 +278,10 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) score += 5 } - policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} - // Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker if results.DmarcRecord.SubdomainPolicy != nil { subPolicy := string(*results.DmarcRecord.SubdomainPolicy) - if policyStrength[subPolicy] >= policyStrength[effectivePolicy] { + if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] { score += 15 } else { score -= 15 @@ -357,19 +290,17 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) score += 15 // inherits main policy — good default } - // Non-existent subdomain policy scoring (np tag, DMARCbis) - score -= 15 + // 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 { - npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] - if npStrength >= policyStrength[effectiveSubPolicy] { - score += 15 - } + score -= 15 } // pct= scaling (deprecated in DMARCbis, kept for backward compatibility). diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 46a3518..5c34a32 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -221,7 +221,7 @@ func containsStr(s, sub string) bool { return false } -func TestExtractDMARCPolicy(t *testing.T) { +func TestParseDMARCRecordPolicy(t *testing.T) { tests := []struct { name string record string @@ -253,15 +253,18 @@ func TestExtractDMARCPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPolicy(tt.record) - if result != tt.expectedPolicy { - t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) + 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 TestExtractDMARCTestMode(t *testing.T) { +func TestParseDMARCRecordTestMode(t *testing.T) { tests := []struct { name string record string @@ -288,24 +291,24 @@ func TestExtractDMARCTestMode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCTestMode(tt.record) + result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode if tt.wantMode == nil { if result != nil { - t.Errorf("extractDMARCTestMode(%q) = %v, want nil", tt.record, *result) + t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("extractDMARCTestMode(%q) = nil, want %v", tt.record, *tt.wantMode) + t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode) } if *result != *tt.wantMode { - t.Errorf("extractDMARCTestMode(%q) = %v, want %v", tt.record, *result, *tt.wantMode) + t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode) } } }) } } -func TestExtractDMARCPSD(t *testing.T) { +func TestParseDMARCRecordPSD(t *testing.T) { tests := []struct { name string record string @@ -337,43 +340,48 @@ func TestExtractDMARCPSD(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPSD(tt.record) + result := analyzer.parseDMARCRecord("example.com", tt.record).Psd if tt.wantPSD == nil { if result != nil { - t.Errorf("extractDMARCPSD(%q) = %v, want nil", tt.record, *result) + t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("extractDMARCPSD(%q) = nil, want %q", tt.record, *tt.wantPSD) + t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD) } if string(*result) != *tt.wantPSD { - t.Errorf("extractDMARCPSD(%q) = %q, want %q", tt.record, string(*result), *tt.wantPSD) + t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD) } } }) } } -func TestHasDMARCTag(t *testing.T) { +func TestParseDMARCRecordDeprecatedTags(t *testing.T) { tests := []struct { - name string - record string - tag string - want bool + name string + record string + wantRf bool + wantRi bool }{ - {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", tag: "rf", want: true}, - {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", tag: "ri", want: true}, - {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", tag: "rf", want: false}, - {name: "ri tag absent", record: "v=DMARC1; p=quarantine", tag: "ri", want: false}, + {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) { - result := analyzer.hasDMARCTag(tt.record, tt.tag) - if result != tt.want { - t.Errorf("hasDMARCTag(%q, %q) = %v, want %v", tt.record, tt.tag, result, tt.want) + 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) } }) } @@ -429,142 +437,36 @@ func TestValidateDMARC(t *testing.T) { } } -func TestExtractDMARCSPFAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "SPF alignment - strict", - record: "v=DMARC1; p=quarantine; aspf=s", - expectedAlignment: "strict", - }, - { - name: "SPF alignment - relaxed (explicit)", - record: "v=DMARC1; p=quarantine; aspf=r", - expectedAlignment: "relaxed", - }, - { - name: "SPF alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=quarantine", - expectedAlignment: "relaxed", - }, - { - 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", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSPFAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCDKIMAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "DKIM alignment - strict", - record: "v=DMARC1; p=reject; adkim=s", - expectedAlignment: "strict", - }, - { - name: "DKIM alignment - relaxed (explicit)", - record: "v=DMARC1; p=reject; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "DKIM alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=none", - expectedAlignment: "relaxed", - }, - { - 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", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCDKIMAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCSubdomainPolicy(t *testing.T) { +func TestParseDMARCRecordAlignment(t *testing.T) { tests := []struct { name string record string - expectedPolicy *string + expectedSPF string + expectedDKIM string }{ { - name: "Subdomain policy - none", - record: "v=DMARC1; p=quarantine; sp=none", - expectedPolicy: utils.PtrTo("none"), + name: "SPF strict, DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedSPF: "strict", + expectedDKIM: "relaxed", }, { - name: "Subdomain policy - quarantine", - record: "v=DMARC1; p=reject; sp=quarantine", - expectedPolicy: utils.PtrTo("quarantine"), + name: "SPF relaxed explicit, DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedSPF: "relaxed", + expectedDKIM: "strict", }, { - name: "Subdomain policy - reject", - record: "v=DMARC1; p=quarantine; sp=reject", - expectedPolicy: utils.PtrTo("reject"), + name: "Defaults when neither specified", + record: "v=DMARC1; p=quarantine", + expectedSPF: "relaxed", + expectedDKIM: "relaxed", }, { - 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"), + 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", }, } @@ -572,53 +474,53 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) { 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 result == nil { - t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) - } - if string(*result) != *tt.expectedPolicy { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) - } + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if rec.SpfAlignment == nil { + t.Fatalf("parseDMARCRecord(%q).SpfAlignment = 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) } }) } } -func TestExtractDMARCNonexistentSubdomainPolicy(t *testing.T) { +func TestParseDMARCRecordSubdomainPolicy(t *testing.T) { tests := []struct { name string record string - expectedPolicy *string + expectedSP *string + expectedNP *string }{ { - name: "Non-existent subdomain policy - none", - record: "v=DMARC1; p=quarantine; np=none", - expectedPolicy: utils.PtrTo("none"), + name: "sp=none, no np", + record: "v=DMARC1; p=quarantine; sp=none", + expectedSP: utils.PtrTo("none"), + expectedNP: nil, }, { - name: "Non-existent subdomain policy - quarantine", - record: "v=DMARC1; p=reject; np=quarantine", - expectedPolicy: utils.PtrTo("quarantine"), + 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: "Non-existent subdomain policy - reject", - record: "v=DMARC1; p=quarantine; np=reject", - expectedPolicy: utils.PtrTo("reject"), + name: "No sp or np (both default)", + record: "v=DMARC1; p=quarantine", + expectedSP: nil, + expectedNP: nil, }, { - name: "No np tag (defaults to effective sp/p policy)", - record: "v=DMARC1; p=quarantine", - expectedPolicy: nil, - }, - { - name: "Complex record with np and sp tags", - record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", - expectedPolicy: utils.PtrTo("reject"), + name: "np=quarantine, no sp", + record: "v=DMARC1; p=reject; np=quarantine", + expectedSP: nil, + expectedNP: utils.PtrTo("quarantine"), }, } @@ -626,86 +528,63 @@ func TestExtractDMARCNonexistentSubdomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCNonexistentSubdomainPolicy(tt.record) - if tt.expectedPolicy == nil { - if result != nil { - t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %v, want nil", tt.record, result) + 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 result == nil { - t.Fatalf("extractDMARCNonexistentSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + if rec.SubdomainPolicy == nil { + t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP) } - if string(*result) != *tt.expectedPolicy { - t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + if string(*rec.SubdomainPolicy) != *tt.expectedSP { + t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP) + } + } + if tt.expectedNP == nil { + if rec.NonexistentSubdomainPolicy != nil { + t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy) + } + } else { + if rec.NonexistentSubdomainPolicy == nil { + t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP) + } + if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP { + t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP) } } }) } } -func TestExtractDMARCPercentage(t *testing.T) { +func TestParseDMARCRecordPercentage(t *testing.T) { tests := []struct { name string record string expectedPercentage *int }{ - { - 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, - }, + {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}, } analyzer := NewDNSAnalyzer(5 * time.Second) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPercentage(tt.record) + result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage if tt.expectedPercentage == nil { if result != nil { - t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage) } if *result != *tt.expectedPercentage { - t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage) } } }) From 6fd3ce912c91fd4fa9a9f3d1746e0cc537b5decc Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 19 May 2026 10:07:04 +0000 Subject: [PATCH 7/7] chore(deps): update dependency vitest to v4 --- web/package-lock.json | 1447 ++++------------------------------------- web/package.json | 2 +- 2 files changed, 128 insertions(+), 1321 deletions(-) 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",