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
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], 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
296pub 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 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 let rule_index: HashMap<String, usize> = rule_ids
330 .iter()
331 .enumerate()
332 .map(|(i, id)| (id.clone(), i))
333 .collect();
334
335 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 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 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 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 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
442fn sarif_level(sev: &Severity) -> &'static str {
445 match sev {
446 Severity::Error => "error",
447 Severity::Warning => "warning",
448 Severity::Info => "note",
449 }
450}
451
452fn 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}