syncable_cli/analyzer/hadolint/formatter/
sarif.rs

1//! SARIF formatter for hadolint-rs.
2//!
3//! Outputs lint results in SARIF (Static Analysis Results Interchange Format)
4//! for GitHub Actions Code Scanning integration.
5//!
6//! SARIF Specification: https://sarifweb.azurewebsites.net/
7
8use crate::analyzer::hadolint::formatter::Formatter;
9use crate::analyzer::hadolint::lint::LintResult;
10use crate::analyzer::hadolint::types::Severity;
11use serde::Serialize;
12use std::io::Write;
13
14/// SARIF output formatter for GitHub Actions.
15#[derive(Debug, Clone, Default)]
16pub struct SarifFormatter;
17
18impl SarifFormatter {
19    /// Create a new SARIF formatter.
20    pub fn new() -> Self {
21        Self
22    }
23}
24
25/// SARIF 2.1.0 schema structures.
26#[derive(Debug, Serialize)]
27#[serde(rename_all = "camelCase")]
28struct SarifReport {
29    #[serde(rename = "$schema")]
30    schema: &'static str,
31    version: &'static str,
32    runs: Vec<SarifRun>,
33}
34
35#[derive(Debug, Serialize)]
36#[serde(rename_all = "camelCase")]
37struct SarifRun {
38    tool: SarifTool,
39    results: Vec<SarifResult>,
40}
41
42#[derive(Debug, Serialize)]
43#[serde(rename_all = "camelCase")]
44struct SarifTool {
45    driver: SarifDriver,
46}
47
48#[derive(Debug, Serialize)]
49#[serde(rename_all = "camelCase")]
50struct SarifDriver {
51    name: &'static str,
52    information_uri: &'static str,
53    version: &'static str,
54    rules: Vec<SarifRule>,
55}
56
57#[derive(Debug, Serialize)]
58#[serde(rename_all = "camelCase")]
59struct SarifRule {
60    id: String,
61    name: String,
62    short_description: SarifMessage,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    help_uri: Option<String>,
65    default_configuration: SarifRuleConfiguration,
66}
67
68#[derive(Debug, Serialize)]
69#[serde(rename_all = "camelCase")]
70struct SarifRuleConfiguration {
71    level: &'static str,
72}
73
74#[derive(Debug, Serialize)]
75#[serde(rename_all = "camelCase")]
76struct SarifMessage {
77    text: String,
78}
79
80#[derive(Debug, Serialize)]
81#[serde(rename_all = "camelCase")]
82struct SarifResult {
83    rule_id: String,
84    level: &'static str,
85    message: SarifMessage,
86    locations: Vec<SarifLocation>,
87}
88
89#[derive(Debug, Serialize)]
90#[serde(rename_all = "camelCase")]
91struct SarifLocation {
92    physical_location: SarifPhysicalLocation,
93}
94
95#[derive(Debug, Serialize)]
96#[serde(rename_all = "camelCase")]
97struct SarifPhysicalLocation {
98    artifact_location: SarifArtifactLocation,
99    region: SarifRegion,
100}
101
102#[derive(Debug, Serialize)]
103#[serde(rename_all = "camelCase")]
104struct SarifArtifactLocation {
105    uri: String,
106}
107
108#[derive(Debug, Serialize)]
109#[serde(rename_all = "camelCase")]
110struct SarifRegion {
111    start_line: u32,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    start_column: Option<u32>,
114}
115
116fn severity_to_sarif_level(severity: Severity) -> &'static str {
117    match severity {
118        Severity::Error => "error",
119        Severity::Warning => "warning",
120        Severity::Info => "note",
121        Severity::Style => "note",
122        Severity::Ignore => "none",
123    }
124}
125
126fn get_rule_help_uri(code: &str) -> Option<String> {
127    if code.starts_with("DL") {
128        Some(format!(
129            "https://github.com/hadolint/hadolint/wiki/{}",
130            code
131        ))
132    } else if code.starts_with("SC") {
133        Some(format!("https://www.shellcheck.net/wiki/{}", code))
134    } else {
135        None
136    }
137}
138
139impl Formatter for SarifFormatter {
140    fn format<W: Write>(
141        &self,
142        result: &LintResult,
143        filename: &str,
144        writer: &mut W,
145    ) -> std::io::Result<()> {
146        // Collect unique rules for the rules array
147        let mut rules: Vec<SarifRule> = Vec::new();
148        let mut seen_rules = std::collections::HashSet::new();
149
150        for failure in &result.failures {
151            let code = failure.code.to_string();
152            if !seen_rules.contains(&code) {
153                seen_rules.insert(code.clone());
154                rules.push(SarifRule {
155                    id: code.clone(),
156                    name: code.clone(),
157                    short_description: SarifMessage {
158                        text: failure.message.clone(),
159                    },
160                    help_uri: get_rule_help_uri(&code),
161                    default_configuration: SarifRuleConfiguration {
162                        level: severity_to_sarif_level(failure.severity),
163                    },
164                });
165            }
166        }
167
168        // Build results
169        let results: Vec<SarifResult> = result
170            .failures
171            .iter()
172            .map(|f| SarifResult {
173                rule_id: f.code.to_string(),
174                level: severity_to_sarif_level(f.severity),
175                message: SarifMessage {
176                    text: f.message.clone(),
177                },
178                locations: vec![SarifLocation {
179                    physical_location: SarifPhysicalLocation {
180                        artifact_location: SarifArtifactLocation {
181                            uri: filename.to_string(),
182                        },
183                        region: SarifRegion {
184                            start_line: f.line,
185                            start_column: f.column,
186                        },
187                    },
188                }],
189            })
190            .collect();
191
192        let report = SarifReport {
193            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
194            version: "2.1.0",
195            runs: vec![SarifRun {
196                tool: SarifTool {
197                    driver: SarifDriver {
198                        name: "hadolint-rs",
199                        information_uri: "https://github.com/syncable-dev/syncable-cli",
200                        version: env!("CARGO_PKG_VERSION"),
201                        rules,
202                    },
203                },
204                results,
205            }],
206        };
207
208        let json = serde_json::to_string_pretty(&report)
209            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
210
211        writeln!(writer, "{}", json)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::analyzer::hadolint::types::CheckFailure;
219
220    #[test]
221    fn test_sarif_output() {
222        let mut result = LintResult::new();
223        result.failures.push(CheckFailure::new(
224            "DL3008",
225            Severity::Warning,
226            "Pin versions in apt get install",
227            5,
228        ));
229
230        let formatter = SarifFormatter::new();
231        let output = formatter.format_to_string(&result, "Dockerfile");
232
233        assert!(output.contains("\"$schema\""));
234        assert!(output.contains("\"version\": \"2.1.0\""));
235        assert!(output.contains("hadolint-rs"));
236        assert!(output.contains("DL3008"));
237        assert!(output.contains("warning"));
238    }
239}