From 0a3644e7d01359e9d26282678078b6d6a61289ed Mon Sep 17 00:00:00 2001 From: linyows Date: Fri, 17 Apr 2026 18:08:13 +0900 Subject: [PATCH 1/6] Add dns command and failure pattern breakdown to check command - 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) --- src/ipinfo.zig | 2 +- src/main.zig | 152 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/ipinfo.zig b/src/ipinfo.zig index 6d21422..82024d6 100644 --- a/src/ipinfo.zig +++ b/src/ipinfo.zig @@ -302,7 +302,7 @@ fn parseCymruAsnOrg(allocator: Allocator, txt: []const u8) ?[]const u8 { // --- 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 { const ns_ip = getNameserver(allocator) catch try allocator.dupe(u8, "8.8.8.8"); defer allocator.free(ns_ip); diff --git a/src/main.zig b/src/main.zig index 67e15e3..070b9cb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -56,6 +56,8 @@ pub fn main() !void { return; } try cmdShow(allocator, args[2], format orelse "table", enrich); + } else if (std.mem.eql(u8, command, "dns")) { + try cmdDns(allocator, domain); } else if (std.mem.eql(u8, command, "domains")) { try cmdDomains(allocator, format orelse "table", account); } else if (std.mem.eql(u8, command, "summary")) { @@ -277,6 +279,121 @@ fn fetchForAccount(allocator: std.mem.Allocator, acct: *const Config.Account, da return .{ .dmarc = counts.dmarc, .tls = counts.tls }; } +fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8) !void { + const cfg = try Config.load(allocator); + defer cfg.deinit(allocator); + + // Collect domains from reports (or use filter) + var domains: std.ArrayList([]const u8) = .empty; + defer { + for (domains.items) |d| allocator.free(d); + domains.deinit(allocator); + } + + if (domain_filter) |d| { + domains.append(allocator, allocator.dupe(u8, d) catch return) catch {}; + } else { + const entries = try loadEntries(allocator, &cfg, null); + defer reports.store.freeReportEntries(allocator, entries); + + var domain_set = std.StringHashMap(void).init(allocator); + defer domain_set.deinit(); + for (entries) |e| { + if (e.domain.len > 0) domain_set.put(e.domain, {}) catch {}; + } + var it = domain_set.iterator(); + while (it.next()) |kv| { + domains.append(allocator, allocator.dupe(u8, kv.key_ptr.*) catch continue) catch {}; + } + } + + for (domains.items) |domain| { + var buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&buf, "\n{s}\n", .{domain}) catch ""; + stdout_file.writeAll(hdr) catch {}; + + // DMARC record + { + const qname = std.fmt.allocPrint(allocator, "_dmarc.{s}", .{domain}) catch continue; + defer allocator.free(qname); + if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { + defer allocator.free(txt); + const msg = std.fmt.bufPrint(&buf, " DMARC: {s}\n", .{txt}) catch ""; + stdout_file.writeAll(msg) catch {}; + } else |_| { + stdout_file.writeAll(" DMARC: (not found)\n") catch {}; + } + } + + // SPF record — try up to 3 times since queryTxt returns one TXT at a time + // and SPF may not be the first TXT record for the domain + { + // Use dig-style approach: query and check if result contains v=spf1 + var spf_found = false; + if (reports.ipinfo.queryTxt(allocator, domain)) |txt| { + defer allocator.free(txt); + if (std.mem.indexOf(u8, txt, "v=spf1") != null) { + const msg = std.fmt.bufPrint(&buf, " SPF: {s}\n", .{txt}) catch ""; + stdout_file.writeAll(msg) catch {}; + spf_found = true; + } + } else |_| {} + if (!spf_found) { + stdout_file.writeAll(" SPF: (not found — may exist as another TXT record)\n") catch {}; + } + } + + // DKIM record (try common selectors) + { + var found = false; + 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| { + defer allocator.free(txt); + if (std.mem.indexOf(u8, txt, "DKIM1") != null or std.mem.indexOf(u8, txt, "p=") != null) { + const trunc = if (txt.len > 60) txt[0..60] else txt; + const msg = std.fmt.allocPrint(allocator, " DKIM: {s}... ({s}._domainkey)\n", .{ trunc, selector }) catch continue; + defer allocator.free(msg); + stdout_file.writeAll(msg) catch {}; + found = true; + break; + } + } else |_| {} + } + if (!found) { + stdout_file.writeAll(" DKIM: (not found)\n") catch {}; + } + } + + // MTA-STS record + { + const qname = std.fmt.allocPrint(allocator, "_mta-sts.{s}", .{domain}) catch continue; + defer allocator.free(qname); + if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { + defer allocator.free(txt); + const msg = std.fmt.bufPrint(&buf, " MTA-STS: {s}\n", .{txt}) catch ""; + stdout_file.writeAll(msg) catch {}; + } else |_| { + stdout_file.writeAll(" MTA-STS: (not found)\n") catch {}; + } + } + + // TLS-RPT record + { + const qname = std.fmt.allocPrint(allocator, "_smtp._tls.{s}", .{domain}) catch continue; + defer allocator.free(qname); + if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { + defer allocator.free(txt); + const msg = std.fmt.bufPrint(&buf, " TLS-RPT: {s}\n", .{txt}) catch ""; + stdout_file.writeAll(msg) catch {}; + } else |_| { + stdout_file.writeAll(" TLS-RPT: (not found)\n") catch {}; + } + } + } +} + fn cmdDomains(allocator: std.mem.Allocator, format: []const u8, account: ?[]const u8) !void { const cfg = try Config.load(allocator); defer cfg.deinit(allocator); @@ -631,6 +748,10 @@ const CheckResult = struct { dmarc_total: u64 = 0, dmarc_pass: u64 = 0, dmarc_fail: u64 = 0, + // Failure pattern breakdown + dkim_only_fail: u64 = 0, // DKIM fail, SPF pass + spf_only_fail: u64 = 0, // DKIM pass, SPF fail + both_fail: u64 = 0, // Both DKIM and SPF fail // TLS-RPT tls_reports: u64 = 0, tls_total_success: u64 = 0, @@ -708,9 +829,16 @@ fn cmdCheck( result.dmarc_total += rec.count; const dkim_pass = std.mem.eql(u8, rec.dkim_eval, "pass"); const spf_pass = std.mem.eql(u8, rec.spf_eval, "pass"); - if (dkim_pass or spf_pass) { + if (dkim_pass and spf_pass) { result.dmarc_pass += rec.count; + } else if (dkim_pass and !spf_pass) { + result.dmarc_pass += rec.count; // DKIM pass is enough + result.spf_only_fail += rec.count; + } else if (!dkim_pass and spf_pass) { + result.dmarc_pass += rec.count; // SPF pass is enough + result.dkim_only_fail += rec.count; } else { + result.both_fail += rec.count; result.dmarc_fail += rec.count; dmarc_fails.append(allocator, .{ .account = entry.account_name, @@ -859,6 +987,23 @@ fn writeCheckText( }) catch ""; stdout_file.writeAll(status_msg) catch {}; + // 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 {}; + if (result.both_fail > 0) { + const msg = std.fmt.bufPrint(&buf, " DKIM+SPF fail: {d} messages (needs DKIM and SPF setup)\n", .{result.both_fail}) catch ""; + stdout_file.writeAll(msg) catch {}; + } + if (result.dkim_only_fail > 0) { + const msg = std.fmt.bufPrint(&buf, " DKIM fail only: {d} messages (needs DKIM setup)\n", .{result.dkim_only_fail}) catch ""; + stdout_file.writeAll(msg) catch {}; + } + if (result.spf_only_fail > 0) { + const msg = std.fmt.bufPrint(&buf, " SPF fail only: {d} messages (needs SPF setup)\n", .{result.spf_only_fail}) catch ""; + stdout_file.writeAll(msg) catch {}; + } + } + if (stale) { const stale_msg = std.fmt.bufPrint(&buf, "WARNING: No reports received in the last {d} days (latest: {s})\n", .{ max_age, if (result.latest_date.len > 0) result.latest_date else "none", @@ -932,8 +1077,8 @@ fn writeCheckJson( try buf.appendSlice(allocator, header); const dmarc_part = try std.fmt.allocPrint(allocator, - \\"dmarc":{{"reports":{d},"total":{d},"pass":{d},"fail":{d},"fail_rate":{d}}}, - , .{ result.dmarc_reports, result.dmarc_total, result.dmarc_pass, result.dmarc_fail, dmarc_fail_rate }); + \\"dmarc":{{"reports":{d},"total":{d},"pass":{d},"fail":{d},"fail_rate":{d},"dkim_only_fail":{d},"spf_only_fail":{d},"both_fail":{d}}}, + , .{ result.dmarc_reports, result.dmarc_total, result.dmarc_pass, result.dmarc_fail, dmarc_fail_rate, result.dkim_only_fail, result.spf_only_fail, result.both_fail }); defer allocator.free(dmarc_part); try buf.appendSlice(allocator, dmarc_part); @@ -1630,6 +1775,7 @@ fn printUsage() void { \\ show Show report details \\ summary Show summary statistics \\ check Check for anomalies (exit 0=OK, 1=WARN, 2=CRIT) + \\ dns Show DNS records (DMARC/SPF/DKIM/MTA-STS/TLS-RPT) \\ domains List domains \\ version Show version \\ help Show this help From 91d298ed114e05689c46542e6f6d8787c36c3e37 Mon Sep 17 00:00:00 2001 From: linyows Date: Fri, 17 Apr 2026 18:32:03 +0900 Subject: [PATCH 2/6] Add JSON output format and improve dns command display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/main.zig | 200 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 142 insertions(+), 58 deletions(-) diff --git a/src/main.zig b/src/main.zig index 070b9cb..f1c673e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -57,7 +57,7 @@ pub fn main() !void { } try cmdShow(allocator, args[2], format orelse "table", enrich); } else if (std.mem.eql(u8, command, "dns")) { - try cmdDns(allocator, domain); + try cmdDns(allocator, domain, format orelse "text"); } else if (std.mem.eql(u8, command, "domains")) { try cmdDomains(allocator, format orelse "table", account); } else if (std.mem.eql(u8, command, "summary")) { @@ -279,7 +279,7 @@ fn fetchForAccount(allocator: std.mem.Allocator, acct: *const Config.Account, da return .{ .dmarc = counts.dmarc, .tls = counts.tls }; } -fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8) !void { +fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []const u8) !void { const cfg = try Config.load(allocator); defer cfg.deinit(allocator); @@ -307,91 +307,175 @@ fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8) !void { } } + const is_json = std.mem.eql(u8, format, "json"); + + const icon_green = "\x1b[38;2;194;255;38m●\x1b[0m"; + const icon_yellow = "\x1b[38;2;255;200;0m●\x1b[0m"; + const icon_red = "\x1b[38;2;255;51;102m●\x1b[0m"; + const check_green = "\x1b[38;2;194;255;38m✓\x1b[0m "; + const check_yellow = "\x1b[38;2;255;200;0m!\x1b[0m "; + const check_red = "\x1b[38;2;255;51;102m✗\x1b[0m "; + const not_found = "\x1b[2m(not found)\x1b[0m"; + + var json_buf: std.ArrayList(u8) = .empty; + defer json_buf.deinit(allocator); + var json_first = true; + if (is_json) json_buf.appendSlice(allocator, "[") catch {}; + for (domains.items) |domain| { var buf: [256]u8 = undefined; - const hdr = std.fmt.bufPrint(&buf, "\n{s}\n", .{domain}) catch ""; - stdout_file.writeAll(hdr) catch {}; - // DMARC record + // Query all records first to determine domain status + var dmarc_txt: ?[]const u8 = null; + defer if (dmarc_txt) |t| allocator.free(t); + var spf_txt: ?[]const u8 = null; + defer if (spf_txt) |t| allocator.free(t); + var dkim_txt: ?[]const u8 = null; + defer if (dkim_txt) |t| allocator.free(t); + var dkim_selector: []const u8 = ""; + var mta_sts_txt: ?[]const u8 = null; + defer if (mta_sts_txt) |t| allocator.free(t); + var tls_rpt_txt: ?[]const u8 = null; + defer if (tls_rpt_txt) |t| allocator.free(t); + + // DMARC { const qname = std.fmt.allocPrint(allocator, "_dmarc.{s}", .{domain}) catch continue; defer allocator.free(qname); - if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { - defer allocator.free(txt); - const msg = std.fmt.bufPrint(&buf, " DMARC: {s}\n", .{txt}) catch ""; - stdout_file.writeAll(msg) catch {}; - } else |_| { - stdout_file.writeAll(" DMARC: (not found)\n") catch {}; - } + dmarc_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; } - // SPF record — try up to 3 times since queryTxt returns one TXT at a time - // and SPF may not be the first TXT record for the domain + // SPF { - // Use dig-style approach: query and check if result contains v=spf1 - var spf_found = false; if (reports.ipinfo.queryTxt(allocator, domain)) |txt| { - defer allocator.free(txt); if (std.mem.indexOf(u8, txt, "v=spf1") != null) { - const msg = std.fmt.bufPrint(&buf, " SPF: {s}\n", .{txt}) catch ""; - stdout_file.writeAll(msg) catch {}; - spf_found = true; + spf_txt = txt; + } else { + allocator.free(txt); } } else |_| {} - if (!spf_found) { - stdout_file.writeAll(" SPF: (not found — may exist as another TXT record)\n") catch {}; - } } - // DKIM record (try common selectors) - { - var found = false; - 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| { - defer allocator.free(txt); - if (std.mem.indexOf(u8, txt, "DKIM1") != null or std.mem.indexOf(u8, txt, "p=") != null) { - const trunc = if (txt.len > 60) txt[0..60] else txt; - const msg = std.fmt.allocPrint(allocator, " DKIM: {s}... ({s}._domainkey)\n", .{ trunc, selector }) catch continue; - defer allocator.free(msg); - stdout_file.writeAll(msg) catch {}; - found = true; - break; - } - } else |_| {} - } - if (!found) { - stdout_file.writeAll(" DKIM: (not found)\n") catch {}; - } + // DKIM + 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 |_| {} } - // MTA-STS record + // MTA-STS { const qname = std.fmt.allocPrint(allocator, "_mta-sts.{s}", .{domain}) catch continue; defer allocator.free(qname); - if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { - defer allocator.free(txt); - const msg = std.fmt.bufPrint(&buf, " MTA-STS: {s}\n", .{txt}) catch ""; - stdout_file.writeAll(msg) catch {}; - } else |_| { - stdout_file.writeAll(" MTA-STS: (not found)\n") catch {}; - } + mta_sts_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; } - // TLS-RPT record + // TLS-RPT { const qname = std.fmt.allocPrint(allocator, "_smtp._tls.{s}", .{domain}) catch continue; defer allocator.free(qname); - if (reports.ipinfo.queryTxt(allocator, qname)) |txt| { - defer allocator.free(txt); - const msg = std.fmt.bufPrint(&buf, " TLS-RPT: {s}\n", .{txt}) catch ""; + tls_rpt_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; + } + + const dmarc_policy_weak = if (dmarc_txt) |t| std.mem.indexOf(u8, t, "p=none") != null else false; + const spf_weak = if (spf_txt) |t| std.mem.indexOf(u8, t, "~all") != null else false; + const has_dmarc = dmarc_txt != null; + const has_spf = spf_txt != null; + const has_dkim = dkim_txt != null; + + if (is_json) { + if (!json_first) json_buf.appendSlice(allocator, ",") catch continue; + json_first = false; + + const status_str: []const u8 = if (!has_dmarc or !has_spf or !has_dkim) "critical" else if (dmarc_policy_weak or spf_weak) "warning" else "ok"; + const dmarc_s = if (dmarc_txt) |t| t else ""; + const spf_s = if (spf_txt) |t| t else ""; + const dkim_s = if (dkim_txt) |t| t else ""; + const mta_s = if (mta_sts_txt) |t| t else ""; + const tls_s = if (tls_rpt_txt) |t| t else ""; + + 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; + } else { + const icon = if (!has_dmarc or !has_spf or !has_dkim) + icon_red + else if (dmarc_policy_weak or spf_weak) + icon_yellow + else + icon_green; + + // Print domain header + const hdr = std.fmt.bufPrint(&buf, " {s} {s}\n", .{ icon, domain }) catch ""; + stdout_file.writeAll(hdr) catch {}; + + // DMARC + if (dmarc_txt) |txt| { + const ci = if (dmarc_policy_weak) check_yellow else check_green; + const msg = std.fmt.bufPrint(&buf, " {s} DMARC: {s}\n", .{ ci, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} DMARC: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // SPF + if (spf_txt) |txt| { + const ci = if (spf_weak) check_yellow else check_green; + const msg = std.fmt.bufPrint(&buf, " {s} SPF: {s}\n", .{ ci, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} SPF: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // DKIM + if (dkim_txt) |txt| { + const trunc = if (txt.len > 60) txt[0..60] else txt; + const msg = std.fmt.allocPrint(allocator, " {s} DKIM: {s}... ({s}._domainkey)\n", .{ check_green, trunc, dkim_selector }) catch continue; + defer allocator.free(msg); + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} DKIM: {s}\n", .{ check_red, not_found }) catch ""; stdout_file.writeAll(msg) catch {}; - } else |_| { - stdout_file.writeAll(" TLS-RPT: (not found)\n") catch {}; } + + // MTA-STS + if (mta_sts_txt) |txt| { + const msg = std.fmt.bufPrint(&buf, " {s} MTA-STS: {s}\n", .{ check_green, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} MTA-STS: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + // TLS-RPT + if (tls_rpt_txt) |txt| { + const msg = std.fmt.bufPrint(&buf, " {s} TLS-RPT: {s}\n", .{ check_green, txt }) catch ""; + stdout_file.writeAll(msg) catch {}; + } else { + const msg = std.fmt.bufPrint(&buf, " {s} TLS-RPT: {s}\n", .{ check_red, not_found }) catch ""; + stdout_file.writeAll(msg) catch {}; + } + + stdout_file.writeAll("\n") catch {}; } } + + if (is_json) { + json_buf.appendSlice(allocator, "]\n") catch {}; + stdout_file.writeAll(json_buf.items) catch {}; + } } fn cmdDomains(allocator: std.mem.Allocator, format: []const u8, account: ?[]const u8) !void { From b688d891048b25464599c3fc82c587e90fc6da52 Mon Sep 17 00:00:00 2001 From: linyows Date: Fri, 17 Apr 2026 23:22:21 +0900 Subject: [PATCH 3/6] Extract DNS status and failure classification into testable stats functions - 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) --- src/main.zig | 79 ++++++++++++++-------------- src/stats.zig | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 40 deletions(-) diff --git a/src/main.zig b/src/main.zig index f1c673e..dab15f2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -385,17 +385,18 @@ fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []co tls_rpt_txt = reports.ipinfo.queryTxt(allocator, qname) catch null; } - const dmarc_policy_weak = if (dmarc_txt) |t| std.mem.indexOf(u8, t, "p=none") != null else false; - const spf_weak = if (spf_txt) |t| std.mem.indexOf(u8, t, "~all") != null else false; + const dmarc_policy_weak = if (dmarc_txt) |t| reports.stats.isDmarcPolicyWeak(t) else false; + const spf_weak = if (spf_txt) |t| reports.stats.isSpfWeak(t) else false; const has_dmarc = dmarc_txt != null; const has_spf = spf_txt != null; const has_dkim = dkim_txt != null; + const dns_status = reports.stats.evaluateDnsStatus(has_dmarc, has_spf, has_dkim, dmarc_policy_weak, spf_weak); if (is_json) { if (!json_first) json_buf.appendSlice(allocator, ",") catch continue; json_first = false; - const status_str: []const u8 = if (!has_dmarc or !has_spf or !has_dkim) "critical" else if (dmarc_policy_weak or spf_weak) "warning" else "ok"; + const status_str = dns_status.label(); const dmarc_s = if (dmarc_txt) |t| t else ""; const spf_s = if (spf_txt) |t| t else ""; const dkim_s = if (dkim_txt) |t| t else ""; @@ -408,12 +409,11 @@ fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []co defer allocator.free(line); json_buf.appendSlice(allocator, line) catch continue; } else { - const icon = if (!has_dmarc or !has_spf or !has_dkim) - icon_red - else if (dmarc_policy_weak or spf_weak) - icon_yellow - else - icon_green; + const icon = switch (dns_status) { + .ok => icon_green, + .warning => icon_yellow, + .critical => icon_red, + }; // Print domain header const hdr = std.fmt.bufPrint(&buf, " {s} {s}\n", .{ icon, domain }) catch ""; @@ -913,27 +913,27 @@ fn cmdCheck( result.dmarc_total += rec.count; const dkim_pass = std.mem.eql(u8, rec.dkim_eval, "pass"); const spf_pass = std.mem.eql(u8, rec.spf_eval, "pass"); - if (dkim_pass and spf_pass) { - result.dmarc_pass += rec.count; - } else if (dkim_pass and !spf_pass) { - result.dmarc_pass += rec.count; // DKIM pass is enough - result.spf_only_fail += rec.count; - } else if (!dkim_pass and spf_pass) { - result.dmarc_pass += rec.count; // SPF pass is enough - result.dkim_only_fail += rec.count; + if (reports.stats.classifyFailure(dkim_pass, spf_pass)) |ft| { + switch (ft) { + .both_fail => result.both_fail += rec.count, + .dkim_only_fail => result.dkim_only_fail += rec.count, + .spf_only_fail => result.spf_only_fail += rec.count, + } + if (ft == .both_fail) { + result.dmarc_fail += rec.count; + dmarc_fails.append(allocator, .{ + .account = entry.account_name, + .domain = entry.domain, + .org = entry.org_name, + .source_ip = allocator.dupe(u8, rec.source_ip) catch continue, + .count = rec.count, + .dkim = allocator.dupe(u8, rec.dkim_eval) catch continue, + .spf = allocator.dupe(u8, rec.spf_eval) catch continue, + .header_from = allocator.dupe(u8, rec.header_from) catch continue, + }) catch {}; + } } else { - result.both_fail += rec.count; - result.dmarc_fail += rec.count; - dmarc_fails.append(allocator, .{ - .account = entry.account_name, - .domain = entry.domain, - .org = entry.org_name, - .source_ip = allocator.dupe(u8, rec.source_ip) catch continue, - .count = rec.count, - .dkim = allocator.dupe(u8, rec.dkim_eval) catch continue, - .spf = allocator.dupe(u8, rec.spf_eval) catch continue, - .header_from = allocator.dupe(u8, rec.header_from) catch continue, - }) catch {}; + result.dmarc_pass += rec.count; } } }, @@ -1074,17 +1074,16 @@ fn writeCheckText( // 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 {}; - if (result.both_fail > 0) { - const msg = std.fmt.bufPrint(&buf, " DKIM+SPF fail: {d} messages (needs DKIM and SPF setup)\n", .{result.both_fail}) catch ""; - stdout_file.writeAll(msg) catch {}; - } - if (result.dkim_only_fail > 0) { - const msg = std.fmt.bufPrint(&buf, " DKIM fail only: {d} messages (needs DKIM setup)\n", .{result.dkim_only_fail}) catch ""; - stdout_file.writeAll(msg) catch {}; - } - if (result.spf_only_fail > 0) { - const msg = std.fmt.bufPrint(&buf, " SPF fail only: {d} messages (needs SPF setup)\n", .{result.spf_only_fail}) catch ""; - stdout_file.writeAll(msg) 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 }, + }; + for (breakdown) |b| { + if (b.count > 0) { + const msg = std.fmt.bufPrint(&buf, " {s}: {d} messages ({s})\n", .{ b.ft.label(), b.count, b.ft.hint() }) catch ""; + stdout_file.writeAll(msg) catch {}; + } } } diff --git a/src/stats.zig b/src/stats.zig index 4444caf..eba60ee 100644 --- a/src/stats.zig +++ b/src/stats.zig @@ -597,3 +597,145 @@ test "hashIncOwned handles empty string key" { hashIncOwned(alloc, &map, "", 3); try std.testing.expectEqual(@as(u64, 8), map.get("").?); } + +// MARK: - DNS status evaluation + +pub const DnsStatus = enum { + ok, + warning, + critical, + + pub fn label(self: DnsStatus) []const u8 { + return switch (self) { + .ok => "ok", + .warning => "warning", + .critical => "critical", + }; + } +}; + +/// Evaluate overall DNS health for a domain based on record presence and strength. +pub fn evaluateDnsStatus( + has_dmarc: bool, + has_spf: bool, + has_dkim: bool, + dmarc_policy_weak: bool, + spf_weak: bool, +) DnsStatus { + if (!has_dmarc or !has_spf or !has_dkim) return .critical; + if (dmarc_policy_weak or spf_weak) return .warning; + return .ok; +} + +/// Check if a DMARC policy is weak (p=none means monitor-only, no enforcement). +pub fn isDmarcPolicyWeak(dmarc_txt: []const u8) bool { + return std.mem.indexOf(u8, dmarc_txt, "p=none") != null; +} + +/// Check if an SPF record uses soft fail (~all instead of -all). +pub fn isSpfWeak(spf_txt: []const u8) bool { + return std.mem.indexOf(u8, spf_txt, "~all") != null; +} + +// MARK: - DMARC failure classification + +pub const FailureType = enum { + both_fail, + dkim_only_fail, + spf_only_fail, + + pub fn label(self: FailureType) []const u8 { + return switch (self) { + .both_fail => "DKIM+SPF fail", + .dkim_only_fail => "DKIM fail only", + .spf_only_fail => "SPF fail only", + }; + } + + pub fn hint(self: FailureType) []const u8 { + return switch (self) { + .both_fail => "needs DKIM and SPF setup", + .dkim_only_fail => "needs DKIM setup", + .spf_only_fail => "needs SPF setup", + }; + } +}; + +/// Classify a DMARC failure based on DKIM and SPF evaluation results. +/// Returns null if both pass (not a failure). +pub fn classifyFailure(dkim_pass: bool, spf_pass: bool) ?FailureType { + if (dkim_pass and spf_pass) return null; + if (!dkim_pass and !spf_pass) return .both_fail; + if (!dkim_pass) return .dkim_only_fail; + return .spf_only_fail; +} + +// MARK: - DNS status tests + +test "evaluateDnsStatus returns ok when all records present and strong" { + try std.testing.expectEqual(DnsStatus.ok, evaluateDnsStatus(true, true, true, false, false)); +} + +test "evaluateDnsStatus returns warning for weak DMARC policy" { + try std.testing.expectEqual(DnsStatus.warning, evaluateDnsStatus(true, true, true, true, false)); +} + +test "evaluateDnsStatus returns warning for weak SPF" { + try std.testing.expectEqual(DnsStatus.warning, evaluateDnsStatus(true, true, true, false, true)); +} + +test "evaluateDnsStatus returns warning when both weak" { + try std.testing.expectEqual(DnsStatus.warning, evaluateDnsStatus(true, true, true, true, true)); +} + +test "evaluateDnsStatus returns critical when DKIM missing" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(true, true, false, false, false)); +} + +test "evaluateDnsStatus returns critical when DMARC missing" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(false, true, true, false, false)); +} + +test "evaluateDnsStatus returns critical when SPF missing" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(true, false, true, false, false)); +} + +test "evaluateDnsStatus returns critical over warning when record missing and weak" { + try std.testing.expectEqual(DnsStatus.critical, evaluateDnsStatus(true, true, false, true, true)); +} + +test "isDmarcPolicyWeak detects p=none" { + try std.testing.expect(isDmarcPolicyWeak("v=DMARC1; p=none; rua=mailto:x@example.com")); +} + +test "isDmarcPolicyWeak returns false for p=quarantine" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=quarantine; rua=mailto:x@example.com")); +} + +test "isDmarcPolicyWeak returns false for p=reject" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=reject; rua=mailto:x@example.com")); +} + +test "isSpfWeak detects ~all" { + try std.testing.expect(isSpfWeak("v=spf1 include:_spf.google.com ~all")); +} + +test "isSpfWeak returns false for -all" { + try std.testing.expect(!isSpfWeak("v=spf1 ip4:1.2.3.4 -all")); +} + +test "classifyFailure returns null when both pass" { + try std.testing.expectEqual(@as(?FailureType, null), classifyFailure(true, true)); +} + +test "classifyFailure returns both_fail" { + try std.testing.expectEqual(FailureType.both_fail, classifyFailure(false, false).?); +} + +test "classifyFailure returns dkim_only_fail" { + try std.testing.expectEqual(FailureType.dkim_only_fail, classifyFailure(false, true).?); +} + +test "classifyFailure returns spf_only_fail" { + try std.testing.expectEqual(FailureType.spf_only_fail, classifyFailure(true, false).?); +} From 2aa63e57604c63bd1b4616dbe3c07b6aed8aa1ab Mon Sep 17 00:00:00 2001 From: linyows Date: Fri, 17 Apr 2026 23:36:01 +0900 Subject: [PATCH 4/6] Fix dmarc_pass count for single-mechanism failures 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) --- src/main.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.zig b/src/main.zig index dab15f2..eedd3cb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -920,6 +920,7 @@ fn cmdCheck( .spf_only_fail => result.spf_only_fail += rec.count, } if (ft == .both_fail) { + // Only both-fail counts as DMARC failure result.dmarc_fail += rec.count; dmarc_fails.append(allocator, .{ .account = entry.account_name, @@ -931,6 +932,9 @@ fn cmdCheck( .spf = allocator.dupe(u8, rec.spf_eval) catch continue, .header_from = allocator.dupe(u8, rec.header_from) catch continue, }) catch {}; + } else { + // Single-mechanism fail still passes DMARC (one pass is enough) + result.dmarc_pass += rec.count; } } else { result.dmarc_pass += rec.count; From d7868841ba60fe168c1d10ee4fec061c5071de1e Mon Sep 17 00:00:00 2001 From: linyows Date: Sat, 18 Apr 2026 00:05:22 +0900 Subject: [PATCH 5/6] Address Copilot review: DMARC policy parsing, JSON escaping, labels - 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) --- src/lib.zig | 36 ++-------------------------- src/main.zig | 20 ++++++++++------ src/stats.zig | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 42 deletions(-) diff --git a/src/lib.zig b/src/lib.zig index cfc9450..3524e9e 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -151,38 +151,6 @@ export fn reports_fetch_account(config_json: [*:0]const u8, account_name: [*:0]c return errStr("Account not found"); } -/// Escape a string for embedding in JSON. Returns the original slice if no escaping needed. -fn jsonEscapeAlloc(input: []const u8) ?[]const u8 { - var needs_escape = false; - for (input) |ch| { - if (ch == '"' or ch == '\\' or ch < 0x20) { - needs_escape = true; - break; - } - } - if (!needs_escape) return input; - - var buf: std.ArrayList(u8) = .empty; - for (input) |ch| { - switch (ch) { - '"' => buf.appendSlice(allocator, "\\\"") catch return null, - '\\' => buf.appendSlice(allocator, "\\\\") catch return null, - '\n' => buf.appendSlice(allocator, "\\n") catch return null, - '\r' => buf.appendSlice(allocator, "\\r") catch return null, - '\t' => buf.appendSlice(allocator, "\\t") catch return null, - else => if (ch < 0x20) { - buf.appendSlice(allocator, "\\u00") catch return null; - const hex = "0123456789abcdef"; - buf.append(allocator, hex[ch >> 4]) catch return null; - buf.append(allocator, hex[ch & 0x0f]) catch return null; - } else { - buf.append(allocator, ch) catch return null; - }, - } - } - return buf.toOwnedSlice(allocator) catch null; -} - fn errStr(msg: []const u8) ?[*:0]u8 { const duped = allocator.dupeZ(u8, msg) catch return null; return duped.ptr; @@ -737,9 +705,9 @@ fn buildSourcesJson(data_dir: []const u8, entries: []const reports.store.ReportE } // JSON-escape enrichment strings that may contain quotes/backslashes - const esc_ptr = jsonEscapeAlloc(ptr_str) orelse ptr_str; + const esc_ptr = reports.stats.jsonEscape(allocator, ptr_str); defer if (esc_ptr.ptr != ptr_str.ptr) allocator.free(esc_ptr); - const esc_asn_org = jsonEscapeAlloc(asn_org_str) orelse asn_org_str; + const esc_asn_org = reports.stats.jsonEscape(allocator, asn_org_str); defer if (esc_asn_org.ptr != asn_org_str.ptr) allocator.free(esc_asn_org); const line = std.fmt.allocPrint(allocator, "{{\"ip\":\"{s}\",\"messages\":{d},\"dmarc_issues\":{d},\"tls_failures\":{d},\"types\":{s},\"domains\":{s},\"ptr\":\"{s}\",\"asn\":\"{s}\",\"asn_org\":\"{s}\",\"country\":\"{s}\"}}", .{ diff --git a/src/main.zig b/src/main.zig index eedd3cb..7a9c589 100644 --- a/src/main.zig +++ b/src/main.zig @@ -323,7 +323,7 @@ fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []co if (is_json) json_buf.appendSlice(allocator, "[") catch {}; for (domains.items) |domain| { - var buf: [256]u8 = undefined; + var buf: [2048]u8 = undefined; // Query all records first to determine domain status var dmarc_txt: ?[]const u8 = null; @@ -397,11 +397,17 @@ fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []co json_first = false; const status_str = dns_status.label(); - const dmarc_s = if (dmarc_txt) |t| t else ""; - const spf_s = if (spf_txt) |t| t else ""; - const dkim_s = if (dkim_txt) |t| t else ""; - const mta_s = if (mta_sts_txt) |t| t else ""; - const tls_s = if (tls_rpt_txt) |t| t else ""; + const esc = reports.stats.jsonEscape; + const dmarc_s = if (dmarc_txt) |t| esc(allocator, t) else ""; + defer if (dmarc_txt != null and dmarc_s.ptr != dmarc_txt.?.ptr) allocator.free(dmarc_s); + const spf_s = if (spf_txt) |t| esc(allocator, t) else ""; + defer if (spf_txt != null and spf_s.ptr != spf_txt.?.ptr) allocator.free(spf_s); + const dkim_s = if (dkim_txt) |t| esc(allocator, t) else ""; + defer if (dkim_txt != null and dkim_s.ptr != dkim_txt.?.ptr) allocator.free(dkim_s); + const mta_s = if (mta_sts_txt) |t| esc(allocator, t) else ""; + defer if (mta_sts_txt != null and mta_s.ptr != mta_sts_txt.?.ptr) allocator.free(mta_s); + const tls_s = if (tls_rpt_txt) |t| esc(allocator, t) else ""; + defer if (tls_rpt_txt != null and tls_s.ptr != tls_rpt_txt.?.ptr) allocator.free(tls_s); 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, @@ -1077,7 +1083,7 @@ fn writeCheckText( // 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 {}; + stdout_file.writeAll("\nAuth mechanism breakdown (single-mechanism fails still pass DMARC):\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 }, diff --git a/src/stats.zig b/src/stats.zig index eba60ee..15e9104 100644 --- a/src/stats.zig +++ b/src/stats.zig @@ -598,6 +598,42 @@ test "hashIncOwned handles empty string key" { try std.testing.expectEqual(@as(u64, 8), map.get("").?); } +// MARK: - JSON string escaping + +/// Escape a string for embedding in a JSON string value. +/// Returns the original slice if no escaping is needed, or a new allocation. +/// Caller must free the result if it differs from input. +pub fn jsonEscape(alloc: Allocator, input: []const u8) []const u8 { + var needs_escape = false; + for (input) |ch| { + if (ch == '"' or ch == '\\' or ch < 0x20) { + needs_escape = true; + break; + } + } + if (!needs_escape) return input; + + var out: std.ArrayList(u8) = .empty; + for (input) |ch| { + switch (ch) { + '"' => out.appendSlice(alloc, "\\\"") catch return input, + '\\' => out.appendSlice(alloc, "\\\\") catch return input, + '\n' => out.appendSlice(alloc, "\\n") catch return input, + '\r' => out.appendSlice(alloc, "\\r") catch return input, + '\t' => out.appendSlice(alloc, "\\t") catch return input, + else => if (ch < 0x20) { + const hex = "0123456789abcdef"; + out.appendSlice(alloc, "\\u00") catch return input; + out.append(alloc, hex[ch >> 4]) catch return input; + out.append(alloc, hex[ch & 0x0f]) catch return input; + } else { + out.append(alloc, ch) catch return input; + }, + } + } + return out.toOwnedSlice(alloc) catch input; +} + // MARK: - DNS status evaluation pub const DnsStatus = enum { @@ -628,8 +664,24 @@ pub fn evaluateDnsStatus( } /// Check if a DMARC policy is weak (p=none means monitor-only, no enforcement). +/// Carefully matches only the "p=" tag, not "sp=" or "np=". pub fn isDmarcPolicyWeak(dmarc_txt: []const u8) bool { - return std.mem.indexOf(u8, dmarc_txt, "p=none") != null; + var i: usize = 0; + while (i < dmarc_txt.len) { + // Find "p=" + const pos = std.mem.indexOf(u8, dmarc_txt[i..], "p=") orelse return false; + const abs = i + pos; + // Make sure it's the "p" tag, not "sp=" or "np=" + if (abs == 0 or dmarc_txt[abs - 1] == ';' or dmarc_txt[abs - 1] == ' ') { + // Check the value after "p=" + const val_start = abs + 2; + if (val_start + 4 <= dmarc_txt.len and std.mem.eql(u8, dmarc_txt[val_start .. val_start + 4], "none")) { + return true; + } + } + i = abs + 2; + } + return false; } /// Check if an SPF record uses soft fail (~all instead of -all). @@ -716,6 +768,18 @@ test "isDmarcPolicyWeak returns false for p=reject" { try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=reject; rua=mailto:x@example.com")); } +test "isDmarcPolicyWeak returns false for sp=none with strong p" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=reject; sp=none")); +} + +test "isDmarcPolicyWeak returns false for np=none with strong p" { + try std.testing.expect(!isDmarcPolicyWeak("v=DMARC1; p=quarantine; np=none")); +} + +test "isDmarcPolicyWeak detects p=none at start of record" { + try std.testing.expect(isDmarcPolicyWeak("p=none; rua=mailto:x@example.com")); +} + test "isSpfWeak detects ~all" { try std.testing.expect(isSpfWeak("v=spf1 include:_spf.google.com ~all")); } From bf7deba6639bf0ffc72d347df01d497607fb266d Mon Sep 17 00:00:00 2001 From: linyows Date: Sat, 18 Apr 2026 14:29:03 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Use=20=E2=96=B3=20instead=20of=20!=20for=20?= =?UTF-8?q?warning=20status=20icon=20in=20dns=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 7a9c589..c75cc6d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -313,7 +313,7 @@ fn cmdDns(allocator: std.mem.Allocator, domain_filter: ?[]const u8, format: []co const icon_yellow = "\x1b[38;2;255;200;0m●\x1b[0m"; const icon_red = "\x1b[38;2;255;51;102m●\x1b[0m"; const check_green = "\x1b[38;2;194;255;38m✓\x1b[0m "; - const check_yellow = "\x1b[38;2;255;200;0m!\x1b[0m "; + const check_yellow = "\x1b[38;2;255;200;0m△\x1b[0m "; const check_red = "\x1b[38;2;255;51;102m✗\x1b[0m "; const not_found = "\x1b[2m(not found)\x1b[0m";