Add dns command and failure pattern breakdown to check#13
Conversation
- Add 'reports dns' command: queries DMARC, SPF, DKIM, MTA-STS, TLS-RPT DNS records for all domains (or --domain filter). Tries common DKIM selectors (default, google, selector1, selector2, s1, s2, dkim, mail). - Add failure pattern breakdown to check output: classifies DMARC failures into DKIM-only fail, SPF-only fail, and both-fail with actionable hints. Included in both text and JSON output formats. - Make ipinfo.queryTxt public so dns command can use it for TXT lookups. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add --format json support to dns command with domain/status/records - Use neon colors for all icons (green=rgb(194,255,38), yellow=rgb(255,200,0), red=rgb(255,51,102)) - Use ✓/!/✗ for record status, ● for domain status - Add spacing between domain groups and indent all lines - Fix SPF may be returned as another TXT record note Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ctions - Add evaluateDnsStatus: pure function for domain health assessment - Add isDmarcPolicyWeak/isSpfWeak: policy strength checks - Add classifyFailure: DKIM/SPF failure pattern classification with label() and hint() methods for display - Delegate from dns command and check command to stats functions - Add 18 new tests covering all status/classification combinations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Self-review fix: DKIM-only or SPF-only failures should still count as DMARC pass (one passing mechanism is sufficient for DMARC). The previous refactoring to use classifyFailure accidentally dropped the dmarc_pass increment for these cases. Note: dkim_eval/spf_eval fields come from DMARC report's policy_evaluated section, which already includes alignment checks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the reports CLI with DNS inspection capabilities and adds a more detailed breakdown of DMARC auth outcomes in reports check, aiming to make misconfigurations easier to diagnose.
Changes:
- Add
reports dnscommand to query/display DMARC, SPF, DKIM, MTA-STS, and TLS-RPT DNS TXT records (with optional--domainfilter). - Add DMARC auth outcome classification (DKIM-only fail / SPF-only fail / both fail) and include it in both text and JSON output for
reports check. - Expose
ipinfo.queryTxtpublicly to support DNS TXT lookups from the CLI.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
src/stats.zig |
Adds DNS health evaluation helpers and DMARC failure classification utilities (+ tests). |
src/main.zig |
Implements dns command and integrates DMARC failure breakdown into check output (text + JSON). |
src/ipinfo.zig |
Makes queryTxt public for reuse by the new DNS command. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for ([_][]const u8{ "default", "google", "selector1", "selector2", "s1", "s2", "dkim", "mail" }) |selector| { | ||
| const qname = std.fmt.allocPrint(allocator, "{s}._domainkey.{s}", .{ selector, domain }) catch continue; | ||
| defer allocator.free(qname); | ||
| if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { | ||
| if (std.mem.indexOf(u8, txt, "DKIM1") != null or std.mem.indexOf(u8, txt, "p=") != null) { | ||
| dkim_txt = txt; | ||
| dkim_selector = selector; | ||
| break; | ||
| } else { | ||
| allocator.free(txt); | ||
| } | ||
| } else |_| {} | ||
| } |
There was a problem hiding this comment.
cmdDns can perform up to 1 (DMARC) + 1 (SPF) + 8 (DKIM selectors) + 1 (MTA-STS) + 1 (TLS-RPT) = 12 DNS lookups per domain, and queryTxt has a 3s receive timeout. In the worst case (NXDOMAIN/timeouts) this can take ~36s per domain and make the command feel hung for accounts with many domains. Consider lowering the timeout for this command, making it configurable, or short-circuiting DKIM probing after a smaller selector set unless requested.
| // Failure pattern breakdown | ||
| if (result.dkim_only_fail > 0 or result.spf_only_fail > 0 or result.both_fail > 0) { | ||
| stdout_file.writeAll("\nFailure breakdown:\n") catch {}; | ||
| const breakdown = [_]struct { ft: reports.stats.FailureType, count: u64 }{ | ||
| .{ .ft = .both_fail, .count = result.both_fail }, | ||
| .{ .ft = .dkim_only_fail, .count = result.dkim_only_fail }, | ||
| .{ .ft = .spf_only_fail, .count = result.spf_only_fail }, |
There was a problem hiding this comment.
The section header "Failure breakdown" is misleading given the logic above: dkim_only_fail / spf_only_fail are explicitly counted as DMARC passes (result.dmarc_pass += rec.count). This makes it easy to misread the output as a breakdown of DMARC failures when it is really an auth-mechanism breakdown. Consider renaming the header/labels (e.g., "Auth results breakdown") or explicitly stating that single-mechanism fails still pass DMARC.
| pub fn isDmarcPolicyWeak(dmarc_txt: []const u8) bool { | ||
| return std.mem.indexOf(u8, dmarc_txt, "p=none") != null; | ||
| } |
There was a problem hiding this comment.
isDmarcPolicyWeak uses a raw substring search for "p=none", which will also match sp=none or np=none (because both contain the substring p=none). That can incorrectly mark a strong DMARC policy (e.g., p=reject; sp=none) as weak. Consider parsing tag/value pairs and checking the p tag specifically (case-insensitive + whitespace-tolerant), and add tests covering sp=none/np=none cases.
| if (is_json) json_buf.appendSlice(allocator, "[") catch {}; | ||
|
|
||
| for (domains.items) |domain| { | ||
| var buf: [256]u8 = undefined; |
There was a problem hiding this comment.
cmdDns uses a fixed 256-byte buffer for multiple std.fmt.bufPrint calls that embed full TXT records. DMARC/SPF/MTA-STS/TLS-RPT TXT records can easily exceed 256 bytes, and on overflow you currently fall back to "", which silently drops the line from output. Consider using allocPrint (like DKIM does) or a larger buffer + explicit truncation so the user always sees something.
| var buf: [256]u8 = undefined; | |
| var buf: [2048]u8 = undefined; |
| // SPF | ||
| { | ||
| if (reports.ipinfo.queryTxt(allocator, domain)) |txt| { | ||
| if (std.mem.indexOf(u8, txt, "v=spf1") != null) { | ||
| spf_txt = txt; | ||
| } else { | ||
| allocator.free(txt); | ||
| } | ||
| } else |_| {} | ||
| } |
There was a problem hiding this comment.
SPF detection here relies on ipinfo.queryTxt(domain) returning a TXT record containing v=spf1. However queryTxt only parses the first TXT answer in the DNS response, and apex domains commonly publish many TXT records (verification tokens, etc.), so SPF may be incorrectly reported as not found. To make this reliable, queryTxt likely needs to return all TXT answers (or a helper that searches all TXT RRs for one containing v=spf1).
| // --- DNS TXT query via raw UDP --- | ||
|
|
||
| fn queryTxt(allocator: Allocator, name: []const u8) ![]const u8 { | ||
| pub fn queryTxt(allocator: Allocator, name: []const u8) ![]const u8 { |
There was a problem hiding this comment.
Now that queryTxt is public and used for general DNS lookups, returning only the first TXT RR (via parseTxtFromResponse parsing the first answer) is a surprising/incorrect contract for domains with multiple TXT records (common at the apex). Consider either updating it to collect all TXT answers (and possibly return an array/slice) or renaming/documenting it as queryFirstTxt to avoid callers assuming it searches all TXT records.
| const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"status\":\"{s}\",\"dmarc\":\"{s}\",\"spf\":\"{s}\",\"dkim\":\"{s}\",\"dkim_selector\":\"{s}\",\"mta_sts\":\"{s}\",\"tls_rpt\":\"{s}\"}}", .{ | ||
| domain, status_str, dmarc_s, spf_s, dkim_s, dkim_selector, mta_s, tls_s, | ||
| }) catch continue; | ||
| defer allocator.free(line); | ||
| json_buf.appendSlice(allocator, line) catch continue; |
There was a problem hiding this comment.
The JSON output is built with allocPrint and interpolates raw TXT record strings into JSON string values without escaping. If any record contains ", \, or control characters, this will emit invalid JSON. Consider using std.json.stringify/stringifyAlloc (or at least a JSON string escaping helper) for each field before writing.
| const line = std.fmt.allocPrint(allocator, "{{\"domain\":\"{s}\",\"status\":\"{s}\",\"dmarc\":\"{s}\",\"spf\":\"{s}\",\"dkim\":\"{s}\",\"dkim_selector\":\"{s}\",\"mta_sts\":\"{s}\",\"tls_rpt\":\"{s}\"}}", .{ | |
| domain, status_str, dmarc_s, spf_s, dkim_s, dkim_selector, mta_s, tls_s, | |
| }) catch continue; | |
| defer allocator.free(line); | |
| json_buf.appendSlice(allocator, line) catch continue; | |
| var line_buf = std.ArrayList(u8){}; | |
| defer line_buf.deinit(allocator); | |
| const writer = line_buf.writer(allocator); | |
| writer.writeAll("{\"domain\":") catch continue; | |
| std.json.stringify(domain, .{}, writer) catch continue; | |
| writer.writeAll(",\"status\":") catch continue; | |
| std.json.stringify(status_str, .{}, writer) catch continue; | |
| writer.writeAll(",\"dmarc\":") catch continue; | |
| std.json.stringify(dmarc_s, .{}, writer) catch continue; | |
| writer.writeAll(",\"spf\":") catch continue; | |
| std.json.stringify(spf_s, .{}, writer) catch continue; | |
| writer.writeAll(",\"dkim\":") catch continue; | |
| std.json.stringify(dkim_s, .{}, writer) catch continue; | |
| writer.writeAll(",\"dkim_selector\":") catch continue; | |
| std.json.stringify(dkim_selector, .{}, writer) catch continue; | |
| writer.writeAll(",\"mta_sts\":") catch continue; | |
| std.json.stringify(mta_s, .{}, writer) catch continue; | |
| writer.writeAll(",\"tls_rpt\":") catch continue; | |
| std.json.stringify(tls_s, .{}, writer) catch continue; | |
| writer.writeAll("}") catch continue; | |
| json_buf.appendSlice(allocator, line_buf.items) catch continue; |
- Fix isDmarcPolicyWeak to not match sp=none or np=none (parse p= tag only) - Add tests for sp=none/np=none edge cases - Rename "Failure breakdown" to "Auth mechanism breakdown" with clarification that single-mechanism fails still pass DMARC - Increase dns command buffer from 256 to 2048 bytes for long TXT records - JSON-escape all DNS record values in --format json output - Consolidate jsonEscape into stats.zig shared by both lib.zig and main.zig Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
reports dnscommand: Queries and displays DMARC, SPF, DKIM, MTA-STS, and TLS-RPT DNS records for all report domains. Supports--domainfilter. Tries common DKIM selectors (default, google, selector1, selector2, s1, s2, dkim, mail).reports check: Classifies DMARC failures into three categories with actionable hints:DKIM+SPF fail— needs both DKIM and SPF setupDKIM fail only— needs DKIM setup (SPF already passing)SPF fail only— needs SPF setup (DKIM already passing)Example output
Test plan
reports dnsand verify all 5 record types are queried for each domainreports dns --domain <domain>and verify single-domain filterreports checkand verify failure breakdown appears with correct countsreports check --format jsonand verifydkim_only_fail,spf_only_fail,both_failfields🤖 Generated with Claude Code