seer-core 0.49.1

Core library for Seer domain name utilities
Documentation
//! Markdown renderers for the security/intelligence report types.

use std::fmt::Write as _;

use super::{MarkdownFormatter, MdSafe};
use crate::caa::CaaPolicy;
use crate::confusables::ConfusableReport;
use crate::drift::DriftReport;
use crate::headers::HeaderReport;
use crate::posture::EmailPosture;
use crate::subdomains::{SubdomainBaselineDiff, SubdomainClassification};
use crate::takeover::{TakeoverReport, TakeoverVerdict};

impl MarkdownFormatter {
    pub(super) fn format_drift(&self, report: &DriftReport) -> String {
        let mut out = format!("## Drift: {}\n\n", MdSafe(&report.domain));
        if let Some(reason) = &report.inconclusive {
            let _ = writeln!(out, "_Not compared: {}._", MdSafe(reason));
            return out;
        }
        if report.changes.is_empty() {
            out.push_str("_No changes since the previous snapshot._\n");
            return out;
        }
        out.push_str("| Field | Old | New |\n|---|---|---|\n");
        for c in &report.changes {
            let _ = writeln!(
                out,
                "| {} | {} | {} |",
                MdSafe(&c.field),
                MdSafe(c.old.as_deref().unwrap_or("—")),
                MdSafe(c.new.as_deref().unwrap_or("—")),
            );
        }
        out
    }

    pub(super) fn format_subdomain_baseline_diff(&self, report: &SubdomainBaselineDiff) -> String {
        let mut out = format!("## Subdomain diff: {}\n\n", MdSafe(&report.domain));

        if report.baseline_missing {
            // Neutral phrasing: the CLI/REPL note carries the actionable
            // "--record" hint (it knows whether a baseline was just recorded).
            out.push_str("_No stored baseline to compare against (first run)._\n");
            return out;
        }

        if let Some(at) = report.baseline_recorded_at {
            let _ = writeln!(
                out,
                "**Baseline recorded:** {}\n",
                at.format("%Y-%m-%d %H:%M UTC")
            );
        }
        let _ = writeln!(
            out,
            "{} added, {} removed, {} unchanged.\n",
            report.added.len(),
            report.removed.len(),
            report.unchanged_count
        );

        if report.added.is_empty() && report.removed.is_empty() {
            out.push_str("_No changes since the baseline._\n");
            return out;
        }

        if !report.added.is_empty() {
            out.push_str("### Added\n\n");
            for name in &report.added {
                let _ = writeln!(out, "- `{}`", MdSafe(name));
            }
            out.push('\n');
        }
        if !report.removed.is_empty() {
            // Removals are informational: CT logs are append-mostly, so a
            // vanished name usually means source flakiness (see baseline.rs).
            out.push_str("### Removed (informational — often CT source flakiness)\n\n");
            for name in &report.removed {
                let _ = writeln!(out, "- `{}`", MdSafe(name));
            }
        }
        out
    }

    pub(super) fn format_posture(&self, posture: &EmailPosture) -> String {
        let mut out = format!("## Email posture: {}\n\n", MdSafe(&posture.domain));
        out.push_str("| Mechanism | Verdict | Detail |\n|---|---|---|\n");
        let spf_detail = posture
            .spf
            .all_qualifier
            .as_ref()
            .map(|q| format!("{q}all"))
            .unwrap_or_default();
        let dmarc_detail = posture
            .dmarc
            .policy
            .as_deref()
            .map(|p| format!("p={p}"))
            .unwrap_or_default();
        let _ = writeln!(
            out,
            "| SPF | {:?} | {} |",
            posture.spf.verdict,
            MdSafe(&spf_detail)
        );
        let _ = writeln!(
            out,
            "| DMARC | {:?} | {} |",
            posture.dmarc.verdict,
            MdSafe(&dmarc_detail)
        );
        let _ = writeln!(out, "| MTA-STS | {:?} |  |", posture.mta_sts.verdict);
        let _ = writeln!(out, "| BIMI | {:?} |  |", posture.bimi.verdict);
        let _ = writeln!(
            out,
            "| DANE | {:?} | {} TLSA |",
            posture.dane.verdict,
            posture.dane.records.len()
        );
        if !posture.notes.is_empty() {
            out.push_str("\n### Advisories\n\n");
            for note in &posture.notes {
                let _ = writeln!(out, "- {}", MdSafe(note));
            }
        }
        out
    }

