use crate::{Finding, Severity};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
pub const SEMGREP_JSON_SCHEMA_VERSION: &str = "1.0.0";
fn semgrep_severity(severity: Severity) -> &'static str {
match severity {
Severity::Critical | Severity::High => "ERROR",
Severity::Medium => "WARNING",
Severity::Low => "INFO",
}
}
fn confidence_bucket(confidence: f32) -> &'static str {
let c = confidence.clamp(0.0, 1.0);
if c >= 0.8 {
"HIGH"
} else if c >= 0.5 {
"MEDIUM"
} else {
"LOW"
}
}
fn fingerprint(finding: &Finding) -> String {
let mut hasher = Sha256::new();
hasher.update(finding.rule_id.as_bytes());
hasher.update([0]);
hasher.update(finding.file.as_bytes());
hasher.update([0]);
hasher.update(finding.line.to_string().as_bytes());
hasher.update([0]);
hasher.update(finding.column.to_string().as_bytes());
hasher.update([0]);
hasher.update(finding.end_line.to_string().as_bytes());
hasher.update([0]);
hasher.update(finding.end_column.to_string().as_bytes());
hasher.update([0]);
let snippet = finding
.snippet
.replace("\r\n", "\n")
.replace('\r', "\n")
.chars()
.filter(|c| *c != ' ' && *c != '\t')
.collect::<String>();
hasher.update(snippet.as_bytes());
let digest = hasher.finalize();
digest
.iter()
.take(16)
.map(|b| format!("{:02x}", b))
.collect()
}
fn position(line: usize, col: usize) -> Value {
json!({
"line": line,
"col": col,
"offset": 0
})
}
fn metadata(finding: &Finding) -> Value {
let mut map = serde_json::Map::new();
if let Some(cwe) = &finding.cwe {
map.insert("cwe".to_string(), json!([cwe]));
}
map.insert(
"confidence".to_string(),
json!(confidence_bucket(finding.confidence)),
);
map.insert(
"confidence_score".to_string(),
json!(finding.confidence.clamp(0.0, 1.0)),
);
if let Some(hops) = finding.taint_hops {
map.insert("taint_hops".to_string(), json!(hops));
}
if !finding.tags.is_empty() {
map.insert("tags".to_string(), json!(finding.tags));
}
Value::Object(map)
}
fn result(finding: &Finding) -> Value {
json!({
"check_id": finding.rule_id,
"path": finding.file.replace('\\', "/"),
"start": position(finding.line, finding.column),
"end": position(finding.end_line, finding.end_column),
"extra": {
"message": finding.description,
"metadata": metadata(finding),
"severity": semgrep_severity(finding.severity),
"lines": finding.snippet,
"fingerprint": fingerprint(finding),
}
})
}
pub fn build_semgrep_json(findings: &[Finding]) -> Value {
let results: Vec<Value> = findings.iter().map(result).collect();
let scanned: BTreeSet<String> = findings
.iter()
.map(|f| f.file.replace('\\', "/"))
.filter(|p| !p.is_empty())
.collect();
json!({
"version": SEMGREP_JSON_SCHEMA_VERSION,
"results": results,
"errors": [],
"paths": {
"scanned": scanned.into_iter().collect::<Vec<_>>()
}
})
}
#[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-89".to_string()),
description: "Tainted input reaches SQL sink".to_string(),
file: "app/views.py".to_string(),
line: 12,
column: 5,
end_line: 12,
end_column: 24,
snippet: "cursor.execute(sql)".to_string(),
source_line: Some(10),
source_description: Some("request.GET".to_string()),
sink_line: Some(12),
sink_description: Some("cursor.execute".to_string()),
fix_suggestion: None,
sink_start_byte: None,
sink_end_byte: None,
confidence: 0.9,
taint_hops: Some(1),
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 emits_semgrep_result_shape() {
let doc = build_semgrep_json(&[finding("py/taint-sql-injection", Severity::Critical)]);
assert_eq!(doc["version"].as_str(), Some(SEMGREP_JSON_SCHEMA_VERSION));
assert_eq!(doc["errors"].as_array().map(Vec::len), Some(0));
assert_eq!(
doc["paths"]["scanned"][0].as_str(),
Some("app/views.py"),
"scanned paths should list the finding's file"
);
let r = &doc["results"][0];
assert_eq!(r["check_id"].as_str(), Some("py/taint-sql-injection"));
assert_eq!(r["path"].as_str(), Some("app/views.py"));
assert_eq!(r["start"]["line"].as_u64(), Some(12));
assert_eq!(r["start"]["col"].as_u64(), Some(5));
assert_eq!(r["end"]["line"].as_u64(), Some(12));
assert_eq!(r["end"]["col"].as_u64(), Some(24));
assert_eq!(r["extra"]["severity"].as_str(), Some("ERROR"));
assert_eq!(
r["extra"]["message"].as_str(),
Some("Tainted input reaches SQL sink")
);
assert_eq!(r["extra"]["lines"].as_str(), Some("cursor.execute(sql)"));
assert_eq!(r["extra"]["metadata"]["cwe"][0].as_str(), Some("CWE-89"));
assert_eq!(r["extra"]["metadata"]["confidence"].as_str(), Some("HIGH"));
assert!(r["extra"]["fingerprint"].as_str().is_some());
}
#[test]
fn severity_maps_four_levels_to_three() {
assert_eq!(semgrep_severity(Severity::Critical), "ERROR");
assert_eq!(semgrep_severity(Severity::High), "ERROR");
assert_eq!(semgrep_severity(Severity::Medium), "WARNING");
assert_eq!(semgrep_severity(Severity::Low), "INFO");
}
#[test]
fn confidence_buckets() {
assert_eq!(confidence_bucket(1.0), "HIGH");
assert_eq!(confidence_bucket(0.8), "HIGH");
assert_eq!(confidence_bucket(0.7), "MEDIUM");
assert_eq!(confidence_bucket(0.5), "MEDIUM");
assert_eq!(confidence_bucket(0.3), "LOW");
}
#[test]
fn fingerprint_is_stable_and_distinct() {
let a = finding("py/a", Severity::High);
let mut b = finding("py/b", Severity::High);
assert_eq!(fingerprint(&a), fingerprint(&a), "stable across calls");
assert_ne!(
fingerprint(&a),
fingerprint(&b),
"different rule_id -> different fingerprint"
);
b.rule_id = "py/a".to_string();
b.line = 99;
assert_ne!(
fingerprint(&a),
fingerprint(&b),
"different line -> different fingerprint"
);
}
#[test]
fn paths_scanned_dedups_and_sorts() {
let doc = build_semgrep_json(&[
finding("py/a", Severity::High),
finding("py/b", Severity::High),
]);
assert_eq!(doc["paths"]["scanned"].as_array().map(Vec::len), Some(1));
assert_eq!(doc["results"].as_array().map(Vec::len), Some(2));
}
}