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