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, PatternDefinition};
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    /// Rule definitions (pattern metadata).
55    #[serde(skip_serializing_if = "Vec::is_empty", default)]
56    pub rules: Vec<SarifRule>,
57}
58
59/// A single result (finding).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SarifResult {
62    /// Rule ID that triggered this result.
63    #[serde(rename = "ruleId")]
64    pub rule_id: String,
65    /// Result level (note, warning, error).
66    pub level: String,
67    /// Human-readable message.
68    pub message: SarifMessage,
69    /// Locations where the issue was found.
70    pub locations: Vec<SarifLocation>,
71    /// Stable fingerprint for deduplication.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub fingerprints: Option<SarifFingerprints>,
74}
75
76/// Message structure.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SarifMessage {
79    /// Message text.
80    pub text: String,
81}
82
83/// Help text for a rule, supporting plain text and markdown.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct SarifHelp {
87    /// Plain-text remediation guidance.
88    pub text: String,
89    /// Markdown-formatted remediation guidance.
90    pub markdown: String,
91}
92
93/// A rule definition (pattern metadata) embedded in the SARIF driver.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct SarifRule {
97    /// Rule identifier (matches ruleId in results).
98    pub id: String,
99    /// Short, one-line description.
100    pub short_description: SarifMessage,
101    /// Longer description with more detail.
102    pub full_description: SarifMessage,
103    /// Remediation help text.
104    pub help: SarifHelp,
105    /// Authoritative reference URL (CWE, OWASP, etc.).
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub help_uri: Option<String>,
108}
109
110/// Location information.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SarifLocation {
113    /// Physical location in source code.
114    #[serde(rename = "physicalLocation")]
115    pub physical_location: SarifPhysicalLocation,
116}
117
118/// Physical location in source code.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SarifPhysicalLocation {
121    /// Artifact (file) location.
122    #[serde(rename = "artifactLocation")]
123    pub artifact_location: SarifArtifactLocation,
124    /// Region (line/column) information.
125    pub region: SarifRegion,
126}
127
128/// Artifact location (file path).
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct SarifArtifactLocation {
131    /// File URI or path.
132    pub uri: String,
133}
134
135/// Region (line/column) information.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SarifRegion {
138    /// Start line (1-indexed).
139    #[serde(rename = "startLine")]
140    pub start_line: usize,
141}
142
143/// Fingerprints for deduplication.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SarifFingerprints {
146    /// Primary fingerprint (SHA-256 hash).
147    #[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    /// Build a SARIF report with rule metadata embedded in the driver.
175    ///
176    /// Rule objects are built from `patterns`; result objects are built from `findings`.
177    /// Use this constructor when you have pattern metadata available (e.g. from the CLI
178    /// `scan-security` subcommand). Prefer `From<Vec<Finding>>` for lightweight usage.
179    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        // Map severity to SARIF level
224        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        // Generate stable fingerprint: hash of (file_path + line_number + pattern_id)
231        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}