diff --git a/api/openapi.yaml b/api/openapi.yaml index c78c71b..0432da1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -267,10 +267,8 @@ components: $ref: '#/components/schemas/AuthenticationResults' spamassassin: $ref: '#/components/schemas/SpamAssassinResult' - dns_records: - type: array - items: - $ref: '#/components/schemas/DNSRecord' + dns_results: + $ref: '#/components/schemas/DNSResults' blacklists: type: object additionalProperties: @@ -694,31 +692,168 @@ components: type: string description: Full SpamAssassin report - DNSRecord: + DNSResults: type: object required: - domain - - record_type - - status properties: domain: type: string description: Domain name example: "example.com" - record_type: + mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the domain + spf_record: + $ref: '#/components/schemas/SPFRecord' + dkim_records: + type: array + items: + $ref: '#/components/schemas/DKIMRecord' + description: DKIM records found + dmarc_record: + $ref: '#/components/schemas/DMARCRecord' + bimi_record: + $ref: '#/components/schemas/BIMIRecord' + errors: + type: array + items: + type: string + description: DNS lookup errors + + MXRecord: + type: object + required: + - host + - priority + - valid + properties: + host: type: string - enum: [MX, SPF, DKIM, DMARC, BIMI] - description: DNS record type - example: "SPF" - status: + description: MX hostname + example: "mail.example.com" + priority: + type: integer + format: uint16 + description: MX priority (lower is higher priority) + example: 10 + valid: + type: boolean + description: Whether the MX record is valid + example: true + error: type: string - enum: [found, missing, invalid] - description: Record status - example: "found" - value: + description: Error message if validation failed + example: "Failed to lookup MX records" + + SPFRecord: + type: object + required: + - valid + properties: + record: type: string - description: Record value + description: SPF record content example: "v=spf1 include:_spf.example.com ~all" + valid: + type: boolean + description: Whether the SPF record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No SPF record found" + + DKIMRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: DKIM selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: DKIM record content + example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." + valid: + type: boolean + description: Whether the DKIM record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DKIM record found" + + DMARCRecord: + type: object + required: + - valid + properties: + record: + type: string + description: DMARC record content + example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + + BIMIRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" BlacklistCheck: type: object diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 0f7c111..27566cf 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -51,79 +51,25 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { } } -// DNSResults represents DNS validation results for an email -type DNSResults struct { - Domain string - MXRecords []MXRecord - SPFRecord *SPFRecord - DKIMRecords []DKIMRecord - DMARCRecord *DMARCRecord - BIMIRecord *BIMIRecord - Errors []string -} - -// MXRecord represents an MX record -type MXRecord struct { - Host string - Priority uint16 - Valid bool - Error string -} - -// SPFRecord represents an SPF record -type SPFRecord struct { - Record string - Valid bool - Error string -} - -// DKIMRecord represents a DKIM record -type DKIMRecord struct { - Selector string - Domain string - Record string - Valid bool - Error string -} - -// DMARCRecord represents a DMARC record -type DMARCRecord struct { - Record string - Policy string // none, quarantine, reject - Valid bool - Error string -} - -// BIMIRecord represents a BIMI record -type BIMIRecord struct { - Selector string - Domain string - Record string - LogoURL string // URL to the brand logo (SVG) - VMCURL string // URL to Verified Mark Certificate (optional) - Valid bool - Error string -} - // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults { // Extract domain from From address domain := d.extractDomain(email) if domain == "" { - return &DNSResults{ - Errors: []string{"Unable to extract domain from email"}, + return &api.DNSResults{ + Errors: &[]string{"Unable to extract domain from email"}, } } - results := &DNSResults{ + results := &api.DNSResults{ Domain: domain, } // Check MX records - results.MXRecords = d.checkMXRecords(domain) + results.MxRecords = d.checkMXRecords(domain) // Check SPF record - results.SPFRecord = d.checkSPFRecord(domain) + results.SpfRecord = d.checkSPFRecord(domain) // Check DKIM records (from authentication results) if authResults != nil && authResults.Dkim != nil { @@ -131,17 +77,20 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic if dkim.Domain != nil && dkim.Selector != nil { dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) if dkimRecord != nil { - results.DKIMRecords = append(results.DKIMRecords, *dkimRecord) + if results.DkimRecords == nil { + results.DkimRecords = new([]api.DKIMRecord) + } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } } } // Check DMARC record - results.DMARCRecord = d.checkDMARCRecord(domain) + results.DmarcRecord = d.checkDMARCRecord(domain) // Check BIMI record (using default selector) - results.BIMIRecord = d.checkBIMIRecord(domain, "default") + results.BimiRecord = d.checkBIMIRecord(domain, "default") return results } @@ -158,51 +107,51 @@ func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { } // checkMXRecords looks up MX records for a domain -func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord { +func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() mxRecords, err := d.resolver.LookupMX(ctx, domain) if err != nil { - return []MXRecord{ + return &[]api.MXRecord{ { Valid: false, - Error: fmt.Sprintf("Failed to lookup MX records: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)), }, } } if len(mxRecords) == 0 { - return []MXRecord{ + return &[]api.MXRecord{ { Valid: false, - Error: "No MX records found", + Error: api.PtrTo("No MX records found"), }, } } - var results []MXRecord + var results []api.MXRecord for _, mx := range mxRecords { - results = append(results, MXRecord{ + results = append(results, api.MXRecord{ Host: mx.Host, Priority: mx.Pref, Valid: true, }) } - return results + return &results } // checkSPFRecord looks up and validates SPF record for a domain -func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { +func (d *DNSAnalyzer) checkSPFRecord(domain string) *api.SPFRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, domain) if err != nil { - return &SPFRecord{ + return &api.SPFRecord{ Valid: false, - Error: fmt.Sprintf("Failed to lookup TXT records: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)), } } @@ -217,31 +166,31 @@ func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { } if spfCount == 0 { - return &SPFRecord{ + return &api.SPFRecord{ Valid: false, - Error: "No SPF record found", + Error: api.PtrTo("No SPF record found"), } } if spfCount > 1 { - return &SPFRecord{ - Record: spfRecord, + return &api.SPFRecord{ + Record: &spfRecord, Valid: false, - Error: "Multiple SPF records found (RFC violation)", + Error: api.PtrTo("Multiple SPF records found (RFC violation)"), } } // Basic validation if !d.validateSPF(spfRecord) { - return &SPFRecord{ - Record: spfRecord, + return &api.SPFRecord{ + Record: &spfRecord, Valid: false, - Error: "SPF record appears malformed", + Error: api.PtrTo("SPF record appears malformed"), } } - return &SPFRecord{ - Record: spfRecord, + return &api.SPFRecord{ + Record: &spfRecord, Valid: true, } } @@ -267,8 +216,8 @@ func (d *DNSAnalyzer) validateSPF(record string) bool { return hasValidEnding } -// checkDKIMRecord looks up and validates DKIM record for a domain and selector -func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { +// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector +func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // DKIM records are at: selector._domainkey.domain dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) @@ -277,20 +226,20 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) if err != nil { - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, Valid: false, - Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), } } if len(txtRecords) == 0 { - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, Valid: false, - Error: "No DKIM record found", + Error: api.PtrTo("No DKIM record found"), } } @@ -299,19 +248,19 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { // Basic validation - should contain "v=DKIM1" and "p=" (public key) if !d.validateDKIM(dkimRecord) { - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, - Record: dkimRecord, + Record: api.PtrTo(dkimRecord), Valid: false, - Error: "DKIM record appears malformed", + Error: api.PtrTo("DKIM record appears malformed"), } } - return &DKIMRecord{ + return &api.DKIMRecord{ Selector: selector, Domain: domain, - Record: dkimRecord, + Record: &dkimRecord, Valid: true, } } @@ -332,8 +281,8 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool { return true } -// checkDMARCRecord looks up and validates DMARC record for a domain -func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { +// checkapi.DMARCRecord looks up and validates DMARC record for a domain +func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord { // DMARC records are at: _dmarc.domain dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) @@ -342,9 +291,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) if err != nil { - return &DMARCRecord{ + return &api.DMARCRecord{ Valid: false, - Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)), } } @@ -358,9 +307,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { } if dmarcRecord == "" { - return &DMARCRecord{ + return &api.DMARCRecord{ Valid: false, - Error: "No DMARC record found", + Error: api.PtrTo("No DMARC record found"), } } @@ -369,17 +318,17 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { // Basic validation if !d.validateDMARC(dmarcRecord) { - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), Valid: false, - Error: "DMARC record appears malformed", + Error: api.PtrTo("DMARC record appears malformed"), } } - return &DMARCRecord{ - Record: dmarcRecord, - Policy: policy, + return &api.DMARCRecord{ + Record: &dmarcRecord, + Policy: api.PtrTo(api.DMARCRecordPolicy(policy)), Valid: true, } } @@ -411,7 +360,7 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool { } // checkBIMIRecord looks up and validates BIMI record for a domain and selector -func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord { // BIMI records are at: selector._bimi.domain bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) @@ -420,20 +369,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) if err != nil { - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, Valid: false, - Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), + Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)), } } if len(txtRecords) == 0 { - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, Valid: false, - Error: "No BIMI record found", + Error: api.PtrTo("No BIMI record found"), } } @@ -446,23 +395,23 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) if !d.validateBIMI(bimiRecord) { - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, - Record: bimiRecord, - LogoURL: logoURL, - VMCURL: vmcURL, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, Valid: false, - Error: "BIMI record appears malformed", + Error: api.PtrTo("BIMI record appears malformed"), } } - return &BIMIRecord{ + return &api.BIMIRecord{ Selector: selector, Domain: domain, - Record: bimiRecord, - LogoURL: logoURL, - VMCURL: vmcURL, + Record: &bimiRecord, + LogoUrl: &logoURL, + VmcUrl: &vmcURL, Valid: true, } } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 135dee1..853b393 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -62,7 +62,7 @@ type AnalysisResults struct { Email *EmailMessage Authentication *api.AuthenticationResults Content *ContentResults - DNS *DNSResults + DNS *api.DNSResults Headers *api.HeaderAnalysis RBL *RBLResults SpamAssassin *SpamAssassinResult @@ -141,10 +141,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu // Add DNS records if results.DNS != nil { - dnsRecords := r.buildDNSRecords(results.DNS) - if len(dnsRecords) > 0 { - report.DnsRecords = &dnsRecords - } + report.DnsResults = results.DNS } // Add headers results @@ -204,118 +201,6 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu return report } -// buildDNSRecords converts DNS analysis results to API DNS records -func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord { - records := []api.DNSRecord{} - - if dns == nil { - return records - } - - // MX records - if len(dns.MXRecords) > 0 { - for _, mx := range dns.MXRecords { - status := api.Found - if !mx.Valid { - if mx.Error != "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.MX, - Status: status, - } - - if mx.Host != "" { - value := mx.Host - record.Value = &value - } - - records = append(records, record) - } - } - - // SPF record - if dns.SPFRecord != nil { - status := api.Found - if !dns.SPFRecord.Valid { - if dns.SPFRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.SPF, - Status: status, - } - - if dns.SPFRecord.Record != "" { - record.Value = &dns.SPFRecord.Record - } - - records = append(records, record) - } - - // DKIM records - for _, dkim := range dns.DKIMRecords { - status := api.Found - if !dkim.Valid { - if dkim.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dkim.Domain, - RecordType: api.DKIM, - Status: status, - } - - if dkim.Record != "" { - // Include selector in value for clarity - value := dkim.Record - record.Value = &value - } - - records = append(records, record) - } - - // DMARC record - if dns.DMARCRecord != nil { - status := api.Found - if !dns.DMARCRecord.Valid { - if dns.DMARCRecord.Record == "" { - status = api.Missing - } else { - status = api.Invalid - } - } - - record := api.DNSRecord{ - Domain: dns.Domain, - RecordType: api.DMARC, - Status: status, - } - - if dns.DMARCRecord.Record != "" { - record.Value = &dns.DMARCRecord.Record - } - - records = append(records, record) - } - - return records -} - // GenerateRawEmail returns the raw email message as a string func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { if email == nil { diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 79272f5..7c624ed 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,11 +1,11 @@
| Domain | -Type | -Status | -Value | -
|---|---|---|---|
{record.domain} |
- {record.record_type} | -- - {record.status} - - | -{record.value || '-'} | -
| Priority | +Host | +Status | +
|---|---|---|
| {mx.priority} | +{mx.host} |
+
+ {#if mx.valid}
+ Valid
+ {:else}
+ Invalid
+ {#if mx.error}
+ {mx.error} + {/if} + {/if} + |
+
{dnsResults.spf_record.record}
+ {dkim.selector}
+ Domain: {dkim.domain}
+ {dkim.record}
+ {dnsResults.dmarc_record.record}
+ {dnsResults.bimi_record.selector}
+ Domain: {dnsResults.bimi_record.domain}
+ {dnsResults.bimi_record.record}
+