Public Access
initial-commit
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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 {
|
||||
if 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user