1use 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 locations: Vec<SarifLocation>,
59 #[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
94const LOCKFILE_URI: &str = "Cargo.lock";
98
99fn 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 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 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}