Skip to main content

diffguard_core/
sarif.rs

1//! SARIF (Static Analysis Results Interchange Format) output renderer.
2//!
3//! Converts CheckReceipt to SARIF 2.1.0 format for integration with
4//! code scanning tools and GitHub Advanced Security.
5
6use serde::Serialize;
7use std::collections::BTreeMap;
8
9use diffguard_types::{CheckReceipt, Finding, Severity};
10
11/// SARIF schema URL
12const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
13
14/// SARIF version
15const SARIF_VERSION: &str = "2.1.0";
16
17/// GitHub repository URL for diffguard
18const DIFFGUARD_INFO_URI: &str = "https://github.com/effortlessmetrics/diffguard";
19
20/// Root SARIF document structure.
21#[derive(Debug, Clone, Serialize)]
22pub struct SarifReport {
23    #[serde(rename = "$schema")]
24    pub schema: String,
25    pub version: String,
26    pub runs: Vec<SarifRun>,
27}
28
29/// A single SARIF run (analysis execution).
30#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SarifRun {
33    pub tool: SarifTool,
34    pub results: Vec<SarifResult>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub invocations: Option<Vec<SarifInvocation>>,
37}
38
39/// Tool information (driver).
40#[derive(Debug, Clone, Serialize)]
41pub struct SarifTool {
42    pub driver: SarifDriver,
43}
44
45/// Tool driver with rules.
46#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct SarifDriver {
49    pub name: String,
50    pub version: String,
51    pub information_uri: String,
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    pub rules: Vec<SarifRule>,
54}
55
56/// Rule definition.
57#[derive(Debug, Clone, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct SarifRule {
60    pub id: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub short_description: Option<SarifMessage>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub default_configuration: Option<SarifRuleConfiguration>,
65}
66
67/// Rule configuration (default severity level).
68#[derive(Debug, Clone, Serialize)]
69pub struct SarifRuleConfiguration {
70    pub level: SarifLevel,
71}
72
73/// SARIF result (finding).
74#[derive(Debug, Clone, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct SarifResult {
77    pub rule_id: String,
78    pub level: SarifLevel,
79    pub message: SarifMessage,
80    pub locations: Vec<SarifLocation>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub partial_fingerprints: Option<BTreeMap<String, String>>,
83}
84
85/// SARIF severity level.
86#[derive(Debug, Clone, Copy, Serialize)]
87#[serde(rename_all = "lowercase")]
88pub enum SarifLevel {
89    Error,
90    Warning,
91    Note,
92    None,
93}
94
95impl From<Severity> for SarifLevel {
96    fn from(s: Severity) -> Self {
97        match s {
98            Severity::Error => SarifLevel::Error,
99            Severity::Warn => SarifLevel::Warning,
100            Severity::Info => SarifLevel::Note,
101        }
102    }
103}
104
105/// Message with text.
106#[derive(Debug, Clone, Serialize)]
107pub struct SarifMessage {
108    pub text: String,
109}
110
111/// Location of a result.
112#[derive(Debug, Clone, Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct SarifLocation {
115    pub physical_location: SarifPhysicalLocation,
116}
117
118/// Physical location with file and region.
119#[derive(Debug, Clone, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct SarifPhysicalLocation {
122    pub artifact_location: SarifArtifactLocation,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub region: Option<SarifRegion>,
125}
126
127/// Artifact (file) location.
128#[derive(Debug, Clone, Serialize)]
129#[serde(rename_all = "camelCase")]
130pub struct SarifArtifactLocation {
131    pub uri: String,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub uri_base_id: Option<String>,
134}
135
136/// Region within a file.
137#[derive(Debug, Clone, Serialize)]
138#[serde(rename_all = "camelCase")]
139pub struct SarifRegion {
140    pub start_line: u32,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub start_column: Option<u32>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub snippet: Option<SarifSnippet>,
145}
146
147/// Code snippet.
148#[derive(Debug, Clone, Serialize)]
149pub struct SarifSnippet {
150    pub text: String,
151}
152
153/// Invocation information.
154#[derive(Debug, Clone, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct SarifInvocation {
157    pub execution_successful: bool,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub command_line: Option<String>,
160}
161
162/// Renders a CheckReceipt as a SARIF 2.1.0 report.
163pub fn render_sarif_for_receipt(receipt: &CheckReceipt) -> SarifReport {
164    // Collect unique rules from findings
165    let rules = collect_rules_from_findings(&receipt.findings);
166
167    // Convert findings to SARIF results
168    let results: Vec<SarifResult> = receipt
169        .findings
170        .iter()
171        .map(finding_to_sarif_result)
172        .collect();
173
174    SarifReport {
175        schema: SARIF_SCHEMA.to_string(),
176        version: SARIF_VERSION.to_string(),
177        runs: vec![SarifRun {
178            tool: SarifTool {
179                driver: SarifDriver {
180                    name: receipt.tool.name.clone(),
181                    version: receipt.tool.version.clone(),
182                    information_uri: DIFFGUARD_INFO_URI.to_string(),
183                    rules,
184                },
185            },
186            results,
187            invocations: None,
188        }],
189    }
190}
191
192/// Renders a SARIF report as a JSON string.
193pub fn render_sarif_json(receipt: &CheckReceipt) -> Result<String, serde_json::Error> {
194    let report = render_sarif_for_receipt(receipt);
195    serde_json::to_string_pretty(&report)
196}
197
198/// Collects unique rule definitions from findings.
199fn collect_rules_from_findings(findings: &[Finding]) -> Vec<SarifRule> {
200    let mut seen = BTreeMap::new();
201
202    for f in findings {
203        if !seen.contains_key(&f.rule_id) {
204            seen.insert(
205                f.rule_id.clone(),
206                SarifRule {
207                    id: f.rule_id.clone(),
208                    short_description: Some(SarifMessage {
209                        text: f.message.clone(),
210                    }),
211                    default_configuration: Some(SarifRuleConfiguration {
212                        level: f.severity.into(),
213                    }),
214                },
215            );
216        }
217    }
218
219    seen.into_values().collect()
220}
221
222/// Converts a diffguard Finding to a SARIF Result.
223fn finding_to_sarif_result(f: &Finding) -> SarifResult {
224    // Create a fingerprint based on rule, path, line
225    let mut fingerprints = BTreeMap::new();
226    fingerprints.insert(
227        "primaryLocationLineHash".to_string(),
228        format!("{}:{}:{}", f.rule_id, f.path, f.line),
229    );
230
231    SarifResult {
232        rule_id: f.rule_id.clone(),
233        level: f.severity.into(),
234        message: SarifMessage {
235            text: f.message.clone(),
236        },
237        locations: vec![SarifLocation {
238            physical_location: SarifPhysicalLocation {
239                artifact_location: SarifArtifactLocation {
240                    uri: f.path.clone(),
241                    uri_base_id: Some("%SRCROOT%".to_string()),
242                },
243                region: Some(SarifRegion {
244                    start_line: f.line,
245                    start_column: f.column,
246                    snippet: Some(SarifSnippet {
247                        text: f.snippet.clone(),
248                    }),
249                }),
250            },
251        }],
252        partial_fingerprints: Some(fingerprints),
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use diffguard_types::{
260        CHECK_SCHEMA_V1, CheckReceipt, DiffMeta, Finding, Scope, ToolMeta, Verdict, VerdictCounts,
261        VerdictStatus,
262    };
263
264    /// Helper to create a test receipt with multiple findings
265    fn create_test_receipt_with_findings() -> CheckReceipt {
266        CheckReceipt {
267            schema: CHECK_SCHEMA_V1.to_string(),
268            tool: ToolMeta {
269                name: "diffguard".to_string(),
270                version: "0.1.0".to_string(),
271            },
272            diff: DiffMeta {
273                base: "origin/main".to_string(),
274                head: "HEAD".to_string(),
275                context_lines: 0,
276                scope: Scope::Added,
277                files_scanned: 3,
278                lines_scanned: 42,
279            },
280            findings: vec![
281                Finding {
282                    rule_id: "rust.no_unwrap".to_string(),
283                    severity: Severity::Error,
284                    message: "Avoid unwrap/expect in production code.".to_string(),
285                    path: "src/lib.rs".to_string(),
286                    line: 15,
287                    column: Some(10),
288                    match_text: ".unwrap()".to_string(),
289                    snippet: "let value = result.unwrap();".to_string(),
290                },
291                Finding {
292                    rule_id: "rust.no_dbg".to_string(),
293                    severity: Severity::Warn,
294                    message: "Remove dbg!/println! before merging.".to_string(),
295                    path: "src/main.rs".to_string(),
296                    line: 23,
297                    column: Some(5),
298                    match_text: "dbg!".to_string(),
299                    snippet: "    dbg!(config);".to_string(),
300                },
301                Finding {
302                    rule_id: "python.no_print".to_string(),
303                    severity: Severity::Warn,
304                    message: "Remove print() before merging.".to_string(),
305                    path: "scripts/deploy.py".to_string(),
306                    line: 8,
307                    column: None,
308                    match_text: "print(".to_string(),
309                    snippet: "print(\"Deploying...\")".to_string(),
310                },
311            ],
312            verdict: Verdict {
313                status: VerdictStatus::Fail,
314                counts: VerdictCounts {
315                    info: 0,
316                    warn: 2,
317                    error: 1,
318                    ..Default::default()
319                },
320                reasons: vec![
321                    "1 error-level finding".to_string(),
322                    "2 warning-level findings".to_string(),
323                ],
324            },
325            timing: None,
326        }
327    }
328
329    /// Helper to create a test receipt with no findings
330    fn create_test_receipt_empty() -> CheckReceipt {
331        CheckReceipt {
332            schema: CHECK_SCHEMA_V1.to_string(),
333            tool: ToolMeta {
334                name: "diffguard".to_string(),
335                version: "0.1.0".to_string(),
336            },
337            diff: DiffMeta {
338                base: "origin/main".to_string(),
339                head: "HEAD".to_string(),
340                context_lines: 0,
341                scope: Scope::Added,
342                files_scanned: 5,
343                lines_scanned: 120,
344            },
345            findings: vec![],
346            verdict: Verdict {
347                status: VerdictStatus::Pass,
348                counts: VerdictCounts {
349                    info: 0,
350                    warn: 0,
351                    error: 0,
352                    ..Default::default()
353                },
354                reasons: vec![],
355            },
356            timing: None,
357        }
358    }
359
360    /// Helper to create a test receipt with info-level findings
361    fn create_test_receipt_info_findings() -> CheckReceipt {
362        CheckReceipt {
363            schema: CHECK_SCHEMA_V1.to_string(),
364            tool: ToolMeta {
365                name: "diffguard".to_string(),
366                version: "0.1.0".to_string(),
367            },
368            diff: DiffMeta {
369                base: "origin/main".to_string(),
370                head: "HEAD".to_string(),
371                context_lines: 0,
372                scope: Scope::Added,
373                files_scanned: 1,
374                lines_scanned: 10,
375            },
376            findings: vec![Finding {
377                rule_id: "info.todo".to_string(),
378                severity: Severity::Info,
379                message: "Found a TODO comment.".to_string(),
380                path: "src/lib.rs".to_string(),
381                line: 5,
382                column: None,
383                match_text: "TODO".to_string(),
384                snippet: "// TODO: refactor this".to_string(),
385            }],
386            verdict: Verdict {
387                status: VerdictStatus::Pass,
388                counts: VerdictCounts {
389                    info: 1,
390                    warn: 0,
391                    error: 0,
392                    ..Default::default()
393                },
394                reasons: vec![],
395            },
396            timing: None,
397        }
398    }
399
400    #[test]
401    fn sarif_has_correct_schema_and_version() {
402        let receipt = create_test_receipt_empty();
403        let sarif = render_sarif_for_receipt(&receipt);
404
405        assert_eq!(sarif.schema, SARIF_SCHEMA);
406        assert_eq!(sarif.version, SARIF_VERSION);
407    }
408
409    #[test]
410    fn sarif_tool_info_is_correct() {
411        let receipt = create_test_receipt_with_findings();
412        let sarif = render_sarif_for_receipt(&receipt);
413
414        assert_eq!(sarif.runs.len(), 1);
415        let driver = &sarif.runs[0].tool.driver;
416        assert_eq!(driver.name, "diffguard");
417        assert_eq!(driver.version, "0.1.0");
418        assert_eq!(driver.information_uri, DIFFGUARD_INFO_URI);
419    }
420
421    #[test]
422    fn sarif_contains_all_findings() {
423        let receipt = create_test_receipt_with_findings();
424        let sarif = render_sarif_for_receipt(&receipt);
425
426        assert_eq!(sarif.runs[0].results.len(), 3);
427    }
428
429    #[test]
430    fn sarif_severity_mapping_error() {
431        let receipt = create_test_receipt_with_findings();
432        let sarif = render_sarif_for_receipt(&receipt);
433
434        let error_result = &sarif.runs[0].results[0];
435        assert!(matches!(error_result.level, SarifLevel::Error));
436    }
437
438    #[test]
439    fn sarif_severity_mapping_warning() {
440        let receipt = create_test_receipt_with_findings();
441        let sarif = render_sarif_for_receipt(&receipt);
442
443        let warn_result = &sarif.runs[0].results[1];
444        assert!(matches!(warn_result.level, SarifLevel::Warning));
445    }
446
447    #[test]
448    fn sarif_severity_mapping_note() {
449        let receipt = create_test_receipt_info_findings();
450        let sarif = render_sarif_for_receipt(&receipt);
451
452        let info_result = &sarif.runs[0].results[0];
453        assert!(matches!(info_result.level, SarifLevel::Note));
454    }
455
456    #[test]
457    fn sarif_location_includes_line_and_column() {
458        let receipt = create_test_receipt_with_findings();
459        let sarif = render_sarif_for_receipt(&receipt);
460
461        let result = &sarif.runs[0].results[0];
462        let location = &result.locations[0];
463        let region = location.physical_location.region.as_ref().unwrap();
464
465        assert_eq!(region.start_line, 15);
466        assert_eq!(region.start_column, Some(10));
467    }
468
469    #[test]
470    fn sarif_location_without_column() {
471        let receipt = create_test_receipt_with_findings();
472        let sarif = render_sarif_for_receipt(&receipt);
473
474        // Third finding has no column
475        let result = &sarif.runs[0].results[2];
476        let location = &result.locations[0];
477        let region = location.physical_location.region.as_ref().unwrap();
478
479        assert_eq!(region.start_line, 8);
480        assert_eq!(region.start_column, None);
481    }
482
483    #[test]
484    fn sarif_empty_receipt_has_no_results() {
485        let receipt = create_test_receipt_empty();
486        let sarif = render_sarif_for_receipt(&receipt);
487
488        assert!(sarif.runs[0].results.is_empty());
489        assert!(sarif.runs[0].tool.driver.rules.is_empty());
490    }
491
492    #[test]
493    fn sarif_rules_are_deduplicated() {
494        // Create receipt with duplicate rule IDs
495        let mut receipt = create_test_receipt_with_findings();
496        receipt.findings.push(Finding {
497            rule_id: "rust.no_unwrap".to_string(), // Same as first finding
498            severity: Severity::Error,
499            message: "Avoid unwrap/expect in production code.".to_string(),
500            path: "src/other.rs".to_string(),
501            line: 100,
502            column: None,
503            match_text: ".unwrap()".to_string(),
504            snippet: "x.unwrap()".to_string(),
505        });
506
507        let sarif = render_sarif_for_receipt(&receipt);
508
509        // Should have 3 unique rules, not 4
510        assert_eq!(sarif.runs[0].tool.driver.rules.len(), 3);
511    }
512
513    #[test]
514    fn sarif_json_is_valid() {
515        let receipt = create_test_receipt_with_findings();
516        let json = render_sarif_json(&receipt).expect("should serialize");
517
518        // Should parse back successfully
519        let _: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON");
520    }
521
522    /// Snapshot test for SARIF output with findings.
523    #[test]
524    fn snapshot_sarif_with_findings() {
525        let receipt = create_test_receipt_with_findings();
526        let json = render_sarif_json(&receipt).expect("should serialize");
527        insta::assert_snapshot!(json);
528    }
529
530    /// Snapshot test for SARIF output with no findings.
531    #[test]
532    fn snapshot_sarif_no_findings() {
533        let receipt = create_test_receipt_empty();
534        let json = render_sarif_json(&receipt).expect("should serialize");
535        insta::assert_snapshot!(json);
536    }
537
538    /// Snapshot test for SARIF output with info-level findings.
539    #[test]
540    fn snapshot_sarif_info_findings() {
541        let receipt = create_test_receipt_info_findings();
542        let json = render_sarif_json(&receipt).expect("should serialize");
543        insta::assert_snapshot!(json);
544    }
545}