Skip to content

Add dns command and failure pattern breakdown to check#13

Merged
linyows merged 6 commits into
mainfrom
add-dns-command-and-check-breakdown
Apr 18, 2026
Merged

Add dns command and failure pattern breakdown to check#13
linyows merged 6 commits into
mainfrom
add-dns-command-and-check-breakdown

Conversation

@linyows
Copy link
Copy Markdown
Owner

@linyows linyows commented Apr 17, 2026

Summary

  • reports dns command: Queries and displays DMARC, SPF, DKIM, MTA-STS, and TLS-RPT DNS records for all report domains. Supports --domain filter. Tries common DKIM selectors (default, google, selector1, selector2, s1, s2, dkim, mail).
  • Failure pattern breakdown in reports check: Classifies DMARC failures into three categories with actionable hints:
    • DKIM+SPF fail — needs both DKIM and SPF setup
    • DKIM fail only — needs DKIM setup (SPF already passing)
    • SPF fail only — needs SPF setup (DKIM already passing)
  • Breakdown included in both text and JSON output formats

Example output

$ reports dns --domain example.com

example.com
  DMARC:   v=DMARC1; p=quarantine; rua=mailto:...
  SPF:     v=spf1 ip4:... -all
  DKIM:    v=DKIM1; k=rsa; p=MIIBIj... (default._domainkey)
  MTA-STS: v=STSv1; id=20260406
  TLS-RPT: v=TLSRPTv1; rua=mailto:...
$ reports check --domain example.com

CRITICAL: DMARC 290/455 messages failed (63%), TLS-RPT 0 failures

Failure breakdown:
  DKIM+SPF fail: 290 messages (needs DKIM and SPF setup)
  DKIM fail only: 165 messages (needs DKIM setup)

Test plan

  • Run reports dns and verify all 5 record types are queried for each domain
  • Run reports dns --domain <domain> and verify single-domain filter
  • Run reports check and verify failure breakdown appears with correct counts
  • Run reports check --format json and verify dkim_only_fail, spf_only_fail, both_fail fields
  • Verify DKIM selector probing finds records for domains with non-default selectors

🤖 Generated with Claude Code

linyows and others added 4 commits April 17, 2026 18:08
- 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dns command to query/display DMARC, SPF, DKIM, MTA-STS, and TLS-RPT DNS TXT records (with optional --domain filter).
  • 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.queryTxt publicly 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.

Comment thread src/main.zig
Comment on lines +360 to +372
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 |_| {}
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/main.zig
Comment on lines +1078 to +1084
// 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 },
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/stats.zig
Comment on lines +631 to +633
pub fn isDmarcPolicyWeak(dmarc_txt: []const u8) bool {
return std.mem.indexOf(u8, dmarc_txt, "p=none") != null;
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/main.zig Outdated
if (is_json) json_buf.appendSlice(allocator, "[") catch {};

for (domains.items) |domain| {
var buf: [256]u8 = undefined;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
var buf: [256]u8 = undefined;
var buf: [2048]u8 = undefined;

Copilot uses AI. Check for mistakes.
Comment thread src/main.zig
Comment on lines +348 to +357
// 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 |_| {}
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/ipinfo.zig
// --- 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 {
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/main.zig
Comment on lines +406 to +410
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;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
linyows and others added 2 commits April 18, 2026 00:05
- 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>
@linyows linyows merged commit 14df243 into main Apr 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants