foxguard 0.12.0

A security scanner as fast as a linter, written in Rust. 200+ built-in rules across 12 source languages.
Documentation
use crate::{Finding, Severity, FINDING_SCHEMA_VERSION};
use serde::Serialize;
use std::time::Duration;

pub const JSON_SCHEMA_VERSION: &str = "1.0.0";

#[derive(Debug, Clone)]
pub struct JsonReportMetadata<'a> {
    pub command: &'a str,
    pub config: JsonConfigMetadata,
    pub target: JsonTargetMetadata<'a>,
    pub duration: Duration,
}

#[derive(Debug, Clone)]
pub struct JsonConfigMetadata {
    pub source: &'static str,
    pub path: Option<String>,
}

#[derive(Debug, Clone)]
pub struct JsonTargetMetadata<'a> {
    pub path: &'a str,
    pub kind: &'static str,
    pub changed_only: bool,
    pub files_scanned: usize,
    pub diff_base: Option<&'a str>,
}

#[derive(Debug, Serialize)]
struct JsonReportEnvelope<'a> {
    schema_version: &'static str,
    finding_schema_version: &'static str,
    scanner: ScannerMetadata<'a>,
    config: ConfigMetadata<'a>,
    target: TargetMetadata<'a>,
    timing: TimingMetadata,
    finding_counts: FindingCounts,
    findings: &'a [Finding],
}

#[derive(Debug, Serialize)]
struct ScannerMetadata<'a> {
    name: &'static str,
    version: &'static str,
    command: &'a str,
}

#[derive(Debug, Serialize)]
struct ConfigMetadata<'a> {
    source: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    path: Option<&'a str>,
}

#[derive(Debug, Serialize)]
struct TargetMetadata<'a> {
    path: &'a str,
    kind: &'static str,
    changed_only: bool,
    files_scanned: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    diff_base: Option<&'a str>,
}

#[derive(Debug, Serialize)]
struct TimingMetadata {
    duration_ms: u128,
}

#[derive(Debug, Serialize)]
struct FindingCounts {
    total: usize,
    by_severity: SeverityCounts,
}

#[derive(Debug, Serialize)]
struct SeverityCounts {
    low: usize,
    medium: usize,
    high: usize,
    critical: usize,
}

pub fn build_json_report(
    findings: &[Finding],
    metadata: JsonReportMetadata<'_>,
) -> serde_json::Value {
    let counts = finding_counts(findings);
    let envelope = JsonReportEnvelope {
        schema_version: JSON_SCHEMA_VERSION,
        finding_schema_version: FINDING_SCHEMA_VERSION,
        scanner: ScannerMetadata {
            name: "foxguard",
            version: env!("CARGO_PKG_VERSION"),
            command: metadata.command,
        },
        config: ConfigMetadata {
            source: metadata.config.source,
            path: metadata.config.path.as_deref(),
        },
        target: TargetMetadata {
            path: metadata.target.path,
            kind: metadata.target.kind,
            changed_only: metadata.target.changed_only,
            files_scanned: metadata.target.files_scanned,
            diff_base: metadata.target.diff_base,
        },
        timing: TimingMetadata {
            duration_ms: metadata.duration.as_millis(),
        },
        finding_counts: counts,
        findings,
    };

    serde_json::to_value(envelope).expect("Failed to serialize JSON report")
}

