Public Access
324 lines
9.3 KiB
Rust
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());
|
|
}
|
|
}
|