Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ipinfo.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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.
const ns_ip = getNameserver(allocator) catch try allocator.dupe(u8, "8.8.8.8");
defer allocator.free(ns_ip);

Expand Down
36 changes: 2 additions & 34 deletions src/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}\"}}", .{
Expand Down
269 changes: 254 additions & 15 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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")) {
Expand Down Expand Up @@ -277,6 +279,211 @@ 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, format: []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 {};
}
}

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: [2048]u8 = undefined;

// 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);
dmarc_txt = reports.ipinfo.queryTxt(allocator, qname) catch null;
}

// 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 |_| {}
}
Comment on lines +348 to +357
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.

// 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 |_| {}
}
Comment on lines +360 to +372
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.

// MTA-STS
{
const qname = std.fmt.allocPrint(allocator, "_mta-sts.{s}", .{domain}) catch continue;
defer allocator.free(qname);
mta_sts_txt = reports.ipinfo.queryTxt(allocator, qname) catch null;
}

// TLS-RPT
{
const qname = std.fmt.allocPrint(allocator, "_smtp._tls.{s}", .{domain}) catch continue;
defer allocator.free(qname);
tls_rpt_txt = reports.ipinfo.queryTxt(allocator, qname) catch null;
}

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 = dns_status.label();
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,
}) catch continue;
defer allocator.free(line);
json_buf.appendSlice(allocator, line) catch continue;
Comment on lines +412 to +416
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.
} else {
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 "";
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 {};
}

// 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 {
const cfg = try Config.load(allocator);
defer cfg.deinit(allocator);
Expand Down Expand Up @@ -631,6 +838,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,
Expand Down Expand Up @@ -708,20 +919,31 @@ 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) {
result.dmarc_pass += 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) {
// Only both-fail counts as DMARC failure
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 {
// Single-mechanism fail still passes DMARC (one pass is enough)
result.dmarc_pass += rec.count;
}
} else {
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;
}
}
},
Expand Down Expand Up @@ -859,6 +1081,22 @@ 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("\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 },
.{ .ft = .spf_only_fail, .count = result.spf_only_fail },
Comment on lines +1084 to +1090
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.
};
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 {};
}
}
}

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",
Expand Down Expand Up @@ -932,8 +1170,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);

Expand Down Expand Up @@ -1630,6 +1868,7 @@ fn printUsage() void {
\\ show <id> 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
Expand Down
Loading
Loading