Skip to main content

rustinel_core/
sarif.rs

1//! SARIF 2.1.0 serialization for the analysis findings.
2//!
3//! Output is deterministic and safe to publish to code-scanning dashboards.
4//! Message text is plain (SARIF consumers render it as text, not HTML), but we
5//! still flatten control characters defensively.
6
7use crate::report::RustinelReport;
8use crate::signals::{RiskSignal, Severity};
9use serde::Serialize;
10use std::collections::BTreeMap;
11
12#[derive(Serialize)]
13pub struct SarifLog {
14    #[serde(rename = "$schema")]
15    schema: String,
16    version: String,
17    runs: Vec<SarifRun>,
18}
19
20#[derive(Serialize)]
21struct SarifRun {
22    tool: SarifTool,
23    results: Vec<SarifResult>,
24}
25
26#[derive(Serialize)]
27struct SarifTool {
28    driver: SarifDriver,
29}
30
31#[derive(Serialize)]
32struct SarifDriver {
33    name: String,
34    version: String,
35    #[serde(rename = "informationUri")]
36    information_uri: String,
37    rules: Vec<SarifRule>,
38}
39
40#[derive(Serialize)]
41struct SarifRule {
42    id: String,
43    name: String,
44    #[serde(rename = "shortDescription")]
45    short_description: SarifText,
46    help: SarifText,
47}
48
49#[derive(Serialize)]
50struct SarifResult {
51    #[serde(rename = "ruleId")]
52    rule_id: String,
53    level: String,
54    message: SarifText,
55    /// Every result carries a location — GitHub code scanning drops results that
56    /// have none. Dependency findings have no source line, so they anchor to the
57    /// lockfile where the dependency is declared.
58    locations: Vec<SarifLocation>,
59    /// Stable, deterministic fingerprint so GitHub code scanning tracks the same
60    /// finding across runs (no alert churn) instead of recomputing from text.
61    #[serde(rename = "partialFingerprints")]
62    partial_fingerprints: BTreeMap<String, String>,
63}
64
65#[derive(Serialize)]
66struct SarifLocation {
67    #[serde(rename = "physicalLocation")]
68    physical_location: SarifPhysicalLocation,
69}
70
71#[derive(Serialize)]
72struct SarifPhysicalLocation {
73    #[serde(rename = "artifactLocation")]
74    artifact_location: SarifArtifactLocation,
75    region: SarifRegion,
76}
77
78#[derive(Serialize)]
79struct SarifArtifactLocation {
80    uri: String,
81}
82
83#[derive(Serialize)]
84struct SarifRegion {
85    #[serde(rename = "startLine")]
86    start_line: u32,
87}
88
89#[derive(Serialize)]
90struct SarifText {
91    text: String,
92}
93
94/// The repo-root lockfile is the conventional location for dependency findings.
95/// (rustinel does not track per-package line numbers, so results anchor to the
96/// file rather than a specific line.)
97const LOCKFILE_URI: &str = "Cargo.lock";
98
99/// FNV-1a 64-bit, hex-encoded. Deterministic across platforms and versions — the
100/// `std` hashers are not guaranteed stable, which would break fingerprint
101/// continuity, so we hash explicitly.
102fn fnv1a_hex(s: &str) -> String {
103    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
104    for b in s.bytes() {
105        hash ^= b as u64;
106        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
107    }
108    format!("{hash:016x}")
109}
110
111fn level_for(severity: Severity) -> &'static str {
112    match severity {
113        Severity::Critical | Severity::High => "error",
114        Severity::Medium => "warning",
115        Severity::Low | Severity::Info => "note",
116    }
117}
118
119fn flatten(text: &str) -> String {
120    text.chars()
121        .map(|c| if c.is_control() { ' ' } else { c })
122        .collect()
123}
124
125pub fn build(report: &RustinelReport) -> SarifLog {
126    // One rule per distinct finding id, in deterministic order.
127    let mut rules_map: BTreeMap<String, SarifRule> = BTreeMap::new();
128    for finding in &report.findings {
129        rules_map
130            .entry(finding.id.clone())
131            .or_insert_with(|| SarifRule {
132                id: finding.id.clone(),
133                name: rule_name(finding),
134                short_description: SarifText {
135                    text: rule_name(finding),
136                },
137                help: SarifText {
138                    text: flatten(&finding.recommendation),
139                },
140            });
141    }
142    let rules: Vec<SarifRule> = rules_map.into_values().collect();
143
144    let results: Vec<SarifResult> = report
145        .findings
146        .iter()
147        .map(|f| {
148            let detail = f
149                .evidence
150                .first()
151                .map(|e| flatten(&e.summary))
152                .unwrap_or_else(|| f.id.clone());
153            let mut fingerprints = BTreeMap::new();
154            // Keyed on rule + package so the same finding on the same package
155            // keeps one stable alert; the `/v1` namespace lets the scheme evolve.
156            fingerprints.insert(
157                "rustinel/v1".to_string(),
158                fnv1a_hex(&format!("{}\u{0}{}", f.id, f.package)),
159            );
160            SarifResult {
161                rule_id: f.id.clone(),
162                level: level_for(f.severity).to_string(),
163                message: SarifText {
164                    text: flatten(&format!("{}: {}", f.package, detail)),
165                },
166                locations: vec![SarifLocation {
167                    physical_location: SarifPhysicalLocation {
168                        artifact_location: SarifArtifactLocation {
169                            uri: LOCKFILE_URI.to_string(),
170                        },
171                        region: SarifRegion { start_line: 1 },
172                    },
173                }],
174                partial_fingerprints: fingerprints,
175            }
176        })
177        .collect();
178
179    SarifLog {
180        schema: "https://json.schemastore.org/sarif-2.1.0.json".into(),
181        version: "2.1.0".into(),
182        runs: vec![SarifRun {
183            tool: SarifTool {
184                driver: SarifDriver {
185                    name: report.tool.name.clone(),
186                    version: report.tool.version.clone(),
187                    information_uri: "https://github.com/kosiorkosa47/rustinel".into(),
188                    rules,
189                },
190            },
191            results,
192        }],
193    }
194}
195
196fn rule_name(finding: &RiskSignal) -> String {
197    match finding.id.as_str() {
198        "native_ffi_detected" => "Native FFI dependency detected".into(),
199        "build_script_present" => "Build script present".into(),
200        "build_script_suspicious" => "Suspicious build script (network / payload)".into(),
201        "suspicious_source_exfil" => "Source matches secret-exfiltration malware pattern".into(),
202        "unsafe_present" => "Unsafe code present".into(),
203        "multiple_versions_same_crate" => "Multiple versions of the same crate".into(),
204        "possible_typosquat" => "Possible typosquat of a popular crate".into(),
205        "yanked_crate" => "Yanked crate version".into(),
206        "license_unknown" => "Unknown license".into(),
207        "license_detected" => "License detected".into(),
208        id if id.starts_with("advisory_") => "Known security advisory".into(),
209        other => other.to_string(),
210    }
211}