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>(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
141        // Collect unique rules for the rules array
142        let mut rules: Vec<SarifRule> = Vec::new();
143        let mut seen_rules = std::collections::HashSet::new();
144
145        for failure in &result.failures {
146            let code = failure.code.to_string();
147            if !seen_rules.contains(&code) {
148                seen_rules.insert(code.clone());
149                rules.push(SarifRule {
150                    id: code.clone(),
151                    name: code.clone(),
152                    short_description: SarifMessage {
153                        text: failure.message.clone(),
154                    },
155                    help_uri: get_rule_help_uri(&code),
156                    default_configuration: SarifRuleConfiguration {
157                        level: severity_to_sarif_level(failure.severity),
158                    },
159                });
160            }
161        }
162
163        // Build results
164        let results: Vec<SarifResult> = result
165            .failures
166            .iter()
167            .map(|f| SarifResult {
168                rule_id: f.code.to_string(),
169                level: severity_to_sarif_level(f.severity),
170                message: SarifMessage {
171                    text: f.message.clone(),
172                },
173                locations: vec![SarifLocation {
174                    physical_location: SarifPhysicalLocation {
175                        artifact_location: SarifArtifactLocation {
176                            uri: filename.to_string(),
177                        },
178                        region: SarifRegion {
179                            start_line: f.line,
180                            start_column: f.column,
181                        },
182                    },
183                }],
184            })
185            .collect();
186
187        let report = SarifReport {
188            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
189            version: "2.1.0",
190            runs: vec![SarifRun {
191                tool: SarifTool {
192                    driver: SarifDriver {
193                        name: "hadolint-rs",
194                        information_uri: "https://github.com/syncable-dev/syncable-cli",
195                        version: env!("CARGO_PKG_VERSION"),
196                        rules,
197                    },
198                },
199                results,
200            }],
201        };
202
203        let json = serde_json::to_string_pretty(&report)
204            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
205
206        writeln!(writer, "{}", json)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::analyzer::hadolint::types::CheckFailure;
214
215    #[test]
216    fn test_sarif_output() {
217        let mut result = LintResult::new();
218        result.failures.push(CheckFailure::new(
219            "DL3008",
220            Severity::Warning,
221            "Pin versions in apt get install",
222            5,
223        ));
224
225        let formatter = SarifFormatter::new();
226        let output = formatter.format_to_string(&result, "Dockerfile");
227
228        assert!(output.contains("\"$schema\""));
229        assert!(output.contains("\"version\": \"2.1.0\""));
230        assert!(output.contains("hadolint-rs"));
231        assert!(output.contains("DL3008"));
232        assert!(output.contains("warning"));
233    }
234}