1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::types::Finding;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SarifReport {
16 pub version: String,
18 #[serde(rename = "$schema")]
20 pub schema: String,
21 pub runs: Vec<SarifRun>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SarifRun {
28 pub tool: SarifTool,
30 pub results: Vec<SarifResult>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SarifTool {
37 pub driver: SarifDriver,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SarifDriver {
44 pub name: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub version: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 #[serde(rename = "informationUri")]
52 pub information_uri: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SarifResult {
58 #[serde(rename = "ruleId")]
60 pub rule_id: String,
61 pub level: String,
63 pub message: SarifMessage,
65 pub locations: Vec<SarifLocation>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub fingerprints: Option<SarifFingerprints>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SarifMessage {
75 pub text: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SarifLocation {
82 #[serde(rename = "physicalLocation")]
84 pub physical_location: SarifPhysicalLocation,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct SarifPhysicalLocation {
90 #[serde(rename = "artifactLocation")]
92 pub artifact_location: SarifArtifactLocation,
93 pub region: SarifRegion,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SarifArtifactLocation {
100 pub uri: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct SarifRegion {
107 #[serde(rename = "startLine")]
109 pub start_line: usize,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SarifFingerprints {
115 #[serde(rename = "primaryLocationLineHash")]
117 pub primary_location_line_hash: String,
118}
119
120impl From<Vec<Finding>> for SarifReport {
121 fn from(findings: Vec<Finding>) -> Self {
122 let results: Vec<SarifResult> = findings.into_iter().map(SarifResult::from).collect();
123
124 SarifReport {
125 version: "2.1.0".to_string(),
126 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
127 runs: vec![SarifRun {
128 tool: SarifTool {
129 driver: SarifDriver {
130 name: "aptu-security-scanner".to_string(),
131 version: Some(env!("CARGO_PKG_VERSION").to_string()),
132 information_uri: Some("https://github.com/clouatre-labs/aptu".to_string()),
133 },
134 },
135 results,
136 }],
137 }
138 }
139}
140
141impl From<Finding> for SarifResult {
142 fn from(finding: Finding) -> Self {
143 let level = match finding.severity {
145 super::types::Severity::Critical | super::types::Severity::High => "error",
146 super::types::Severity::Medium => "warning",
147 super::types::Severity::Low => "note",
148 };
149
150 let fingerprint_input = format!(
152 "{}:{}:{}",
153 finding.file_path, finding.line_number, finding.pattern_id
154 );
155 let mut hasher = Sha256::new();
156 hasher.update(fingerprint_input.as_bytes());
157 let hash = hasher.finalize();
158 let fingerprint = format!("{hash:x}");
159
160 SarifResult {
161 rule_id: finding.pattern_id,
162 level: level.to_string(),
163 message: SarifMessage {
164 text: finding.description,
165 },
166 locations: vec![SarifLocation {
167 physical_location: SarifPhysicalLocation {
168 artifact_location: SarifArtifactLocation {
169 uri: finding.file_path,
170 },
171 region: SarifRegion {
172 start_line: finding.line_number,
173 },
174 },
175 }],
176 fingerprints: Some(SarifFingerprints {
177 primary_location_line_hash: fingerprint,
178 }),
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::security::types::{Confidence, Severity};
187
188 #[test]
189 fn test_sarif_report_structure() {
190 let findings = vec![Finding {
191 pattern_id: "hardcoded-secret".to_string(),
192 description: "Hardcoded API key detected".to_string(),
193 severity: Severity::Critical,
194 confidence: Confidence::High,
195 file_path: "src/config.rs".to_string(),
196 line_number: 42,
197 matched_text: "api_key = \"sk-1234567890\"".to_string(),
198 cwe: Some("CWE-798".to_string()),
199 }];
200
201 let report = SarifReport::from(findings);
202
203 assert_eq!(report.version, "2.1.0");
204 assert_eq!(report.runs.len(), 1);
205 assert_eq!(report.runs[0].results.len(), 1);
206 assert_eq!(report.runs[0].tool.driver.name, "aptu-security-scanner");
207 }
208
209 #[test]
210 fn test_severity_mapping() {
211 let critical = Finding {
212 pattern_id: "test".to_string(),
213 description: "Test".to_string(),
214 severity: Severity::Critical,
215 confidence: Confidence::High,
216 file_path: "test.rs".to_string(),
217 line_number: 1,
218 matched_text: "test".to_string(),
219 cwe: None,
220 };
221
222 let result = SarifResult::from(critical.clone());
223 assert_eq!(result.level, "error");
224
225 let medium = Finding {
226 severity: Severity::Medium,
227 ..critical.clone()
228 };
229 let result = SarifResult::from(medium);
230 assert_eq!(result.level, "warning");
231
232 let low = Finding {
233 severity: Severity::Low,
234 ..critical
235 };
236 let result = SarifResult::from(low);
237 assert_eq!(result.level, "note");
238 }
239
240 #[test]
241 fn test_fingerprint_stability() {
242 let finding = Finding {
243 pattern_id: "test-pattern".to_string(),
244 description: "Test finding".to_string(),
245 severity: Severity::High,
246 confidence: Confidence::Medium,
247 file_path: "src/main.rs".to_string(),
248 line_number: 10,
249 matched_text: "test code".to_string(),
250 cwe: None,
251 };
252
253 let result1 = SarifResult::from(finding.clone());
254 let result2 = SarifResult::from(finding);
255
256 assert_eq!(
257 result1
258 .fingerprints
259 .as_ref()
260 .unwrap()
261 .primary_location_line_hash,
262 result2
263 .fingerprints
264 .as_ref()
265 .unwrap()
266 .primary_location_line_hash
267 );
268 }
269
270 #[test]
271 fn test_fingerprint_uniqueness() {
272 let finding1 = Finding {
273 pattern_id: "pattern1".to_string(),
274 description: "Test".to_string(),
275 severity: Severity::High,
276 confidence: Confidence::High,
277 file_path: "src/main.rs".to_string(),
278 line_number: 10,
279 matched_text: "test".to_string(),
280 cwe: None,
281 };
282
283 let finding2 = Finding {
284 pattern_id: "pattern2".to_string(),
285 ..finding1.clone()
286 };
287
288 let result1 = SarifResult::from(finding1);
289 let result2 = SarifResult::from(finding2);
290
291 assert_ne!(
292 result1
293 .fingerprints
294 .as_ref()
295 .unwrap()
296 .primary_location_line_hash,
297 result2
298 .fingerprints
299 .as_ref()
300 .unwrap()
301 .primary_location_line_hash
302 );
303 }
304
305 #[test]
306 fn test_sarif_serialization() {
307 let findings = vec![Finding {
308 pattern_id: "test-pattern".to_string(),
309 description: "Test finding".to_string(),
310 severity: Severity::High,
311 confidence: Confidence::Medium,
312 file_path: "src/test.rs".to_string(),
313 line_number: 5,
314 matched_text: "test".to_string(),
315 cwe: Some("CWE-123".to_string()),
316 }];
317
318 let report = SarifReport::from(findings);
319 let json = serde_json::to_string(&report).unwrap();
320
321 assert!(json.contains("\"version\":\"2.1.0\""));
322 assert!(json.contains("\"ruleId\":\"test-pattern\""));
323 assert!(json.contains("\"level\":\"error\""));
324 }
325}