Skip to main content

dockerfile_roast/
output.rs

1use crate::rules::{Finding, Severity};
2use colored::*;
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum OutputFormat {
9    Terminal,
10    Json,
11    Github,
12    Compact,
13    Sarif,
14}
15
16impl std::str::FromStr for OutputFormat {
17    type Err = String;
18    fn from_str(s: &str) -> Result<Self, Self::Err> {
19        match s.to_lowercase().as_str() {
20            "terminal" | "tty" => Ok(OutputFormat::Terminal),
21            "json" => Ok(OutputFormat::Json),
22            "github" | "gh" => Ok(OutputFormat::Github),
23            "compact" => Ok(OutputFormat::Compact),
24            "sarif" => Ok(OutputFormat::Sarif),
25            other => Err(format!("unknown format '{}'", other)),
26        }
27    }
28}
29
30#[derive(Serialize)]
31struct JsonFinding {
32    rule: String,
33    fingerprint: String,
34    severity: String,
35    line: usize,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    column: Option<usize>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    end_line: Option<usize>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    end_column: Option<usize>,
42    message: String,
43    roast: String,
44}
45
46#[derive(Serialize)]
47struct JsonOutput {
48    file: String,
49    total: usize,
50    errors: usize,
51    warnings: usize,
52    infos: usize,
53    findings: Vec<JsonFinding>,
54}
55
56pub fn print_findings(file: &str, findings: &[Finding], format: OutputFormat, no_roast: bool) {
57    match format {
58        OutputFormat::Terminal => print_terminal(file, findings, no_roast),
59        OutputFormat::Json => print_json(file, findings),
60        OutputFormat::Github => print_github(file, findings),
61        OutputFormat::Compact => print_compact(file, findings),
62        OutputFormat::Sarif => {
63            unreachable!("SARIF output is handled via print_sarif, not print_findings")
64        }
65    }
66}
67
68pub fn print_no_new_findings(file: &str) {
69    println!(
70        "\n  {} {}\n",
71        "✓".green().bold(),
72        format!("{} has no new findings since the baseline.", file).green()
73    );
74}
75
76fn severity_color(s: &Severity) -> ColoredString {
77    match s {
78        Severity::Error => "ERROR".red().bold(),
79        Severity::Warning => "WARN ".yellow().bold(),
80        Severity::Info => "INFO ".cyan(),
81    }
82}
83
84fn print_terminal(file: &str, findings: &[Finding], no_roast: bool) {
85    if findings.is_empty() {
86        println!(
87            "\n  {} {}\n",
88            "✓".green().bold(),
89            format!("{} passed with no issues. Impressive restraint.", file).green()
90        );
91        return;
92    }
93
94    println!(
95        "\n  {} {}\n",
96        "🔥".bold(),
97        format!("Roasting {}...", file).bold()
98    );
99
100    for f in findings {
101        let line_info = if f.line > 0 {
102            let location = if f.column > 0 {
103                format!("{}:{}:{}", file, f.line, f.column)
104            } else {
105                format!("{}:{}", file, f.line)
106            };
107            location.dimmed().to_string()
108        } else {
109            file.dimmed().to_string()
110        };
111
112        println!(
113            "  {} [{}]  {}",
114            severity_color(&f.severity),
115            f.rule.dimmed(),
116            f.message.bold()
117        );
118        println!("  {}      at {}", " ".repeat(5), line_info);
119        if !no_roast {
120            println!(
121                "  {}      {} {}\n",
122                " ".repeat(5),
123                "💬".dimmed(),
124                format!("\"{}\"", f.roast).italic().dimmed()
125            );
126        } else {
127            println!();
128        }
129    }
130
131    let errors = findings
132        .iter()
133        .filter(|f| f.severity == Severity::Error)
134        .count();
135    let warnings = findings
136        .iter()
137        .filter(|f| f.severity == Severity::Warning)
138        .count();
139    let infos = findings
140        .iter()
141        .filter(|f| f.severity == Severity::Info)
142        .count();
143
144    println!(
145        "  {} {} error(s), {} warning(s), {} info(s)",
146        "Summary:".bold(),
147        errors.to_string().red().bold(),
148        warnings.to_string().yellow().bold(),
149        infos.to_string().cyan()
150    );
151
152    if errors > 0 {
153        println!(
154            "\n  {} This Dockerfile is a liability. Fix the errors.",
155            "💀".bold()
156        );
157    } else if warnings > 0 {
158        println!(
159            "\n  {} Could be worse. Could also be much better.",
160            "🤔".bold()
161        );
162    } else {
163        println!(
164            "\n  {} Only informational findings. You're almost competent.",
165            "📝".bold()
166        );
167    }
168    println!();
169}
170
171fn print_json(file: &str, findings: &[Finding]) {
172    println!(
173        "{}",
174        serde_json::to_string_pretty(&json_output(file, findings)).unwrap()
175    );
176}
177
178fn json_output(file: &str, findings: &[Finding]) -> JsonOutput {
179    let errors = findings
180        .iter()
181        .filter(|f| f.severity == Severity::Error)
182        .count();
183    let warnings = findings
184        .iter()
185        .filter(|f| f.severity == Severity::Warning)
186        .count();
187    let infos = findings
188        .iter()
189        .filter(|f| f.severity == Severity::Info)
190        .count();
191    JsonOutput {
192        file: file.to_string(),
193        total: findings.len(),
194        errors,
195        warnings,
196        infos,
197        findings: findings
198            .iter()
199            .map(|f| JsonFinding {
200                rule: f.rule.to_string(),
201                fingerprint: finding_fingerprint(file, f),
202                severity: f.severity.to_string(),
203                line: f.line,
204                column: (f.column > 0).then_some(f.column),
205                end_line: (f.end_line > 0).then_some(f.end_line),
206                end_column: (f.end_column > 0).then_some(f.end_column),
207                message: f.message.clone(),
208                roast: f.roast.clone(),
209            })
210            .collect(),
211    }
212}
213
214/// Stable baseline identity. It intentionally excludes the physical line so a
215/// finding survives unrelated insertions above it, while retaining the file,
216/// rule, and normalized diagnostic message.
217pub fn finding_fingerprint(file: &str, finding: &Finding) -> String {
218    let normalized_message = finding.message.split_whitespace().collect::<Vec<_>>().join(" ");
219    let material = format!("droast-fingerprint-v1\0{}\0{}\0{}", normalize_uri(file), finding.rule, normalized_message);
220    format!("sha256:{:x}", Sha256::digest(material.as_bytes()))
221}
222
223/// Emit one valid JSON document for a repository scan while preserving the
224/// historical object shape for a single Dockerfile.
225pub fn print_json_results(results: &[(&str, &[Finding])]) {
226    if let [(file, findings)] = results {
227        print_json(file, findings);
228        return;
229    }
230    let output = results
231        .iter()
232        .map(|(file, findings)| json_output(file, findings))
233        .collect::<Vec<_>>();
234    println!("{}", serde_json::to_string_pretty(&output).unwrap());
235}
236
237fn print_github(file: &str, findings: &[Finding]) {
238    for f in findings {
239        let level = match f.severity {
240            Severity::Error => "error",
241            Severity::Warning => "warning",
242            Severity::Info => "notice",
243        };
244        let mut location = if f.line > 0 {
245            format!(",line={}", f.line)
246        } else {
247            String::new()
248        };
249        if f.column > 0 {
250            location.push_str(&format!(",col={}", f.column));
251        }
252        if f.end_line > 0 {
253            location.push_str(&format!(",endLine={}", f.end_line));
254        }
255        if f.end_column > 0 {
256            location.push_str(&format!(",endColumn={}", f.end_column));
257        }
258        println!(
259            "::{} file={}{},title=[{}] {}::{}",
260            level, file, location, f.rule, f.message, f.roast
261        );
262    }
263}
264
265fn print_compact(file: &str, findings: &[Finding]) {
266    for f in findings {
267        let line_info = if f.line > 0 {
268            if f.column > 0 {
269                format!(":{}:{}", f.line, f.column)
270            } else {
271                format!(":{}", f.line)
272            }
273        } else {
274            String::new()
275        };
276        println!(
277            "{}{}:{} [{}] {}",
278            file, line_info, f.severity, f.rule, f.message
279        );
280    }
281}
282
283/// Emit a SARIF 2.1.0 document covering all linted files at once.
284///
285/// SARIF is a document format — all files and findings must be collected
286/// before emission. Call this once after linting every file, not per-file.
287///
288/// Compatible with GitHub Advanced Security (`upload-sarif`), VS Code SARIF
289/// Viewer, and any tool that consumes the OASIS SARIF 2.1.0 schema.
290pub fn print_sarif(results: &[(&str, &[Finding])]) {
291    println!("{}", build_sarif(results));
292}
293
294fn build_sarif(results: &[(&str, &[Finding])]) -> String {
295    let all_rule_meta = crate::rules::all_rules();
296    let rule_desc: HashMap<&str, &str> = all_rule_meta
297        .iter()
298        .map(|r| (r.id, r.description))
299        .collect();
300    let rule_categories: HashMap<&str, &[&str]> = all_rule_meta
301        .iter()
302        .map(|rule| (rule.id, rule.categories()))
303        .collect();
304
305    // Collect the ordered, deduplicated set of rule IDs that actually fired.
306    // Sorted for deterministic output and so ruleIndex values are stable.
307    let mut seen_ids = std::collections::BTreeSet::new();
308    for (_, findings) in results {
309        for f in *findings {
310            seen_ids.insert(f.rule.clone());
311        }
312    }
313    let rule_ids: Vec<String> = seen_ids.into_iter().collect();
314
315    // ruleId → index in the rules array (ruleIndex in results must match).
316    let rule_index: HashMap<String, usize> = rule_ids
317        .iter()
318        .enumerate()
319        .map(|(i, id)| (id.clone(), i))
320        .collect();
321
322    // Highest severity seen per rule — used for defaultConfiguration.level.
323    let mut rule_max_sev: HashMap<String, Severity> = HashMap::new();
324    for (_, findings) in results {
325        for f in *findings {
326            let entry = rule_max_sev.entry(f.rule.clone()).or_insert(Severity::Info);
327            if f.severity > *entry {
328                *entry = f.severity;
329            }
330        }
331    }
332
333    // Build tool.driver.rules
334    let sarif_rules: Vec<serde_json::Value> = rule_ids
335        .iter()
336        .map(|id| {
337            let desc = rule_desc.get(id.as_str()).copied().unwrap_or(id.as_str());
338            let level = sarif_level(rule_max_sev.get(id).unwrap_or(&Severity::Info));
339            let categories = rule_categories
340                .get(id.as_str())
341                .copied()
342                .unwrap_or_default();
343            serde_json::json!({
344                "id": id,
345                "name": id,
346                "shortDescription": { "text": desc },
347                "helpUri": "https://github.com/immanuwell/dockerfile-roast",
348                "defaultConfiguration": { "level": level },
349                "properties": { "tags": categories }
350            })
351        })
352        .collect();
353
354    // Build results array
355    let mut sarif_results: Vec<serde_json::Value> = Vec::new();
356    for (file, findings) in results {
357        let uri = normalize_uri(file);
358        for f in *findings {
359            let idx = *rule_index.get(&f.rule).unwrap_or(&0);
360            let mut result = serde_json::json!({
361                "ruleId": f.rule,
362                "ruleIndex": idx,
363                "level": sarif_level(&f.severity),
364                "message": { "text": f.message },
365                "partialFingerprints": { "droast/v1": finding_fingerprint(file, f) },
366                "locations": [{
367                    "physicalLocation": {
368                        "artifactLocation": {
369                            "uri": uri,
370                            "uriBaseId": "%SRCROOT%"
371                        }
372                    }
373                }]
374            });
375            // region is optional in SARIF; only add when we have a real line number.
376            if f.line > 0 {
377                result["locations"][0]["physicalLocation"]["region"] =
378                    serde_json::json!({ "startLine": f.line });
379                if f.column > 0 {
380                    result["locations"][0]["physicalLocation"]["region"]["startColumn"] =
381                        serde_json::json!(f.column);
382                }
383                if f.end_line > 0 {
384                    result["locations"][0]["physicalLocation"]["region"]["endLine"] =
385                        serde_json::json!(f.end_line);
386                }
387                if f.end_column > 0 {
388                    result["locations"][0]["physicalLocation"]["region"]["endColumn"] =
389                        serde_json::json!(f.end_column);
390                }
391            }
392            sarif_results.push(result);
393        }
394    }
395
396    // Artifacts — the list of scanned files (optional but useful for tooling).
397    let artifacts: Vec<serde_json::Value> = results
398        .iter()
399        .map(|(file, _)| {
400            serde_json::json!({
401                "location": {
402                    "uri": normalize_uri(file),
403                    "uriBaseId": "%SRCROOT%"
404                }
405            })
406        })
407        .collect();
408
409    let doc = serde_json::json!({
410        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
411        "version": "2.1.0",
412        "runs": [{
413            "tool": {
414                "driver": {
415                    "name": "droast",
416                    "version": env!("CARGO_PKG_VERSION"),
417                    "informationUri": "https://github.com/immanuwell/dockerfile-roast",
418                    "rules": sarif_rules
419                }
420            },
421            "results": sarif_results,
422            "artifacts": artifacts
423        }]
424    });
425
426    serde_json::to_string_pretty(&doc).unwrap()
427}
428
429/// Map droast severity to the SARIF level string.
430/// SARIF uses "note" for informational findings, not "info".
431fn sarif_level(sev: &Severity) -> &'static str {
432    match sev {
433        Severity::Error => "error",
434        Severity::Warning => "warning",
435        Severity::Info => "note",
436    }
437}
438
439/// Convert a file path to a forward-slash URI relative to the repo root.
440/// Absolute paths are made relative by stripping the current working directory.
441fn normalize_uri(path: &str) -> String {
442    let p = std::path::Path::new(path);
443    let relative = if p.is_absolute() {
444        std::env::current_dir()
445            .ok()
446            .and_then(|cwd| p.strip_prefix(&cwd).ok().map(|r| r.to_path_buf()))
447            .unwrap_or_else(|| p.to_path_buf())
448    } else {
449        p.to_path_buf()
450    };
451    relative.to_string_lossy().replace('\\', "/")
452}
453
454pub fn print_summary_header() {
455    println!(
456        "\n{}",
457        r#"
458  ██████╗ ██████╗  ██████╗  █████╗ ███████╗████████╗
459  ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝
460  ██║  ██║██████╔╝██║   ██║███████║███████╗   ██║
461  ██║  ██║██╔══██╗██║   ██║██╔══██║╚════██║   ██║
462  ██████╔╝██║  ██║╚██████╔╝██║  ██║███████║   ██║
463  ╚═════╝ ╚═╝  ╚═╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝   ╚═╝
464  Dockerfile linter with personality
465"#
466        .bold()
467        .red()
468    );
469}
470
471#[cfg(test)]
472mod tests {
473    use super::{build_sarif, finding_fingerprint, json_output};
474    use crate::rules::{Finding, Severity};
475
476    fn shellcheck_finding() -> Finding {
477        Finding {
478            rule: "SC2086".into(),
479            severity: Severity::Warning,
480            line: 4,
481            column: 9,
482            end_line: 4,
483            end_column: 14,
484            message: "Double quote to prevent globbing".into(),
485            roast: "ShellCheck found a shell-script problem inside this RUN instruction.".into(),
486        }
487    }
488
489    #[test]
490    fn json_and_sarif_preserve_shellcheck_ids_and_ranges() {
491        let finding = shellcheck_finding();
492        let json = serde_json::to_value(json_output("Dockerfile", &[finding.clone()])).unwrap();
493        assert_eq!(json["findings"][0]["rule"], "SC2086");
494        assert_eq!(json["findings"][0]["column"], 9);
495
496        let sarif: serde_json::Value =
497            serde_json::from_str(&build_sarif(&[("Dockerfile", &[finding.clone()])])).unwrap();
498        assert_eq!(sarif["runs"][0]["results"][0]["ruleId"], "SC2086");
499        assert_eq!(
500            sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]
501                ["startColumn"],
502            9
503        );
504        assert_eq!(
505            finding_fingerprint("Dockerfile", &finding),
506            finding_fingerprint("Dockerfile", &Finding { line: 99, ..finding })
507        );
508    }
509}