Files
charybdis/src/scanners/sarif.rs
T
Guillaume GRABÉ 1302ce412c
CI / Format (push) Successful in 48s
CI / Test (push) Failing after 7m9s
CI / Check (push) Successful in 12m6s
CI / Clippy (push) Successful in 12m10s
fix: format and lint
2026-06-08 23:51:29 +02:00

324 lines
9.3 KiB
Rust

use std::collections::HashMap;
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{NormalizedFinding, ParsedReport, ScannerParser, Severity};
pub struct SarifParser;
impl ScannerParser for SarifParser {
fn format_id(&self) -> &str {
"sarif"
}
fn description(&self) -> &str {
"SARIF v2.1.0 (Static Analysis Results Interchange Format)"
}
fn parse(&self, data: &[u8]) -> Result<ParsedReport> {
let report: SarifReport =
serde_json::from_slice(data).context("Failed to parse SARIF JSON")?;
let mut findings = Vec::new();
for run in &report.runs {
let tool_name = &run.tool.driver.name;
let rules = &run.tool.driver.rules;
for result in &run.results {
let rule_id = result.rule_id.clone().unwrap_or_default();
let rule_meta = rules
.as_ref()
.and_then(|r| r.iter().find(|rule| rule.id == rule_id));
let title = result
.message
.text
.clone()
.unwrap_or_else(|| rule_id.clone());
let description = rule_meta
.and_then(|r| r.short_description.as_ref())
.and_then(|d| d.text.clone())
.unwrap_or_default();
let severity = resolve_severity(result.level.as_deref(), rule_meta);
let (file_path, line_start, line_end) = extract_location(result);
let details_url = rule_meta.and_then(|r| r.help_uri.clone());
let tags = rule_meta
.and_then(|r| r.properties.as_ref())
.and_then(|p| p.tags.clone())
.unwrap_or_default();
let cwe = extract_cwe(&tags);
let scanner_fingerprint = extract_scanner_fingerprint(result);
findings.push(NormalizedFinding {
title,
description,
severity,
scanner: tool_name.clone(),
rule_id,
file_path,
line_start,
line_end,
cwe,
cve: None,
cvss_score: None,
package_name: None,
package_version: None,
fixed_version: None,
details_url,
tags,
scanner_fingerprint,
});
}
}
Ok(ParsedReport {
scanner_name: report
.runs
.first()
.map(|r| r.tool.driver.name.clone())
.unwrap_or_else(|| "unknown".to_string()),
findings,
})
}
}
fn resolve_severity(level: Option<&str>, rule_meta: Option<&SarifRule>) -> Severity {
let level = level.or_else(|| {
rule_meta
.and_then(|r| r.default_configuration.as_ref())
.and_then(|c| c.level.as_deref())
});
match level {
Some("error") => Severity::High,
Some("warning") => Severity::Medium,
Some("note") => Severity::Low,
Some("none") => Severity::Info,
_ => Severity::Medium,
}
}
fn extract_location(result: &SarifResult) -> (Option<String>, Option<u32>, Option<u32>) {
let location = match result.locations.as_ref().and_then(|l| l.first()) {
Some(loc) => loc,
None => return (None, None, None),
};
let artifact = match &location.physical_location {
Some(pl) => pl,
None => return (None, None, None),
};
let file_path = artifact
.artifact_location
.as_ref()
.and_then(|al| al.uri.clone());
let line_start = artifact.region.as_ref().and_then(|r| r.start_line);
let line_end = artifact.region.as_ref().and_then(|r| r.end_line);
(file_path, line_start, line_end)
}
fn extract_cwe(tags: &[String]) -> Option<String> {
tags.iter()
.find(|t| t.starts_with("CWE-") || t.starts_with("cwe-"))
.cloned()
}
/// Extract a stable fingerprint from SARIF partialFingerprints or fingerprints.
/// Scanners like Semgrep, CodeQL, and Checkov provide content-based hashes here
/// that survive line shifts.
fn extract_scanner_fingerprint(result: &SarifResult) -> Option<String> {
if let Some(ref fps) = result.partial_fingerprints {
// Prefer primaryLocationLineHash (most common), then any first value
if let Some(v) = fps.get("primaryLocationLineHash") {
return Some(v.clone());
}
if let Some(v) = fps.values().next() {
return Some(v.clone());
}
}
if let Some(ref fps) = result.fingerprints
&& let Some(v) = fps.values().next()
{
return Some(v.clone());
}
None
}
// SARIF v2.1.0 schema (subset relevant to findings)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifReport {
#[serde(default)]
runs: Vec<SarifRun>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifRun {
tool: SarifTool,
#[serde(default)]
results: Vec<SarifResult>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifDriver {
name: String,
#[serde(default)]
rules: Option<Vec<SarifRule>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifRule {
id: String,
short_description: Option<SarifMessage>,
help_uri: Option<String>,
default_configuration: Option<SarifConfiguration>,
properties: Option<SarifRuleProperties>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifConfiguration {
level: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifRuleProperties {
#[serde(default)]
tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifResult {
rule_id: Option<String>,
level: Option<String>,
message: SarifMessage,
locations: Option<Vec<SarifLocation>>,
partial_fingerprints: Option<HashMap<String, String>>,
fingerprints: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifMessage {
text: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifLocation {
physical_location: Option<SarifPhysicalLocation>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifPhysicalLocation {
artifact_location: Option<SarifArtifactLocation>,
region: Option<SarifRegion>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifArtifactLocation {
uri: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SarifRegion {
start_line: Option<u32>,
end_line: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_sarif() {
let sarif = r#"{
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "TestScanner",
"rules": [{
"id": "TEST-001",
"shortDescription": { "text": "Test rule description" },
"defaultConfiguration": { "level": "error" }
}]
}
},
"results": [{
"ruleId": "TEST-001",
"level": "error",
"message": { "text": "Found a vulnerability" },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": "src/main.rs" },
"region": { "startLine": 42, "endLine": 42 }
}
}]
}]
}]
}"#;
let parser = SarifParser;
let report = parser.parse(sarif.as_bytes()).unwrap();
assert_eq!(report.scanner_name, "TestScanner");
assert_eq!(report.findings.len(), 1);
let finding = &report.findings[0];
assert_eq!(finding.title, "Found a vulnerability");
assert_eq!(finding.rule_id, "TEST-001");
assert_eq!(finding.severity, Severity::High);
assert_eq!(finding.file_path.as_deref(), Some("src/main.rs"));
assert_eq!(finding.line_start, Some(42));
}
#[test]
fn test_parse_sarif_no_locations() {
let sarif = r#"{
"version": "2.1.0",
"runs": [{
"tool": { "driver": { "name": "Scanner" } },
"results": [{
"ruleId": "RULE-1",
"message": { "text": "Global issue" }
}]
}]
}"#;
let parser = SarifParser;
let report = parser.parse(sarif.as_bytes()).unwrap();
assert_eq!(report.findings.len(), 1);
assert!(report.findings[0].file_path.is_none());
}
}