fn finding_counts(findings: &[Finding]) -> FindingCounts {
    let mut by_severity = SeverityCounts {
        low: 0,
        medium: 0,
        high: 0,
        critical: 0,
    };

    for finding in findings {
        match finding.severity {
            Severity::Low => by_severity.low += 1,
            Severity::Medium => by_severity.medium += 1,
            Severity::High => by_severity.high += 1,
            Severity::Critical => by_severity.critical += 1,
        }
    }

    FindingCounts {
        total: findings.len(),
        by_severity,
    }
}

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

    fn finding(rule_id: &str, severity: Severity) -> Finding {
        Finding {
            rule_id: rule_id.to_string(),
            severity,
            cwe: Some("CWE-79".to_string()),
            description: "Use of dangerous HTML sink".to_string(),
            file: "src/app.js".to_string(),
            line: 3,
            column: 5,
            end_line: 3,
            end_column: 12,
            snippet: "sink(value)".to_string(),
            source_line: None,
            source_description: None,
            sink_line: None,
            sink_description: None,
            fix_suggestion: None,
            sink_start_byte: None,
            sink_end_byte: None,
            confidence: 1.0,
            taint_hops: None,
            tags: vec![],
            crypto_algorithm: None,
            cnsa2_deadline: None,
            dep_name: None,
            dep_version: None,
            dep_ecosystem: None,
            dep_purl: None,
            dep_vulnerability_id: None,
            dep_fixed_version: None,
            dep_source: None,
            dep_vulnerability_severity: None,
            dep_path: vec![],
            crypto_material: None,
        }
    }

    #[test]
    fn json_report_wraps_findings_in_versioned_envelope() {
        let findings = vec![
            finding("js/no-innerhtml", Severity::High),
            finding("js/no-eval", Severity::Medium),
        ];
        let report = build_json_report(
            &findings,
            JsonReportMetadata {
                command: "scan",
                config: JsonConfigMetadata {
                    source: "explicit",
                    path: Some("/dev/null".to_string()),
                },
                target: JsonTargetMetadata {
                    path: ".",
                    kind: "directory",
                    changed_only: false,
                    files_scanned: 12,
                    diff_base: None,
                },
                duration: Duration::from_millis(420),
            },
        );

        assert_eq!(report["schema_version"].as_str(), Some(JSON_SCHEMA_VERSION));
        assert_eq!(
            report["finding_schema_version"].as_str(),
            Some(FINDING_SCHEMA_VERSION)
        );
        assert_eq!(report["scanner"]["name"].as_str(), Some("foxguard"));
        assert_eq!(report["scanner"]["command"].as_str(), Some("scan"));
        assert_eq!(report["config"]["source"].as_str(), Some("explicit"));
        assert_eq!(report["config"]["path"].as_str(), Some("/dev/null"));
        assert_eq!(report["target"]["files_scanned"].as_u64(), Some(12));
        assert_eq!(report["timing"]["duration_ms"].as_u64(), Some(420));
        assert_eq!(report["finding_counts"]["total"].as_u64(), Some(2));
        assert_eq!(
            report["finding_counts"]["by_severity"]["medium"].as_u64(),
            Some(1)
        );
        assert_eq!(
            report["finding_counts"]["by_severity"]["high"].as_u64(),
            Some(1)
        );
        assert_eq!(report["findings"].as_array().map(Vec::len), Some(2));
    }

    #[test]
    fn native_v1_contract_fixture_deserializes_as_current_finding() {
        let fixture: serde_json::Value =
            serde_json::from_str(include_str!("../../tests/contracts/native-report-v1.json"))
                .expect("native v1 contract fixture must be valid JSON");

        assert_eq!(fixture["schema_version"], JSON_SCHEMA_VERSION);
        assert_eq!(fixture["finding_schema_version"], FINDING_SCHEMA_VERSION);
        let finding: Finding = serde_json::from_value(fixture["findings"][0].clone())
            .expect("native v1 fixture must deserialize as Finding");
        assert_eq!(finding.rule_id, "js/taint-command-injection");
        assert_eq!(finding.source_line, Some(8));
        assert_eq!(
            finding.dep_purl.as_deref(),
            Some("pkg:npm/example-package@1.2.3")
        );

        let mut serialized = serde_json::to_value(finding).expect("Finding must serialize");
        let mut fixture_finding = fixture["findings"][0].clone();
        let serialized_confidence = serialized["confidence"]
            .as_f64()
            .expect("serialized confidence must be numeric");
        assert!((serialized_confidence - 0.91).abs() < 0.001);
        serialized
            .as_object_mut()
            .expect("serialized Finding must be an object")
            .remove("confidence");
        fixture_finding
            .as_object_mut()
            .expect("fixture Finding must be an object")
            .remove("confidence");
        assert_eq!(serialized, fixture_finding);
    }

    #[test]
    fn finding_v1_json_schema_is_valid_json_and_tracks_version() {
        let schema: serde_json::Value =
            serde_json::from_str(include_str!("../../schemas/finding-v1.schema.json"))
                .expect("finding v1 schema must be valid JSON");
        assert_eq!(schema["title"], "Foxguard Finding v1");
        assert!(schema["$id"]
            .as_str()
            .is_some_and(|id| id.ends_with("finding-v1.schema.json")));
    }
}