use super::{caa_body, heading, table_header, Bullets, MarkdownFormatter, MdCode, MdSafe};
use crate::caa::CaaPolicy;
use crate::confusables::ConfusableReport;
use crate::drift::DriftReport;
use crate::headers::HeaderReport;
use crate::output::{subdomain_status_label, takeover_label};
use crate::posture::{EmailPosture, PostureVerdict};
use crate::subdomains::{SubdomainBaselineDiff, SubdomainClassification, SubdomainStatus};
use crate::takeover::{TakeoverReport, TakeoverVerdict};
fn bullet_section(out: &mut Vec<String>, title: &str, items: &[String]) {
if items.is_empty() {
return;
}
out.extend([String::new(), format!("### {title}"), String::new()]);
out.extend(items.iter().map(|item| format!("- {}", MdSafe(item))));
}
fn or_dash(value: Option<&str>) -> String {
value.map_or_else(|| "—".to_string(), |v| MdSafe(v).to_string())
}
impl MarkdownFormatter {
pub(super) fn format_drift(&self, report: &DriftReport) -> String {
let mut out = heading(format!("## Drift: {}", MdSafe(&report.domain)));
if let Some(reason) = &report.inconclusive {
out.push(format!("_Not compared: {}._", MdSafe(reason)));
} else if report.changes.is_empty() {
out.push("_No changes since the previous snapshot._".to_string());
} else {
table_header(&mut out, &["Field", "Old", "New"]);
for c in &report.changes {
out.push(format!(
"| {} | {} | {} |",
MdSafe(&c.field),
or_dash(c.old.as_deref()),
or_dash(c.new.as_deref()),
));
}
}
out.join("\n")
}
pub(super) fn format_subdomain_baseline_diff(&self, report: &SubdomainBaselineDiff) -> String {
let mut out = heading(format!("## Subdomain diff: {}", MdSafe(&report.domain)));
if report.baseline_missing {
out.push("_No stored baseline to compare against (first run)._".to_string());
return out.join("\n");
}
if let Some(at) = report.baseline_recorded_at {
Bullets(&mut out).raw("Baseline recorded", at.format("%Y-%m-%d %H:%M UTC"));
out.push(String::new());
}
if report.baseline_truncated {
out.extend([
"_Baseline came from truncated enumerations — added names may be \
long-standing (not counted as new until a complete run is recorded)._"
.to_string(),
String::new(),
]);
}
out.push(format!(
"{} added, {} removed, {} unchanged.",
report.added.len(),
report.removed.len(),
report.unchanged_count
));
if report.added.is_empty() && report.removed.is_empty() {
out.extend([
String::new(),
"_No changes since the baseline._".to_string(),
]);
return out.join("\n");
}
let names = |out: &mut Vec<String>, title: &str, names: &[String]| {
if !names.is_empty() {
out.extend([String::new(), format!("### {title}"), String::new()]);
out.extend(names.iter().map(|name| format!("- `{}`", MdCode(name))));
}
};
names(&mut out, "Added", &report.added);
names(
&mut out,
"Removed (informational — often CT source flakiness)",
&report.removed,
);
out.join("\n")
}
pub(super) fn format_posture(&self, posture: &EmailPosture) -> String {
let mut out = heading(format!("## Email posture: {}", MdSafe(&posture.domain)));
table_header(&mut out, &["Mechanism", "Verdict", "Detail"]);
let spf = posture
.spf
.all_qualifier
.as_ref()
.map(|q| format!("{q}all"));
let dmarc = posture.dmarc.policy.as_deref().map(|p| format!("p={p}"));
let dane = format!("{} TLSA", posture.dane.records.len());
for (name, verdict, detail) in [
("SPF", posture.spf.verdict, spf.as_deref()),
("DMARC", posture.dmarc.verdict, dmarc.as_deref()),
("MTA-STS", posture.mta_sts.verdict, None),
("BIMI", posture.bimi.verdict, None),
("DANE", posture.dane.verdict, Some(dane.as_str())),
] {
let detail = match verdict {
PostureVerdict::Unknown => "lookup failed".to_string(),
_ => MdSafe(detail.unwrap_or("")).to_string(),
};
out.push(format!("| {name} | {} | {detail} |", verdict.as_str()));
}
bullet_section(&mut out, "Advisories", &posture.notes);
out.join("\n")
}
pub(super) fn format_headers(&self, report: &HeaderReport) -> String {
let mut out = heading(format!(
"## HTTP security headers: {}",
MdSafe(&report.domain)
));
let mut b = Bullets(&mut out);
b.raw(
"Grade",
format_args!("**{}** ({}/100)", MdSafe(&report.grade), report.score),
);
b.raw(
"URL",
format_args!("`{}` (HTTP {})", MdCode(&report.url), report.status),
);
if report.redirects > 0 {
b.raw("Redirects followed", report.redirects);
}
out.push(String::new());
table_header(&mut out, &["Header", "Verdict", "Value"]);
for f in &report.headers {
out.push(format!(
"| {} | {} | {} |",
MdSafe(&f.header),
f.verdict.as_str(),
MdSafe(f.value.as_deref().unwrap_or(""))
));
}
if !report.cookies.is_empty() {
out.extend([String::new(), "### Cookies".to_string(), String::new()]);
table_header(
&mut out,
&["Cookie", "Verdict", "Secure", "HttpOnly", "SameSite"],
);
for c in &report.cookies {
out.push(format!(
"| {} | {} | {} | {} | {} |",
MdSafe(&c.name),
c.verdict.as_str(),
c.secure,
c.http_only,
or_dash(c.same_site.as_deref())
));
}
}
if !report.disclosures.is_empty() {
out.extend([
String::new(),
"### Disclosed software".to_string(),
String::new(),
]);
for d in &report.disclosures {
out.push(format!(
"- `{}`: {}{}",
MdCode(&d.header),
MdSafe(&d.value),
if d.versioned { " (versioned)" } else { "" }
));
}
}
bullet_section(&mut out, "Advisories", &report.notes);
out.join("\n")
}
pub(super) fn format_takeover(&self, report: &TakeoverReport) -> String {
let mut out = heading(format!("## Takeover scan: {}", MdSafe(&report.domain)));
out.push(format!(
"{} host(s) checked — **{} vulnerable**, {} potential",
report.hosts_checked, report.vulnerable, report.potential
));
if report.inconclusive > 0 {
out.extend([
String::new(),
format!("_{} host(s) could not be checked._", report.inconclusive),
]);
}
if report.hosts_skipped > 0 {
out.extend([
String::new(),
format!(
"_{} more host(s) exceeded the scan cap and were not examined._",
report.hosts_skipped
),
]);
}
out.push(String::new());
if report.findings.is_empty() {
out.push("_No takeover signals found._".to_string());
} else {
table_header(
&mut out,
&["Host", "Verdict", "Provider", "CNAME", "Evidence", "Note"],
);
for f in &report.findings {
let label = takeover_label(f.verdict);
let verdict = match f.verdict {
TakeoverVerdict::Vulnerable => format!("**{label}**"),
_ => label.to_string(),
};
out.push(format!(
"| {} | {verdict} | {} | {} | {} | {} |",
MdSafe(&f.host),
or_dash(f.provider.as_deref()),
or_dash(f.cname.as_deref()),
or_dash(f.evidence.as_deref()),
or_dash(f.probe_note.as_deref())
));
}
}
bullet_section(&mut out, "Advisories", &report.notes);
out.join("\n")
}
pub(super) fn format_caa(&self, policy: &CaaPolicy) -> String {
let mut out = heading("## CAA Policy".to_string());
caa_body(&mut out, policy);
if policy.has_policy && (!policy.iodef.is_empty() || policy.wildcard_note.is_some()) {
out.push(String::new());
let mut b = Bullets(&mut out);
if !policy.iodef.is_empty() {
b.text("iodef (incident reporting)", &policy.iodef.join(", "));
}
b.opt("Wildcard", &policy.wildcard_note);
}
out.extend([String::new(), format!("> **Note:** {}", policy.note)]);
out.join("\n")
}
pub(super) fn format_confusables(&self, report: &ConfusableReport) -> String {
let mut out = heading(format!("## Look-alikes: {}", MdSafe(&report.domain)));
out.push(format!(
"{} candidates generated, {} registered.",
report.candidates_generated,
report.registered.len()
));
out.push(String::new());
if report.registered.is_empty() {
out.push("_No registered look-alikes found._".to_string());
return out.join("\n");
}
table_header(
&mut out,
&["Domain", "Technique", "Registered", "Registrar"],
);
for r in &report.registered {
let created = r
.creation_date
.map_or_else(|| "—".to_string(), |d| d.format("%Y-%m-%d").to_string());
out.push(format!(
"| {} | {} | {created} | {} |",
MdSafe(&r.domain),
MdSafe(&r.technique),
or_dash(r.registrar.as_deref()),
));
}
out.join("\n")
}
pub(super) fn format_subdomain_classification(
&self,
result: &SubdomainClassification,
) -> String {
let mut out = heading(format!("## Subdomains: {}", MdSafe(&result.domain)));
if result.wildcard_detected {
out.push(
"> ⚠️ Wildcard DNS detected — some \"live\" verdicts may be zone wildcards."
.to_string(),
);
out.push(String::new());
}
let live = result
.subdomains
.iter()
.filter(|s| s.status == SubdomainStatus::Live)
.count();
let at_risk = result
.subdomains
.iter()
.filter(|s| s.takeover_risk.is_some())
.count();
out.push(format!(
"{} names — {live} live, {at_risk} takeover-risk.",
result.subdomains.len()
));
out.push(String::new());
if result.names_skipped > 0 {
out.push(format!(
"> ⚠️ {} more names exceeded the classification cap and were not resolved.",
result.names_skipped
));
out.push(String::new());
}
table_header(&mut out, &["Name", "Status", "CNAME", "Takeover risk"]);
for s in &result.subdomains {
out.push(format!(
"| {} | {} | {} | {} |",
MdSafe(&s.name),
subdomain_status_label(s.status),
or_dash(s.cname.as_deref()),
or_dash(s.takeover_risk.as_deref()),
));
}
out.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::takeover::TakeoverFinding;
#[test]
fn takeover_table_includes_probe_note() {
let report = TakeoverReport {
domain: "example.com".to_string(),
hosts_checked: 1,
hosts_skipped: 0,
vulnerable: 0,
potential: 1,
inconclusive: 0,
findings: vec![TakeoverFinding {
host: "docs.example.com".to_string(),
verdict: TakeoverVerdict::Potential,
provider: Some("GitHub Pages".to_string()),
cname: Some("example.github.io".to_string()),
addresses: Vec::new(),
evidence: None,
http_status: None,
probe_note: Some("HTTP probe failed: connection|refused".to_string()),
}],
notes: Vec::new(),
};
let out = MarkdownFormatter::new().format_takeover(&report);
assert!(
out.contains("| Host | Verdict | Provider | CNAME | Evidence | Note |"),
"got:\n{out}"
);
assert!(
out.contains(
"| docs.example.com | potential | GitHub Pages | example.github.io | — \
| HTTP probe failed: connection\\|refused |"
),
"probe note must render (MdSafe-escaped):\n{out}"
);
}
}