vsec 0.0.1

Detect secrets and in Rust codebases
Documentation
// src/output/sarif.rs

use serde::Serialize;

use crate::models::{Finding, Severity};

/// SARIF 2.1.0 formatter for CI integration
pub struct SarifFormatter;

impl SarifFormatter {
    pub fn new() -> Self {
        Self
    }

    /// Format findings to SARIF 2.1.0
    pub fn format(&self, findings: &[Finding]) -> String {
        let results: Vec<SarifResult> = findings.iter().map(SarifResult::from).collect();

        let report = SarifReport {
            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
            version: "2.1.0",
            runs: vec![SarifRun {
                tool: SarifTool {
                    driver: SarifDriver {
                        name: "secretrace",
                        information_uri: "https://github.com/yourusername/secretrace",
                        version: env!("CARGO_PKG_VERSION").to_string(),
                        rules: vec![
                            SarifRule {
                                id: "hardcoded-secret".into(),
                                name: "Hardcoded Secret".into(),
                                short_description: SarifMessage {
                                    text: "Detects hardcoded secrets in source code".into(),
                                },
                                default_configuration: SarifConfiguration {
                                    level: "error".into(),
                                },
                            },
                            SarifRule {
                                id: "suspicious-constant".into(),
                                name: "Suspicious Constant".into(),
                                short_description: SarifMessage {
                                    text: "Detects potentially suspicious constant values".into(),
                                },
                                default_configuration: SarifConfiguration {
                                    level: "warning".into(),
                                },
                            },
                        ],
                    },
                },
                results,
            }],
        };

        serde_json::to_string_pretty(&report).unwrap_or_default()
    }
}

impl Default for SarifFormatter {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Serialize)]
struct SarifReport {
    #[serde(rename = "$schema")]
    schema: &'static str,
    version: &'static str,
    runs: Vec<SarifRun>,
}

#[derive(Serialize)]
struct SarifRun {
    tool: SarifTool,
    results: Vec<SarifResult>,
}

#[derive(Serialize)]
struct SarifTool {
    driver: SarifDriver,
}

#[derive(Serialize)]
struct SarifDriver {
    name: &'static str,
    #[serde(rename = "informationUri")]
    information_uri: &'static str,
    version: String,
    rules: Vec<SarifRule>,
}

#[derive(Serialize)]
struct SarifRule {
    id: String,
    name: String,
    #[serde(rename = "shortDescription")]
    short_description: SarifMessage,
    #[serde(rename = "defaultConfiguration")]
    default_configuration: SarifConfiguration,
}

#[derive(Serialize)]
struct SarifConfiguration {
    level: String,
}

#[derive(Serialize)]
struct SarifResult {
    #[serde(rename = "ruleId")]
    rule_id: String,
    level: String,
    message: SarifMessage,
    locations: Vec<SarifLocation>,
}

#[derive(Serialize)]
struct SarifMessage {
    text: String,
}

#[derive(Serialize)]
struct SarifLocation {
    #[serde(rename = "physicalLocation")]
    physical_location: SarifPhysicalLocation,
}

#[derive(Serialize)]
struct SarifPhysicalLocation {
    #[serde(rename = "artifactLocation")]
    artifact_location: SarifArtifactLocation,
    region: SarifRegion,
}

#[derive(Serialize)]
struct SarifArtifactLocation {
    uri: String,
}

#[derive(Serialize)]
struct SarifRegion {
    #[serde(rename = "startLine")]
    start_line: u32,
    #[serde(rename = "startColumn")]
    start_column: u32,
}

impl From<&Finding> for SarifResult {
    fn from(f: &Finding) -> Self {
        let (rule_id, level) = match f.score.severity {
            Severity::Critical | Severity::High => ("hardcoded-secret", "error"),
            Severity::Medium => ("suspicious-constant", "warning"),
            _ => ("suspicious-constant", "note"),
        };

        SarifResult {
            rule_id: rule_id.into(),
            level: level.into(),
            message: SarifMessage {
                text: f.explanation.clone(),
            },
            locations: vec![SarifLocation {
                physical_location: SarifPhysicalLocation {
                    artifact_location: SarifArtifactLocation {
                        uri: f.location.file.to_string_lossy().into(),
                    },
                    region: SarifRegion {
                        start_line: f.location.line,
                        start_column: f.location.column,
                    },
                },
            }],
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{FindingId, Score, SourceLocation, SuspectValue};
    use std::path::PathBuf;

    fn create_test_finding(severity_score: i32) -> Finding {
        Finding {
            id: FindingId::new("test-finding-1"),
            suspect: SuspectValue::Constant {
                name: "API_KEY".to_string(),
                value: "sk_live_12345".to_string(),
                type_annotation: Some("&str".to_string()),
            },
            location: SourceLocation::new(PathBuf::from("src/main.rs"), 10, 5),
            usage: None,
            context: Default::default(),
            score: Score::from_total(severity_score),
            explanation: "Test explanation".to_string(),
            remediation: Some("Test remediation".to_string()),
            metadata: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn test_sarif_format_empty() {
        let formatter = SarifFormatter::new();
        let output = formatter.format(&[]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        assert_eq!(parsed["version"], "2.1.0");
        assert!(parsed["runs"][0]["results"].as_array().unwrap().is_empty());
    }

    #[test]
    fn test_sarif_format_high_severity() {
        let formatter = SarifFormatter::new();
        let finding = create_test_finding(90); // Critical
        let output = formatter.format(&[finding]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        let result = &parsed["runs"][0]["results"][0];
        assert_eq!(result["ruleId"], "hardcoded-secret");
        assert_eq!(result["level"], "error");
    }

    #[test]
    fn test_sarif_format_medium_severity() {
        let formatter = SarifFormatter::new();
        let finding = create_test_finding(60); // Medium
        let output = formatter.format(&[finding]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        let result = &parsed["runs"][0]["results"][0];
        assert_eq!(result["ruleId"], "suspicious-constant");
        assert_eq!(result["level"], "warning");
    }

    #[test]
    fn test_sarif_has_schema() {
        let formatter = SarifFormatter::new();
        let output = formatter.format(&[]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        assert!(parsed["$schema"].as_str().unwrap().contains("sarif-schema-2.1.0"));
    }

    #[test]
    fn test_sarif_location() {
        let formatter = SarifFormatter::new();
        let finding = create_test_finding(85);
        let output = formatter.format(&[finding]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        let location = &parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"];
        assert_eq!(location["region"]["startLine"], 10);
        assert_eq!(location["region"]["startColumn"], 5);
    }
}