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
214pub 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
223pub 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
283pub 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 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 let rule_index: HashMap<String, usize> = rule_ids
317 .iter()
318 .enumerate()
319 .map(|(i, id)| (id.clone(), i))
320 .collect();
321
322 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 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 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 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 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
429fn sarif_level(sev: &Severity) -> &'static str {
432 match sev {
433 Severity::Error => "error",
434 Severity::Warning => "warning",
435 Severity::Info => "note",
436 }
437}
438
439fn 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}