    pub(super) fn format_headers(&self, report: &HeaderReport) -> String {
        let mut out = format!("## HTTP security headers: {}\n\n", MdSafe(&report.domain));
        let _ = writeln!(
            out,
            "**Grade: {} ({}/100)** — `{}` (HTTP {})\n",
            MdSafe(&report.grade),
            report.score,
            MdSafe(&report.url),
            report.status
        );

        out.push_str("| Header | Verdict | Value |\n|---|---|---|\n");
        for f in &report.headers {
            let _ = writeln!(
                out,
                "| {} | {:?} | {} |",
                MdSafe(&f.header),
                f.verdict,
                MdSafe(f.value.as_deref().unwrap_or(""))
            );
        }

        if !report.cookies.is_empty() {
            out.push_str("\n### Cookies\n\n");
            out.push_str(
                "| Cookie | Verdict | Secure | HttpOnly | SameSite |\n|---|---|---|---|---|\n",
            );
            for c in &report.cookies {
                let _ = writeln!(
                    out,
                    "| {} | {:?} | {} | {} | {} |",
                    MdSafe(&c.name),
                    c.verdict,
                    c.secure,
                    c.http_only,
                    MdSafe(c.same_site.as_deref().unwrap_or("—"))
                );
            }
        }

        if !report.disclosures.is_empty() {
            out.push_str("\n### Disclosed software\n\n");
            for d in &report.disclosures {
                let _ = writeln!(
                    out,
                    "- `{}`: {}{}",
                    MdSafe(&d.header),
                    MdSafe(&d.value),
                    if d.versioned { " (versioned)" } else { "" }
                );
            }
        }

        if !report.notes.is_empty() {
            out.push_str("\n### Advisories\n\n");
            for note in &report.notes {
                let _ = writeln!(out, "- {}", MdSafe(note));
            }
        }
        out
    }

    pub(super) fn format_takeover(&self, report: &TakeoverReport) -> String {
        let mut out = format!("## Takeover scan: {}\n\n", MdSafe(&report.domain));
        let _ = writeln!(
            out,
            "{} host(s) checked — **{} vulnerable**, {} potential\n",
            report.hosts_checked, report.vulnerable, report.potential
        );
        if report.hosts_skipped > 0 {
            let _ = writeln!(
                out,
                "_{} host(s) exceeded the scan cap and were not examined._\n",
                report.hosts_skipped
            );
        }

        if report.findings.is_empty() {
            out.push_str("_No takeover signals found._\n");
        } else {
            // `Note` carries the probe note — why a finding stayed "potential"
            // (no fingerprint for the provider, probe failed, …).
            out.push_str(
                "| Host | Verdict | Provider | CNAME | Evidence | Note |\n\
                 |---|---|---|---|---|---|\n",
            );
            for f in &report.findings {
                let verdict = match f.verdict {
                    TakeoverVerdict::Vulnerable => "**VULNERABLE**",
                    TakeoverVerdict::Potential => "potential",
                    TakeoverVerdict::Safe => "safe",
                };
                let _ = writeln!(
                    out,
                    "| {} | {} | {} | {} | {} | {} |",
                    MdSafe(&f.host),
                    verdict,
                    MdSafe(f.provider.as_deref().unwrap_or("—")),
                    MdSafe(f.cname.as_deref().unwrap_or("—")),
                    MdSafe(f.evidence.as_deref().unwrap_or("—")),
                    MdSafe(f.probe_note.as_deref().unwrap_or("—"))
                );
            }
        }

        if !report.notes.is_empty() {
            out.push_str("\n### Advisories\n\n");
            for note in &report.notes {
                let _ = writeln!(out, "- {}", MdSafe(note));
            }
        }
        out
    }

