secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
use crate::models::GenericFinding;
use std::collections::BTreeMap;

fn fnv1a_hash_hex(parts: &[&str]) -> String {
    const OFFSET: u64 = 14695981039346656037;
    const PRIME: u64 = 1099511628211;
    let mut h = OFFSET;
    for part in parts {
        for &b in part.as_bytes() {
            h ^= b as u64;
            h = h.wrapping_mul(PRIME);
        }
        // separator to avoid ambiguity between concatenated parts
        h ^= 0xff;
        h = h.wrapping_mul(PRIME);
    }
    format!("{:016x}", h)
}

pub(crate) fn render_json_generic(
    findings: &[GenericFinding<'_>],
) -> Result<String, serde_json::Error> {
    let items: Vec<_> = findings.iter().map(GenericFinding::json_value).collect();
    serde_json::to_string_pretty(&items)
}

pub(crate) fn render_jsonl_generic(
    findings: &[GenericFinding<'_>],
) -> Result<String, serde_json::Error> {
    let mut out = Vec::with_capacity(findings.len());
    for finding in findings {
        out.push(serde_json::to_string(&finding.json_value())?);
    }
    Ok(out.join("\n"))
}

pub(crate) fn render_sarif_generic(
    findings: &[GenericFinding<'_>],
    tool_name: &str,
) -> Result<String, serde_json::Error> {
    let mut rule_map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
    for f in findings {
        let key = if f.rule_id.is_empty() {
            // fallback stable key when rule_id is not provided
            format!(
                "{}/{}",
                f.scanner,
                f.title.to_ascii_lowercase().replace(' ', "-")
            )
        } else {
            f.rule_id.clone()
        };
        if !rule_map.contains_key(&key) {
            let precision = match f.confidence {
                Some(c) if c.is_finite() => {
                    if c >= 0.9 {
                        "high"
                    } else if c >= 0.5 {
                        "medium"
                    } else {
                        "low"
                    }
                }
                _ => "medium",
            };

            rule_map.insert(
                key.clone(),
                serde_json::json!({
                    "id": key,
                    "name": f.title,
                    "shortDescription": { "text": f.title },
                    "fullDescription": { "text": f.detail },
                    "help": {
                        "text": f.exploit_hint.unwrap_or(f.detail),
                        "markdown": f.exploit_hint.unwrap_or(f.detail),
                    },
                    "properties": {
                        "tags": f.tags,
                        "severity": f.severity.to_string(),
                        "category": format!("{:?}", f.kind),
                        "precision": precision,
                        "cwe": f.cwe_ids,
                        "cve": f.cve_ids,
                    }
                }),
            );
        }
    }
    let rules: Vec<serde_json::Value> = rule_map.into_values().collect();

    let results: Vec<serde_json::Value> = findings
        .iter()
        .map(|f| {
            let rule_key = if f.rule_id.is_empty() {
                format!(
                    "{}/{}",
                    f.scanner,
                    f.title.to_ascii_lowercase().replace(' ', "-")
                )
            } else {
                f.rule_id.clone()
            };
            let fingerprint = fnv1a_hash_hex(&[&rule_key, &f.target, &f.title, &f.detail]);
            serde_json::json!({
                "ruleId": rule_key,
                "level": f.sarif_level,
                "message": { "text": format!("{}\n{}", f.title, f.detail) },
                "locations": [{
                    "physicalLocation": {
                        "artifactLocation": { "uri": f.target }
                    }
                }],
                "partialFingerprints": {
                    "primaryLocationLineHash": fingerprint,
                },
                "codeFlows": [{
                    "threadFlows": [{
                        "locations": [{
                            "location": {
                                "physicalLocation": {
                                    "artifactLocation": { "uri": f.target }
                                },
                                "message": { "text": f.detail }
                            }
                        }]
                    }]
                }],
                "properties": {
                    "tags": f.tags,
                    "severity": f.severity.to_string(),
                    "kind": format!("{:?}", f.kind),
                    "confidence": f.confidence,
                    "cwe_ids": f.cwe_ids,
                    "cve_ids": f.cve_ids,
                    "exploit_hint": f.exploit_hint,
                    "evidence": f.evidence.iter().map(|e| e.to_string()).collect::<Vec<_>>(),
                }
            })
        })
        .collect();

    serde_json::to_string_pretty(&serde_json::json!({
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
        "version": "2.1.0",
        "runs": [{
            "tool": { "driver": { "name": tool_name, "rules": rules } },
            "results": results,
        }]
    }))
}