1use hex;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use super::types::Finding;
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}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SarifResult {
59 #[serde(rename = "ruleId")]
61 pub rule_id: String,
62 pub level: String,
64 pub message: SarifMessage,
66 pub locations: Vec<SarifLocation>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub fingerprints: Option<SarifFingerprints>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SarifMessage {
76 pub text: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct SarifLocation {
83 #[serde(rename = "physicalLocation")]
85 pub physical_location: SarifPhysicalLocation,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SarifPhysicalLocation {
91 #[serde(rename = "artifactLocation")]
93 pub artifact_location: SarifArtifactLocation,
94 pub region: SarifRegion,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SarifArtifactLocation {
101 pub uri: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct SarifRegion {
108 #[serde(rename = "startLine")]
110 pub start_line: usize,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct SarifFingerprints {
116 #[serde(rename = "primaryLocationLineHash")]
118 pub primary_location_line_hash: String,
119}
120
121impl From<Vec<Finding>> for SarifReport {
122 fn from(findings: Vec<Finding>) -> Self {
123 let results: Vec<SarifResult> = findings.into_iter().map(SarifResult::from).collect();
124
125 SarifReport {
126 version: "2.1.0".to_string(),
127 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
128 runs: vec![SarifRun {
129 tool: SarifTool {
130 driver: SarifDriver {
131 name: "aptu-security-scanner".to_string(),
132 version: Some(env!("CARGO_PKG_VERSION").to_string()),
133 information_uri: Some("https://github.com/clouatre-labs/aptu".to_string()),
134 },
135 },
136 results,
137 }],
138 }
139 }
140}
141
142impl From<Finding> for SarifResult {
143 fn from(finding: Finding) -> Self {
144 let level = match finding.severity {
146 super::types::Severity::Critical | super::types::Severity::High => "error",
147 super::types::Severity::Medium => "warning",
148 super::types::Severity::Low => "note",
149 };
150
151 let fingerprint_input = format!(
153 "{}:{}:{}",
154 finding.file_path, finding.line_number, finding.pattern_id
155 );
156 let mut hasher = Sha256::new();
157 hasher.update(fingerprint_input.as_bytes());
158 let fingerprint = hex::encode(hasher.finalize());
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}