Skip to main content

aptu_core/security/
sarif.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! SARIF (Static Analysis Results Interchange Format) output support.
4//!
5//! Converts security findings to SARIF 2.1.0 format for integration with
6//! GitHub Code Scanning and other security tools.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::types::Finding;
12
13/// SARIF report structure (SARIF 2.1.0).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SarifReport {
16    /// SARIF schema version.
17    pub version: String,
18    /// SARIF schema URI.
19    #[serde(rename = "$schema")]
20    pub schema: String,
21    /// List of runs (one per tool invocation).
22    pub runs: Vec<SarifRun>,
23}
24
25/// A single run of a tool.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SarifRun {
28    /// Tool information.
29    pub tool: SarifTool,
30    /// List of results (findings).
31    pub results: Vec<SarifResult>,
32}
33
34/// Tool information.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SarifTool {
37    /// Driver (the tool itself).
38    pub driver: SarifDriver,
39}
40
41/// Tool driver information.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SarifDriver {
44    /// Tool name.
45    pub name: String,
46    /// Tool version.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub version: Option<String>,
49    /// Information URI.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    #[serde(rename = "informationUri")]
52    pub information_uri: Option<String>,
53}
54
55/// A single result (finding).
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SarifResult {
58    /// Rule ID that triggered this result.
59    #[serde(rename = "ruleId")]
60    pub rule_id: String,
61    /// Result level (note, warning, error).
62    pub level: String,
63    /// Human-readable message.
64    pub message: SarifMessage,
65    /// Locations where the issue was found.
66    pub locations: Vec<SarifLocation>,
67    /// Stable fingerprint for deduplication.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub fingerprints: Option<SarifFingerprints>,
70}
71
72/// Message structure.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SarifMessage {
75    /// Message text.
76    pub text: String,
77}
78
79/// Location information.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SarifLocation {
82    /// Physical location in source code.
83    #[serde(rename = "physicalLocation")]
84    pub physical_location: SarifPhysicalLocation,
85}
86
87/// Physical location in source code.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct SarifPhysicalLocation {
90    /// Artifact (file) location.
91    #[serde(rename = "artifactLocation")]
92    pub artifact_location: SarifArtifactLocation,
93    /// Region (line/column) information.
94    pub region: SarifRegion,
95}
96
97/// Artifact location (file path).
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SarifArtifactLocation {
100    /// File URI or path.
101    pub uri: String,
102}
103
104/// Region (line/column) information.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct SarifRegion {
107    /// Start line (1-indexed).
108    #[serde(rename = "startLine")]
109    pub start_line: usize,
110}
111
112/// Fingerprints for deduplication.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SarifFingerprints {
115    /// Primary fingerprint (SHA-256 hash).
116    #[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        // Map severity to SARIF level
144        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        // Generate stable fingerprint: hash of (file_path + line_number + pattern_id)
151        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}