1use hex;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use super::types::{Finding, PatternDefinition};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SarifReport {
17 pub version: String,
19 #[serde(rename = "$schema")]
21 pub schema: String,
22 pub runs: Vec<SarifRun>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SarifRun {
29 pub tool: SarifTool,
31 pub results: Vec<SarifResult>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SarifTool {
38 pub driver: SarifDriver,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SarifDriver {
45 pub name: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub version: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 #[serde(rename = "informationUri")]
53 pub information_uri: Option<String>,
54 #[serde(skip_serializing_if = "Vec::is_empty", default)]
56 pub rules: Vec<SarifRule>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SarifResult {
62 #[serde(rename = "ruleId")]
64 pub rule_id: String,
65 pub level: String,
67 pub message: SarifMessage,
69 pub locations: Vec<SarifLocation>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub fingerprints: Option<SarifFingerprints>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SarifMessage {
79 pub text: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct SarifHelp {
87 pub text: String,
89 pub markdown: String,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct SarifRule {
97 pub id: String,
99 pub short_description: SarifMessage,
101 pub full_description: SarifMessage,
103 pub help: SarifHelp,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub help_uri: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SarifLocation {
113 #[serde(rename = "physicalLocation")]
115 pub physical_location: SarifPhysicalLocation,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SarifPhysicalLocation {
121 #[serde(rename = "artifactLocation")]
123 pub artifact_location: SarifArtifactLocation,
124 pub region: SarifRegion,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct SarifArtifactLocation {
131 pub uri: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SarifRegion {
138 #[serde(rename = "startLine")]
140 pub start_line: usize,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SarifFingerprints {
146 #[serde(rename = "primaryLocationLineHash")]
148 pub primary_location_line_hash: String,
149}
150
151impl From<Vec<Finding>> for SarifReport {
152 fn from(findings: Vec<Finding>) -> Self {
153 let results: Vec<SarifResult> = findings.into_iter().map(SarifResult::from).collect();
154
155 SarifReport {
156 version: "2.1.0".to_string(),
157 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
158 runs: vec![SarifRun {
159 tool: SarifTool {
160 driver: SarifDriver {
161 name: "aptu-security-scanner".to_string(),
162 version: Some(env!("CARGO_PKG_VERSION").to_string()),
163 information_uri: Some("https://github.com/clouatre-labs/aptu".to_string()),
164 rules: Vec::new(),
165 },
166 },
167 results,
168 }],
169 }
170 }
171}
172
173impl SarifReport {
174 pub fn with_rules(findings: Vec<Finding>, patterns: &[PatternDefinition]) -> Self {
180 let rules: Vec<SarifRule> = patterns
181 .iter()
182 .map(|p| {
183 let help_text = p.remediation.clone().unwrap_or_default();
184 SarifRule {
185 id: p.id.clone(),
186 short_description: SarifMessage {
187 text: p.description.clone(),
188 },
189 full_description: SarifMessage {
190 text: p.description.clone(),
191 },
192 help: SarifHelp {
193 text: help_text.clone(),
194 markdown: help_text,
195 },
196 help_uri: p.authority_url.clone(),
197 }
198 })
199 .collect();
200
201 let results: Vec<SarifResult> = findings.into_iter().map(SarifResult::from).collect();
202
203 SarifReport {
204 version: "2.1.0".to_string(),
205 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
206 runs: vec![SarifRun {
207 tool: SarifTool {
208 driver: SarifDriver {
209 name: "aptu-security-scanner".to_string(),
210 version: Some(env!("CARGO_PKG_VERSION").to_string()),
211 information_uri: Some("https://github.com/clouatre-labs/aptu".to_string()),
212 rules,
213 },
214 },
215 results,
216 }],
217 }
218 }
219}
220
221impl From<Finding> for SarifResult {
222 fn from(finding: Finding) -> Self {
223 let level = match finding.severity {
225 super::types::Severity::Critical | super::types::Severity::High => "error",
226 super::types::Severity::Medium => "warning",
227 super::types::Severity::Low => "note",
228 };
229
230 let fingerprint_input = format!(
232 "{}:{}:{}",
233 finding.file_path, finding.line_number, finding.pattern_id
234 );
235 let mut hasher = Sha256::new();
236 hasher.update(fingerprint_input.as_bytes());
237 let fingerprint = hex::encode(hasher.finalize());
238
239 SarifResult {
240 rule_id: finding.pattern_id,
241 level: level.to_string(),
242 message: SarifMessage {
243 text: finding.description,
244 },
245 locations: vec![SarifLocation {
246 physical_location: SarifPhysicalLocation {
247 artifact_location: SarifArtifactLocation {
248 uri: finding.file_path,
249 },
250 region: SarifRegion {
251 start_line: finding.line_number,
252 },
253 },
254 }],
255 fingerprints: Some(SarifFingerprints {
256 primary_location_line_hash: fingerprint,
257 }),
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::security::types::{Confidence, Severity};
266
267 #[test]
268 fn test_sarif_report_structure() {
269 let findings = vec![Finding {
270 pattern_id: "hardcoded-secret".to_string(),
271 description: "Hardcoded API key detected".to_string(),
272 severity: Severity::Critical,
273 confidence: Confidence::High,
274 file_path: "src/config.rs".to_string(),
275 line_number: 42,
276 matched_text: "api_key = \"sk-1234567890\"".to_string(),
277 cwe: Some("CWE-798".to_string()),
278 }];
279
280 let report = SarifReport::from(findings);
281
282 assert_eq!(report.version, "2.1.0");
283 assert_eq!(report.runs.len(), 1);
284 assert_eq!(report.runs[0].results.len(), 1);
285 assert_eq!(report.runs[0].tool.driver.name, "aptu-security-scanner");
286 }
287
288 #[test]
289 fn test_severity_mapping() {
290 let critical = Finding {
291 pattern_id: "test".to_string(),
292 description: "Test".to_string(),
293 severity: Severity::Critical,
294 confidence: Confidence::High,
295 file_path: "test.rs".to_string(),
296 line_number: 1,
297 matched_text: "test".to_string(),
298 cwe: None,
299 };
300
301 let result = SarifResult::from(critical.clone());
302 assert_eq!(result.level, "error");
303
304 let medium = Finding {
305 severity: Severity::Medium,
306 ..critical.clone()
307 };
308 let result = SarifResult::from(medium);
309 assert_eq!(result.level, "warning");
310
311 let low = Finding {
312 severity: Severity::Low,
313 ..critical
314 };
315 let result = SarifResult::from(low);
316 assert_eq!(result.level, "note");
317 }
318
319 #[test]
320 fn test_fingerprint_stability() {
321 let finding = Finding {
322 pattern_id: "test-pattern".to_string(),
323 description: "Test finding".to_string(),
324 severity: Severity::High,
325 confidence: Confidence::Medium,
326 file_path: "src/main.rs".to_string(),
327 line_number: 10,
328 matched_text: "test code".to_string(),
329 cwe: None,
330 };
331
332 let result1 = SarifResult::from(finding.clone());
333 let result2 = SarifResult::from(finding);
334
335 assert_eq!(
336 result1
337 .fingerprints
338 .as_ref()
339 .unwrap()
340 .primary_location_line_hash,
341 result2
342 .fingerprints
343 .as_ref()
344 .unwrap()
345 .primary_location_line_hash
346 );
347 }
348
349 #[test]
350 fn test_fingerprint_uniqueness() {
351 let finding1 = Finding {
352 pattern_id: "pattern1".to_string(),
353 description: "Test".to_string(),
354 severity: Severity::High,
355 confidence: Confidence::High,
356 file_path: "src/main.rs".to_string(),
357 line_number: 10,
358 matched_text: "test".to_string(),
359 cwe: None,
360 };
361
362 let finding2 = Finding {
363 pattern_id: "pattern2".to_string(),
364 ..finding1.clone()
365 };
366
367 let result1 = SarifResult::from(finding1);
368 let result2 = SarifResult::from(finding2);
369
370 assert_ne!(
371 result1
372 .fingerprints
373 .as_ref()
374 .unwrap()
375 .primary_location_line_hash,
376 result2
377 .fingerprints
378 .as_ref()
379 .unwrap()
380 .primary_location_line_hash
381 );
382 }
383
384 #[test]
385 fn test_sarif_serialization() {
386 let findings = vec![Finding {
387 pattern_id: "test-pattern".to_string(),
388 description: "Test finding".to_string(),
389 severity: Severity::High,
390 confidence: Confidence::Medium,
391 file_path: "src/test.rs".to_string(),
392 line_number: 5,
393 matched_text: "test".to_string(),
394 cwe: Some("CWE-123".to_string()),
395 }];
396
397 let report = SarifReport::from(findings);
398 let json = serde_json::to_string(&report).unwrap();
399
400 assert!(json.contains("\"version\":\"2.1.0\""));
401 assert!(json.contains("\"ruleId\":\"test-pattern\""));
402 assert!(json.contains("\"level\":\"error\""));
403 }
404}