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, no_roast),
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], no_roast: bool) {
238    for f in findings {
239        println!("{}", github_annotation(file, f, no_roast));
240    }
241}
242
243fn github_annotation(file: &str, finding: &Finding, no_roast: bool) -> String {
244    let level = match finding.severity {
245        Severity::Error => "error",
246        Severity::Warning => "warning",
247        Severity::Info => "notice",
248    };
249    let mut location = if finding.line > 0 {
250        format!(",line={}", finding.line)
251    } else {
252        String::new()
253    };
254    if finding.column > 0 {
255        location.push_str(&format!(",col={}", finding.column));
256    }
257    if finding.end_line > 0 {
258        location.push_str(&format!(",endLine={}", finding.end_line));
259    }
260    if finding.end_column > 0 {
261        location.push_str(&format!(",endColumn={}", finding.end_column));
262    }
263    format!(
264        "::{} file={}{},title=[{}] {}::{}",
265        level,
266        file,
267        location,
268        finding.rule,
269        finding.message,
270        if no_roast {
271            &finding.message
272        } else {
273            &finding.roast
274        }
275    )
276}
277
278fn print_compact(file: &str, findings: &[Finding]) {
279    for f in findings {
280        let line_info = if f.line > 0 {
281            if f.column > 0 {
282                format!(":{}:{}", f.line, f.column)
283            } else {
284                format!(":{}", f.line)
285            }
286        } else {
287            String::new()
288        };
289        println!(
290            "{}{}:{} [{}] {}",
291            file, line_info, f.severity, f.rule, f.message
292        );
293    }
294}
295
296/// Emit a SARIF 2.1.0 document covering all linted files at once.
297///
298/// SARIF is a document format — all files and findings must be collected
299/// before emission. Call this once after linting every file, not per-file.
300///
301/// Compatible with GitHub Advanced Security (`upload-sarif`), VS Code SARIF
302/// Viewer, and any tool that consumes the OASIS SARIF 2.1.0 schema.
303pub fn print_sarif(results: &[(&str, &[Finding])]) {
304    println!("{}", build_sarif(results));
305}
306
307fn build_sarif(results: &[(&str, &[Finding])]) -> String {
308    let all_rule_meta = crate::rules::all_rules();
309    let rule_desc: HashMap<&str, &str> = all_rule_meta
310        .iter()
311        .map(|r| (r.id, r.description))
312        .collect();
313    let rule_categories: HashMap<&str, &[&str]> = all_rule_meta
314        .iter()
315        .map(|rule| (rule.id, rule.categories()))
316        .collect();
317
318    // Collect the ordered, deduplicated set of rule IDs that actually fired.
319    // Sorted for deterministic output and so ruleIndex values are stable.
320    let mut seen_ids = std::collections::BTreeSet::new();
321    for (_, findings) in results {
322        for f in *findings {
323            seen_ids.insert(f.rule.clone());
324        }
325    }
326    let rule_ids: Vec<String> = seen_ids.into_iter().collect();
327
328    // ruleId → index in the rules array (ruleIndex in results must match).
329    let rule_index: HashMap<String, usize> = rule_ids
330        .iter()
331        .enumerate()
332        .map(|(i, id)| (id.clone(), i))
333        .collect();
334
335    // Highest severity seen per rule — used for defaultConfiguration.level.
336    let mut rule_max_sev: HashMap<String, Severity> = HashMap::new();
337    for (_, findings) in results {
338        for f in *findings {
339            let entry = rule_max_sev.entry(f.rule.clone()).or_insert(Severity::Info);
340            if f.severity > *entry {
341                *entry = f.severity;
342            }
343        }
344    }
345
346    // Build tool.driver.rules
347    let sarif_rules: Vec<serde_json::Value> = rule_ids
348        .iter()
349        .map(|id| {
350            let desc = rule_desc.get(id.as_str()).copied().unwrap_or(id.as_str());
351            let level = sarif_level(rule_max_sev.get(id).unwrap_or(&Severity::Info));
352            let categories = rule_categories
353                .get(id.as_str())
354                .copied()
355                .unwrap_or_default();
356            serde_json::json!({
357                "id": id,
358                "name": id,
359                "shortDescription": { "text": desc },
360                "helpUri": "https://github.com/immanuwell/dockerfile-roast",
361                "defaultConfiguration": { "level": level },
362                "properties": { "tags": categories }
363            })
364        })
365        .collect();
366
367    // Build results array
368    let mut sarif_results: Vec<serde_json::Value> = Vec::new();
369    for (file, findings) in results {
370        let uri = normalize_uri(file);
371        for f in *findings {
372            let idx = *rule_index.get(&f.rule).unwrap_or(&0);
373            let mut result = serde_json::json!({
374                "ruleId": f.rule,
375                "ruleIndex": idx,
376                "level": sarif_level(&f.severity),
377                "message": { "text": f.message },
378                "partialFingerprints": { "droast/v1": finding_fingerprint(file, f) },
379                "locations": [{
380                    "physicalLocation": {
381                        "artifactLocation": {
382                            "uri": uri,
383                            "uriBaseId": "%SRCROOT%"
384                        }
385                    }
386                }]
387            });
388            // region is optional in SARIF; only add when we have a real line number.
389            if f.line > 0 {
390                result["locations"][0]["physicalLocation"]["region"] =
391                    serde_json::json!({ "startLine": f.line });
392                if f.column > 0 {
393                    result["locations"][0]["physicalLocation"]["region"]["startColumn"] =
394                        serde_json::json!(f.column);
395                }
396                if f.end_line > 0 {
397                    result["locations"][0]["physicalLocation"]["region"]["endLine"] =
398                        serde_json::json!(f.end_line);
399                }
400                if f.end_column > 0 {
401                    result["locations"][0]["physicalLocation"]["region"]["endColumn"] =
402                        serde_json::json!(f.end_column);
403                }
404            }
405            sarif_results.push(result);
406        }
407    }
408
409    // Artifacts — the list of scanned files (optional but useful for tooling).
410    let artifacts: Vec<serde_json::Value> = results
411        .iter()
412        .map(|(file, _)| {
413            serde_json::json!({
414                "location": {
415                    "uri": normalize_uri(file),
416                    "uriBaseId": "%SRCROOT%"
417                }
418            })
419        })
420        .collect();
421
422    let doc = serde_json::json!({
423        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
424        "version": "2.1.0",
425        "runs": [{
426            "tool": {
427                "driver": {
428                    "name": "droast",
429                    "version": env!("CARGO_PKG_VERSION"),
430                    "informationUri": "https://github.com/immanuwell/dockerfile-roast",
431                    "rules": sarif_rules
432                }
433            },
434            "results": sarif_results,
435            "artifacts": artifacts
436        }]
437    });
438
439    serde_json::to_string_pretty(&doc).unwrap()
440}
441
442/// Map droast severity to the SARIF level string.
443/// SARIF uses "note" for informational findings, not "info".
444fn sarif_level(sev: &Severity) -> &'static str {
445    match sev {
446        Severity::Error => "error",
447        Severity::Warning => "warning",
448        Severity::Info => "note",
449    }
450}
451
452/// Convert a file path to a forward-slash URI relative to the repo root.
453/// Absolute paths are made relative by stripping the current working directory.
454fn normalize_uri(path: &str) -> String {
455    let p = std::path::Path::new(path);
456    let relative = if p.is_absolute() {
457        std::env::current_dir()
458            .ok()
459            .and_then(|cwd| p.strip_prefix(&cwd).ok().map(|r| r.to_path_buf()))
460            .unwrap_or_else(|| p.to_path_buf())
461    } else {
462        p.to_path_buf()
463    };
464    relative.to_string_lossy().replace('\\', "/")
465}
466
467pub fn print_summary_header() {
468    println!(
469        "\n{}",
470        r#"
471  ██████╗ ██████╗  ██████╗  █████╗ ███████╗████████╗
472  ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝
473  ██║  ██║██████╔╝██║   ██║███████║███████╗   ██║
474  ██║  ██║██╔══██╗██║   ██║██╔══██║╚════██║   ██║
475  ██████╔╝██║  ██║╚██████╔╝██║  ██║███████║   ██║
476  ╚═════╝ ╚═╝  ╚═╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝   ╚═╝
477  Dockerfile linter with personality
478"#
479        .bold()
480        .red()
481    );
482}
483
484#[cfg(test)]
485mod tests {
486    use super::{build_sarif, finding_fingerprint, github_annotation, json_output};
487    use crate::rules::{Finding, Severity};
488
489    fn shellcheck_finding() -> Finding {
490        Finding {
491            rule: "SC2086".into(),
492            severity: Severity::Warning,
493            line: 4,
494            column: 9,
495            end_line: 4,
496            end_column: 14,
497            message: "Double quote to prevent globbing".into(),
498            roast: "ShellCheck found a shell-script problem inside this RUN instruction.".into(),
499        }
500    }
501
502    #[test]
503    fn json_and_sarif_preserve_shellcheck_ids_and_ranges() {
504        let finding = shellcheck_finding();
505        let json = serde_json::to_value(json_output("Dockerfile", &[finding.clone()])).unwrap();
506        assert_eq!(json["findings"][0]["rule"], "SC2086");
507        assert_eq!(json["findings"][0]["column"], 9);
508
509        let sarif: serde_json::Value =
510            serde_json::from_str(&build_sarif(&[("Dockerfile", &[finding.clone()])])).unwrap();
511        assert_eq!(sarif["runs"][0]["results"][0]["ruleId"], "SC2086");
512        assert_eq!(
513            sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]
514                ["startColumn"],
515            9
516        );
517        assert_eq!(
518            finding_fingerprint("Dockerfile", &finding),
519            finding_fingerprint("Dockerfile", &Finding { line: 99, ..finding })
520        );
521    }
522
523    #[test]
524    fn github_output_honors_no_roast() {
525        let finding = shellcheck_finding();
526        let rendered = github_annotation("Dockerfile", &finding, true);
527        assert!(rendered.ends_with(&finding.message));
528        assert!(!rendered.contains(&finding.roast));
529    }
530}