secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
use secfinding::Severity;

use crate::models::GenericFinding;

pub(crate) fn escape_markdown_text(input: &str) -> String {
    let sanitized = neutralize_markdown_links(input);
    escape_markdown_literals(&sanitized)
}

fn escape_markdown_literals(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut in_angle = false;
    for ch in input.chars() {
        if ch == '<' {
            in_angle = true;
            out.push('\\');
            out.push(ch);
            continue;
        }
        if ch == '>' {
            in_angle = false;
            out.push('\\');
            out.push(ch);
            continue;
        }

        match ch {
            '(' | ')' if in_angle => {}
            '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '|'
            | '!' | '>' | '<' => {
                out.push('\\');
            }
            _ => {}
        }
        out.push(ch);
    }
    out
}

fn neutralize_markdown_links(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut rest = input;

    while let Some(label_start) = rest.find('[') {
        out.push_str(&rest[..label_start]);
        let after_label_start = &rest[label_start + 1..];
        let Some(label_end_rel) = after_label_start.find(']') else {
            out.push_str(&rest[label_start..]);
            return out;
        };
        let label_end = label_start + 1 + label_end_rel;
        let after_label = &rest[label_end + 1..];
        if !after_label.starts_with('(') {
            out.push_str(&rest[label_start..=label_end]);
            rest = &rest[label_end + 1..];
            continue;
        }
        let Some(url_end_rel) = find_matching_paren(after_label) else {
            out.push_str(&rest[label_start..]);
            return out;
        };
        let url_end = label_end + 1 + url_end_rel;
        let label = &rest[label_start + 1..label_end];
        let url = &rest[label_end + 2..url_end];
        if url.contains(':') {
            out.push_str(&format!("[{label}] <{url}>"));
        } else {
            out.push_str(&rest[label_start..=url_end]);
        }
        rest = &rest[url_end + 1..];
    }

    out.push_str(rest);
    out
}

fn find_matching_paren(input: &str) -> Option<usize> {
    let mut depth = 0usize;
    for (index, ch) in input.char_indices() {
        match ch {
            '(' => depth += 1,
            ')' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    return Some(index);
                }
            }
            _ => {}
        }
    }
    None
}

// Percent-encode a string into a URL-safe fragment using RFC 3986 unreserved chars.
fn pct_encode(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for &b in input.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char);
            }
            other => {
                out.push('%');
                out.push_str(&format!("{:02X}", other));
            }
        }
    }
    out
}

// Return the length of the longest run of `ch` in the input string.
fn max_consecutive_char(input: &str, ch: char) -> usize {
    let mut max = 0usize;
    let mut cur = 0usize;
    for c in input.chars() {
        if c == ch {
            cur += 1;
            if cur > max {
                max = cur;
            }
        } else {
            cur = 0;
        }
    }
    max
}

pub(crate) fn render_markdown_generic(findings: &[GenericFinding<'_>], tool_name: &str) -> String {
    let now = chrono::Utc::now().format("%Y-%m-%d").to_string();
    let escaped_tool = escape_markdown_text(tool_name);
    let target = {
        let mut uniq = findings
            .iter()
            .map(|f| escape_markdown_text(f.target))
            .collect::<Vec<_>>();
        uniq.sort();
        uniq.dedup();
        if uniq.is_empty() {
            "unknown".to_string()
        } else if uniq.len() == 1 {
            uniq.into_iter().next().unwrap()
        } else {
            "multiple targets".to_string()
        }
    };
    let mut md = String::with_capacity(findings.len() * 300);
    md.push_str(&format!(
        "# {escaped_tool} Security Report \u{2014} {target}\n\n*Generated {now} \u{00b7} {} findings*\n\n",
        findings.len()
    ));

    md.push_str("## Risk Summary\n\n| Severity | Count |\n|---|---|\n");
    for severity in [
        Severity::Critical,
        Severity::High,
        Severity::Medium,
        Severity::Low,
        Severity::Info,
    ] {
        let count = findings.iter().filter(|f| f.severity == severity).count();
        if count > 0 {
            md.push_str(&format!("| {} | {count} |\n", severity.label()));
        }
    }
    md.push('\n');

    for (sev, heading) in [
        (Severity::Critical, "Critical Findings"),
        (Severity::High, "High Findings"),
        (Severity::Medium, "Medium Findings"),
        (Severity::Low, "Low Findings"),
        (Severity::Info, "Informational"),
    ] {
        let group: Vec<_> = findings.iter().filter(|f| f.severity == sev).collect();
        if group.is_empty() {
            continue;
        }
        md.push_str(&format!("## {heading}\n\n"));
        for f in group {
            md.push_str(&format!(
                "### {}\n\n**Target:** `{}`  \n**Scanner:** {}  \n**Category:** {}  \n",
                escape_markdown_text(f.title),
                escape_markdown_text(f.target),
                escape_markdown_text(f.scanner),
                escape_markdown_text(&format!("{:?}", f.kind)),
            ));
            if !f.tags.is_empty() {
                let tags = f
                    .tags
                    .iter()
                    .map(|tag| format!("`{}`", escape_markdown_text(tag)))
                    .collect::<Vec<_>>()
                    .join(" ");
                md.push_str(&format!("**Tags:** {tags}  \n"));
            }
            if !f.cwe_ids.is_empty() {
                let cwe_escaped = f
                    .cwe_ids
                    .iter()
                    .map(|id| escape_markdown_text(id))
                    .collect::<Vec<_>>()
                    .join(", ");
                md.push_str(&format!("**CWE:** {}  \n", cwe_escaped));
            }
            if !f.cve_ids.is_empty() {
                let cve_escaped = f
                    .cve_ids
                    .iter()
                    .map(|id| escape_markdown_text(id))
                    .collect::<Vec<_>>()
                    .join(", ");
                md.push_str(&format!("**CVE:** {}  \n", cve_escaped));
            }
            md.push('\n');
            if !f.detail.is_empty() {
                md.push_str(&format!("{}\n\n", escape_markdown_text(f.detail)));
            }
            if let Some(hint) = &f.exploit_hint {
                let backtick_run = max_consecutive_char(hint, '`');
                let fence = "`".repeat(backtick_run + 1);
                md.push_str(&format!(
                    "**Exploit / PoC:**\n{}bash\n{}\n{}\n\n",
                    fence, hint, fence
                ));
            }
            md.push_str("---\n\n");
        }
    }
    let encoded_tool = pct_encode(tool_name);
    md.push_str(&format!(
        "*Report generated by [{escaped_tool}](https://github.com/santh-io/{})*\n",
        encoded_tool
    ));
    md
}