    pub(super) fn format_caa(&self, policy: &CaaPolicy) -> String {
        let mut out = String::from("## CAA Policy\n\n");
        if !policy.has_policy {
            out.push_str("_No CAA records (any CA may issue)._\n\n");
        } else {
            if let Some(eff) = &policy.effective_domain {
                let _ = writeln!(out, "**Found at:** {}\n", MdSafe(eff));
            }
            out.push_str("| Flags | Tag | Value |\n|---|---|---|\n");
            for r in &policy.records {
                let _ = writeln!(
                    out,
                    "| {} | {} | {} |",
                    r.flags,
                    MdSafe(&r.tag),
                    MdSafe(&r.value)
                );
            }
            if !policy.iodef.is_empty() {
                let _ = writeln!(
                    out,
                    "\n**iodef (incident reporting):** {}",
                    MdSafe(&policy.iodef.join(", "))
                );
            }
            if let Some(note) = &policy.wildcard_note {
                let _ = writeln!(out, "\n**Wildcard:** {}", MdSafe(note));
            }
        }
        let _ = writeln!(out, "\n> {}", MdSafe(&policy.note));
        out
    }

    pub(super) fn format_confusables(&self, report: &ConfusableReport) -> String {
        let mut out = format!("## Look-alikes: {}\n\n", MdSafe(&report.domain));
        let _ = writeln!(
            out,
            "{} candidates generated, {} registered.\n",
            report.candidates_generated,
            report.registered.len()
        );
        if report.registered.is_empty() {
            out.push_str("_No registered look-alikes found._\n");
            return out;
        }
        out.push_str("| Domain | Technique | Registered | Registrar |\n|---|---|---|---|\n");
        for r in &report.registered {
            let created = r
                .creation_date
                .map(|d| d.format("%Y-%m-%d").to_string())
                .unwrap_or_else(|| "—".to_string());
            let _ = writeln!(
                out,
                "| {} | {} | {} | {} |",
                MdSafe(&r.domain),
                MdSafe(&r.technique),
                created,
                MdSafe(r.registrar.as_deref().unwrap_or("—")),
            );
        }
        out
    }

    pub(super) fn format_subdomain_classification(
        &self,
        result: &SubdomainClassification,
    ) -> String {
        let mut out = format!("## Subdomains: {}\n\n", MdSafe(&result.domain));
        if result.wildcard_detected {
            out.push_str(
                "> ⚠️ Wildcard DNS detected — some \"live\" verdicts may be zone wildcards.\n\n",
            );
        }
        if result.names_skipped > 0 {
            let _ = writeln!(
                out,
                "> ⚠️ {} more names exceeded the classification cap and were not resolved.\n",
                result.names_skipped
            );
        }
        out.push_str("| Name | Status | CNAME | Takeover risk |\n|---|---|---|---|\n");
        for s in &result.subdomains {
            let _ = writeln!(
                out,
                "| {} | {:?} | {} | {} |",
                MdSafe(&s.name),
                s.status,
                MdSafe(s.cname.as_deref().unwrap_or("—")),
                MdSafe(s.takeover_risk.as_deref().unwrap_or("—")),
            );
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::takeover::TakeoverFinding;

    #[test]
    fn takeover_table_includes_probe_note() {
        // The probe note is the reason a finding is only "potential"; the
        // human formatter prints it, markdown dropped it.
        let report = TakeoverReport {
            domain: "example.com".to_string(),
            hosts_checked: 1,
            hosts_skipped: 0,
            vulnerable: 0,
            potential: 1,
            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}"
        );
    }
}