1use crate::parser::{parse_document, DiagnosticSeverity, Instruction};
2use regex::Regex;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
5pub enum Severity {
6 Info,
7 Warning,
8 Error,
9}
10
11impl std::fmt::Display for Severity {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 match self {
14 Severity::Info => write!(f, "INFO"),
15 Severity::Warning => write!(f, "WARN"),
16 Severity::Error => write!(f, "ERROR"),
17 }
18 }
19}
20
21#[derive(Debug, Clone)]
22pub struct Finding {
23 pub rule: &'static str,
24 pub severity: Severity,
25 pub line: usize,
26 pub message: String,
27 pub roast: String,
28}
29
30type RuleFn = fn(&[Instruction], &str) -> Vec<Finding>;
31
32pub struct Rule {
33 pub id: &'static str,
34 pub severity: Severity,
35 pub description: &'static str,
36 pub func: RuleFn,
37}
38
39impl Rule {
40 pub fn categories(&self) -> &'static [&'static str] {
41 categories_for(self.id)
42 }
43}
44
45pub const ALL_CATEGORIES: &[&str] = &[
46 "correctness",
47 "maintainability",
48 "performance",
49 "reliability",
50 "reproducibility",
51 "security",
52 "supply-chain",
53];
54
55pub fn categories_for(id: &str) -> &'static [&'static str] {
56 match id {
57 "DF002" | "DF010" | "DF013" | "DF014" | "DF020" | "DF034" => &["security"],
58 "DF021" => &["security", "supply-chain"],
59 "DF057" | "DF066" => &["reliability", "security"],
60 "DF001" | "DF005" | "DF024" | "DF042" | "DF062" | "DF069" => {
61 &["correctness", "reproducibility"]
62 }
63 "DF003" | "DF004" | "DF007" | "DF011" | "DF016" | "DF028" | "DF029" | "DF030" | "DF031"
64 | "DF045" | "DF046" | "DF047" | "DF055" | "DF064" | "DF070" => &["performance"],
65 "DF006" | "DF008" | "DF026" | "DF056" | "DF058" | "DF067" => {
66 &["maintainability", "performance"]
67 }
68 "DF033" => &["performance", "security"],
69 "DF009" | "DF019" | "DF023" | "DF037" | "DF038" | "DF043" | "DF044" | "DF059" | "DF061"
70 | "DF063" => &["correctness", "maintainability"],
71 "DF012" | "DF017" | "DF022" | "DF032" | "DF035" | "DF036" | "DF060" => {
72 &["maintainability", "reliability"]
73 }
74 "DF015" | "DF018" | "DF025" | "DF027" | "DF039" | "DF040" | "DF041" | "DF048" | "DF049"
75 | "DF050" | "DF068" | "DF071" => &["correctness", "reliability"],
76 "DF051" | "DF052" | "DF053" | "DF054" | "DF065" | "DF073" => {
77 &["reproducibility", "supply-chain"]
78 }
79 "DF072" | "DF074" => &["correctness", "security"],
80 _ => &[],
81 }
82}
83
84pub fn all_rules() -> Vec<Rule> {
85 vec![
86 Rule {
87 id: "DF001",
88 severity: Severity::Warning,
89 description: "Use specific base image tags instead of 'latest'",
90 func: rule_latest_tag,
91 },
92 Rule {
93 id: "DF002",
94 severity: Severity::Error,
95 description: "Do not run as root",
96 func: rule_running_as_root,
97 },
98 Rule {
99 id: "DF011",
100 severity: Severity::Warning,
101 description: "Use multi-stage builds to reduce image size",
102 func: rule_no_multistage,
103 },
104 Rule {
105 id: "DF013",
106 severity: Severity::Error,
107 description: "Avoid storing secrets in ENV variables",
108 func: rule_secrets_in_env,
109 },
110 Rule {
111 id: "DF014",
112 severity: Severity::Error,
113 description: "Avoid hardcoding passwords or tokens in ARG/ENV",
114 func: rule_hardcoded_secrets,
115 },
116 Rule {
117 id: "DF020",
118 severity: Severity::Warning,
119 description: "Set explicit non-root USER",
120 func: rule_no_user_instruction,
121 },
122 Rule {
123 id: "DF003",
124 severity: Severity::Warning,
125 description: "Combine RUN commands to reduce layers",
126 func: rule_many_run_layers,
127 },
128 Rule {
129 id: "DF004",
130 severity: Severity::Warning,
131 description: "Clean apt/yum/apk cache in the same RUN layer",
132 func: rule_uncleaned_package_cache,
133 },
134 Rule {
135 id: "DF005",
136 severity: Severity::Info,
137 description: "Pin package versions for reproducibility",
138 func: rule_unpinned_packages,
139 },
140 Rule {
141 id: "DF006",
142 severity: Severity::Warning,
143 description: "Avoid ADD for local files; prefer COPY",
144 func: rule_add_instead_of_copy,
145 },
146 Rule {
147 id: "DF007",
148 severity: Severity::Warning,
149 description: "Do not copy the entire build context (COPY . .)",
150 func: rule_copy_all,
151 },
152 Rule {
153 id: "DF008",
154 severity: Severity::Info,
155 description: "Use WORKDIR instead of inline cd commands",
156 func: rule_cd_instead_of_workdir,
157 },
158 Rule {
159 id: "DF009",
160 severity: Severity::Warning,
161 description: "Use absolute paths in WORKDIR",
162 func: rule_relative_workdir,
163 },
164 Rule {
165 id: "DF010",
166 severity: Severity::Warning,
167 description: "Avoid using sudo inside containers",
168 func: rule_sudo_usage,
169 },
170 Rule {
171 id: "DF012",
172 severity: Severity::Info,
173 description: "Set HEALTHCHECK for long-running services",
174 func: rule_no_healthcheck,
175 },
176 Rule {
177 id: "DF017",
178 severity: Severity::Warning,
179 description: "Use ENTRYPOINT with CMD for flexible images",
180 func: rule_cmd_without_entrypoint,
181 },
182 Rule {
183 id: "DF018",
184 severity: Severity::Warning,
185 description: "Avoid using shell form for ENTRYPOINT",
186 func: rule_shell_form_entrypoint,
187 },
188 Rule {
189 id: "DF019",
190 severity: Severity::Warning,
191 description: "Do not use deprecated MAINTAINER; use LABEL instead",
192 func: rule_deprecated_maintainer,
193 },
194 Rule {
195 id: "DF022",
196 severity: Severity::Info,
197 description: "Specify EXPOSE for documented ports",
198 func: rule_no_expose,
199 },
200 Rule {
201 id: "DF023",
202 severity: Severity::Warning,
203 description: "Avoid multiple FROM without aliases (unintended multistage)",
204 func: rule_multiple_from_no_alias,
205 },
206 Rule {
207 id: "DF024",
208 severity: Severity::Warning,
209 description: "Avoid using :latest in FROM even with aliases",
210 func: rule_from_latest_alias,
211 },
212 Rule {
213 id: "DF025",
214 severity: Severity::Warning,
215 description: "Use JSON array syntax for CMD/ENTRYPOINT",
216 func: rule_shell_form_cmd,
217 },
218 Rule {
219 id: "DF026",
220 severity: Severity::Warning,
221 description: "Avoid recursive COPY from root",
222 func: rule_copy_root,
223 },
224 Rule {
225 id: "DF030",
226 severity: Severity::Info,
227 description: "Avoid using pip without --no-cache-dir",
228 func: rule_pip_no_cache,
229 },
230 Rule {
231 id: "DF031",
232 severity: Severity::Info,
233 description: "Avoid npm install without ci/--production for prod images",
234 func: rule_npm_install,
235 },
236 Rule {
237 id: "DF032",
238 severity: Severity::Info,
239 description: "Set PYTHONDONTWRITEBYTECODE and PYTHONUNBUFFERED for Python images",
240 func: rule_python_env_vars,
241 },
242 Rule {
243 id: "DF033",
244 severity: Severity::Info,
245 description: "Use an effective .dockerignore for each build context",
246 func: rule_no_dockerignore,
247 },
248 Rule {
249 id: "DF034",
250 severity: Severity::Error,
251 description: "Avoid chmod 777 — overly permissive",
252 func: rule_chmod_777,
253 },
254 Rule {
255 id: "DF035",
256 severity: Severity::Info,
257 description: "Avoid using curl without --fail flags",
258 func: rule_curl_no_fail,
259 },
260 Rule {
261 id: "DF036",
262 severity: Severity::Warning,
263 description: "Avoid Dockerfile with no CMD or ENTRYPOINT",
264 func: rule_no_cmd_or_entrypoint,
265 },
266 Rule {
267 id: "DF015",
268 severity: Severity::Error,
269 description: "Avoid using apt-get without -y flag",
270 func: rule_apt_no_y,
271 },
272 Rule {
273 id: "DF016",
274 severity: Severity::Info,
275 description: "Use --no-install-recommends with apt-get",
276 func: rule_apt_recommends,
277 },
278 Rule {
279 id: "DF021",
280 severity: Severity::Error,
281 description: "Avoid wget|sh pipe patterns (execute remote code)",
282 func: rule_curl_pipe_sh,
283 },
284 Rule {
285 id: "DF027",
286 severity: Severity::Error,
287 description: "Do not use yum without -y flag",
288 func: rule_yum_no_y,
289 },
290 Rule {
291 id: "DF028",
292 severity: Severity::Warning,
293 description: "Cache-bust apt-get update",
294 func: rule_apt_get_update_alone,
295 },
296 Rule {
297 id: "DF029",
298 severity: Severity::Warning,
299 description: "Avoid apk add without --no-cache",
300 func: rule_apk_no_cache,
301 },
302 Rule {
303 id: "DF037",
304 severity: Severity::Error,
305 description: "Dockerfile must begin with FROM, ARG, or a comment",
306 func: rule_invalid_instruction_order,
307 },
308 Rule {
309 id: "DF038",
310 severity: Severity::Warning,
311 description: "Multiple CMD instructions — only the last one takes effect",
312 func: rule_multiple_cmd,
313 },
314 Rule {
315 id: "DF039",
316 severity: Severity::Error,
317 description: "Multiple ENTRYPOINT instructions — only the last one takes effect",
318 func: rule_multiple_entrypoint,
319 },
320 Rule {
321 id: "DF040",
322 severity: Severity::Error,
323 description: "EXPOSE port must be in valid range 0-65535",
324 func: rule_expose_port_range,
325 },
326 Rule {
327 id: "DF041",
328 severity: Severity::Error,
329 description: "Multiple HEALTHCHECK instructions — only the last one applies",
330 func: rule_multiple_healthcheck,
331 },
332 Rule {
333 id: "DF042",
334 severity: Severity::Error,
335 description: "FROM stage aliases must be unique",
336 func: rule_unique_stage_aliases,
337 },
338 Rule {
339 id: "DF043",
340 severity: Severity::Warning,
341 description: "zypper install without non-interactive flag",
342 func: rule_zypper_no_y,
343 },
344 Rule {
345 id: "DF044",
346 severity: Severity::Warning,
347 description: "Avoid zypper dist-upgrade in Dockerfiles",
348 func: rule_zypper_dist_upgrade,
349 },
350 Rule {
351 id: "DF045",
352 severity: Severity::Info,
353 description: "Run zypper clean after zypper install",
354 func: rule_zypper_clean,
355 },
356 Rule {
357 id: "DF046",
358 severity: Severity::Warning,
359 description: "Run dnf clean all after dnf install",
360 func: rule_dnf_clean,
361 },
362 Rule {
363 id: "DF047",
364 severity: Severity::Warning,
365 description: "Run yum clean all after yum install",
366 func: rule_yum_clean,
367 },
368 Rule {
369 id: "DF048",
370 severity: Severity::Error,
371 description: "COPY with multiple sources requires destination to end with /",
372 func: rule_copy_multi_arg_slash,
373 },
374 Rule {
375 id: "DF049",
376 severity: Severity::Warning,
377 description: "COPY --from must reference a previously defined stage",
378 func: rule_copy_from_undefined_stage,
379 },
380 Rule {
381 id: "DF050",
382 severity: Severity::Error,
383 description: "COPY --from cannot reference the current stage",
384 func: rule_copy_from_self,
385 },
386 Rule {
387 id: "DF051",
388 severity: Severity::Warning,
389 description: "Pin versions in pip install",
390 func: rule_pip_version_pinning,
391 },
392 Rule {
393 id: "DF052",
394 severity: Severity::Warning,
395 description: "Pin versions in apk add",
396 func: rule_apk_version_pinning,
397 },
398 Rule {
399 id: "DF053",
400 severity: Severity::Warning,
401 description: "Pin versions in gem install",
402 func: rule_gem_version_pinning,
403 },
404 Rule {
405 id: "DF054",
406 severity: Severity::Warning,
407 description: "Pin versions in go install with @version",
408 func: rule_go_install_version,
409 },
410 Rule {
411 id: "DF055",
412 severity: Severity::Info,
413 description: "Run yarn cache clean after yarn install",
414 func: rule_yarn_cache_clean,
415 },
416 Rule {
417 id: "DF056",
418 severity: Severity::Info,
419 description: "Use wget --progress=dot:giga to avoid bloated build logs",
420 func: rule_wget_no_progress,
421 },
422 Rule {
423 id: "DF057",
424 severity: Severity::Warning,
425 description: "Set -o pipefail before RUN commands that use pipes",
426 func: rule_pipefail_missing,
427 },
428 Rule {
429 id: "DF058",
430 severity: Severity::Warning,
431 description: "Use either wget or curl consistently, not both",
432 func: rule_wget_and_curl,
433 },
434 Rule {
435 id: "DF059",
436 severity: Severity::Warning,
437 description: "Use apt-get or apt-cache instead of apt in scripts",
438 func: rule_apt_instead_of_apt_get,
439 },
440 Rule {
441 id: "DF060",
442 severity: Severity::Info,
443 description: "Avoid running pointless interactive commands inside containers",
444 func: rule_useless_commands,
445 },
446 Rule {
447 id: "DF061",
448 severity: Severity::Warning,
449 description: "Do not use --platform in FROM unless required",
450 func: rule_from_platform_flag,
451 },
452 Rule {
453 id: "DF062",
454 severity: Severity::Error,
455 description: "ENV variable must not reference itself in the same statement",
456 func: rule_env_self_reference,
457 },
458 Rule {
459 id: "DF063",
460 severity: Severity::Warning,
461 description: "COPY to relative destination requires WORKDIR to be set first",
462 func: rule_copy_relative_no_workdir,
463 },
464 Rule {
465 id: "DF064",
466 severity: Severity::Warning,
467 description: "useradd without -l flag may create excessively large images",
468 func: rule_useradd_no_l,
469 },
470 Rule {
471 id: "DF065",
472 severity: Severity::Warning,
473 description: "FROM uses an unrecognised image registry",
474 func: rule_untrusted_registry,
475 },
476 Rule {
477 id: "DF066",
478 severity: Severity::Warning,
479 description: "Bash-specific syntax used without a SHELL instruction",
480 func: rule_bash_syntax_no_shell,
481 },
482 Rule {
483 id: "DF067",
484 severity: Severity::Info,
485 description: "COPY of a local archive — ADD auto-extracts tarballs",
486 func: rule_copy_archive_use_add,
487 },
488 Rule {
489 id: "DF068",
490 severity: Severity::Error,
491 description: "FROM, ONBUILD, and MAINTAINER are forbidden as ONBUILD triggers",
492 func: rule_onbuild_forbidden,
493 },
494 Rule {
495 id: "DF069",
496 severity: Severity::Warning,
497 description: "Avoid apt-get upgrade / dist-upgrade — makes builds non-reproducible",
498 func: rule_apt_upgrade,
499 },
500 Rule {
501 id: "DF070",
502 severity: Severity::Warning,
503 description: "Avoid broad COPY before package install — invalidates Docker layer cache",
504 func: rule_copy_before_install,
505 },
506 Rule {
507 id: "DF071",
508 severity: Severity::Error,
509 description: "Dockerfile syntax must be valid",
510 func: rule_parser_syntax,
511 },
512 Rule {
513 id: "DF072",
514 severity: Severity::Error,
515 description: "Suppression directives must satisfy policy",
516 func: rule_configured_policy,
517 },
518 Rule {
519 id: "DF073",
520 severity: Severity::Error,
521 description: "Base images must satisfy the approved image policy",
522 func: rule_configured_policy,
523 },
524 Rule {
525 id: "DF074",
526 severity: Severity::Error,
527 description: "Image labels must satisfy the configured schema",
528 func: rule_configured_policy,
529 },
530 ]
531}
532
533fn rule_configured_policy(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
534 Vec::new()
535}
536
537fn rule_parser_syntax(_instrs: &[Instruction], raw: &str) -> Vec<Finding> {
538 parse_document(raw)
539 .diagnostics
540 .into_iter()
541 .map(|diagnostic| Finding {
542 rule: "DF071",
543 severity: match diagnostic.severity {
544 DiagnosticSeverity::Warning => Severity::Warning,
545 DiagnosticSeverity::Error => Severity::Error,
546 },
547 line: diagnostic.span.start.line,
548 message: diagnostic.message,
549 roast: "The Dockerfile parser could not interpret this reliably; fix the syntax before trusting downstream lint results.".to_string(),
550 })
551 .collect()
552}
553
554fn instrs_of<'a>(instrs: &'a [Instruction], name: &str) -> Vec<&'a Instruction> {
555 instrs.iter().filter(|i| i.instruction == name).collect()
556}
557
558fn has_instr(instrs: &[Instruction], name: &str) -> bool {
559 instrs.iter().any(|i| i.instruction == name)
560}
561
562#[derive(Clone, Copy, Debug, PartialEq, Eq)]
563struct FromArguments<'a> {
564 image: &'a str,
565 alias: Option<&'a str>,
566}
567
568fn parse_from_arguments(arguments: &str) -> Option<FromArguments<'_>> {
573 let mut tokens = arguments.split_whitespace();
574 let image = tokens.find(|token| !token.starts_with("--"))?;
575 let alias = match (tokens.next(), tokens.next()) {
576 (Some(keyword), Some(alias)) if keyword.eq_ignore_ascii_case("as") => Some(alias),
577 _ => None,
578 };
579 Some(FromArguments { image, alias })
580}
581
582fn rule_latest_tag(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
583 let mut stage_aliases = std::collections::HashSet::new();
584 let mut findings = Vec::new();
585
586 for instruction in instrs_of(instrs, "FROM") {
587 let Some(from) = parse_from_arguments(&instruction.arguments) else {
588 continue;
589 };
590 let base = from.image;
591 let is_previous_stage = stage_aliases.contains(&base.to_lowercase());
592 if !is_previous_stage
593 && !base.eq_ignore_ascii_case("scratch")
594 && (base.ends_with(":latest") || (!base.contains(':') && !base.contains('@')))
595 {
596 findings.push(Finding {
597 rule: "DF001",
598 severity: Severity::Warning,
599 line: instruction.line,
600 message: format!("'{}' uses an unpinned image tag", base),
601 roast: "Pinning to 'latest' is like ordering 'whatever' at a restaurant and then \
602 complaining when your image breaks in prod. Use a real tag."
603 .to_string(),
604 });
605 }
606 if let Some(alias) = from.alias {
607 stage_aliases.insert(alias.to_lowercase());
608 }
609 }
610
611 findings
612}
613
614fn rule_running_as_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
615 let mut findings = Vec::new();
616 for u in instrs_of(instrs, "USER") {
617 let val = u.arguments.trim().to_lowercase();
618 if val == "root" || val == "0" || val == "0:0" || val == "root:root" {
619 findings.push(Finding {
620 rule: "DF002",
621 severity: Severity::Error,
622 line: u.line,
623 message: "Container is explicitly set to run as root".to_string(),
624 roast: "Congratulations, you're running as root. Your security team is crying, \
625 your CISO is drafting a strongly-worded email, and a hacker somewhere \
626 just smiled."
627 .to_string(),
628 });
629 }
630 }
631 findings
632}
633
634fn rule_no_multistage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
635 let from_count = instrs_of(instrs, "FROM").len();
636 if from_count > 1 {
637 return vec![];
638 }
639 let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
640 Some(f) => f,
641 None => return vec![],
642 };
643 let build_images = [
644 "golang", "node", "rust", "maven", "gradle", "openjdk", "python", "dotnet", "gcc",
645 ];
646 let img = first_from.arguments.to_lowercase();
647 if build_images.iter().any(|b| img.contains(b)) {
648 return vec![Finding {
649 rule: "DF011",
650 severity: Severity::Warning,
651 line: first_from.line,
652 message: "Single-stage build with a heavy build image — consider multi-stage builds"
653 .to_string(),
654 roast: "Shipping your entire build toolchain to production? Your 2GB Go image is \
655 basically a free gift to anyone who gets shell access. Multi-stage builds \
656 exist. They're fantastic. Use them."
657 .to_string(),
658 }];
659 }
660 vec![]
661}
662
663fn rule_many_run_layers(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
664 let mut findings = Vec::new();
665 let mut consecutive = 0usize;
666 let mut start_line = 0usize;
667 for i in instrs {
668 if i.instruction == "RUN" {
669 if consecutive == 0 {
670 start_line = i.line;
671 }
672 consecutive += 1;
673 } else if i.instruction == "FROM" {
674 consecutive = 0;
675 } else if consecutive > 0 {
676 if consecutive >= 4 {
677 findings.push(Finding {
678 rule: "DF003",
679 severity: Severity::Warning,
680 line: start_line,
681 message: format!(
682 "{} consecutive RUN instructions could be merged into one",
683 consecutive
684 ),
685 roast: format!(
686 "{} separate RUN layers? Your image has more layers than a mid-2000s emo \
687 band. Combine them with && and save everyone's bandwidth.",
688 consecutive
689 ),
690 });
691 }
692 consecutive = 0;
693 }
694 }
695 if consecutive >= 4 {
696 findings.push(Finding {
697 rule: "DF003",
698 severity: Severity::Warning,
699 line: start_line,
700 message: format!("{} consecutive RUN instructions could be merged into one", consecutive),
701 roast: format!(
702 "{} separate RUN layers? Your image is basically an onion — except nobody's \
703 crying because it's beautiful; they're crying because it takes 10 minutes to pull.", consecutive
704 ),
705 });
706 }
707 findings
708}
709
710fn rule_add_instead_of_copy(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
711 instrs_of(instrs, "ADD")
712 .into_iter()
713 .filter(|i| {
714 let source = match i
716 .arguments
717 .split_whitespace()
718 .find(|t| !t.starts_with("--"))
719 {
720 Some(s) => s,
721 None => return false,
722 };
723 let is_url = source.contains("://");
724 let is_archive = source.ends_with(".tar.gz")
725 || source.ends_with(".tgz")
726 || source.ends_with(".tar.xz")
727 || source.ends_with(".tar.bz2")
728 || source.ends_with(".tar");
729 !is_url && !is_archive
730 })
731 .map(|i| Finding {
732 rule: "DF006",
733 severity: Severity::Warning,
734 line: i.line,
735 message: "ADD used for local file — prefer COPY".to_string(),
736 roast: "Using ADD to copy local files is like taking a helicopter to cross the \
737 street. COPY exists, it's right there, it's boring and correct — which is \
738 everything you want in infrastructure."
739 .to_string(),
740 })
741 .collect()
742}
743
744fn rule_copy_all(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
745 instrs_of(instrs, "COPY")
746 .into_iter()
747 .filter(|i| {
748 let a = i.arguments.trim();
749 a.starts_with(". ") || a == "."
750 })
751 .map(|i| Finding {
752 rule: "DF007",
753 severity: Severity::Warning,
754 line: i.line,
755 message: "COPY . copies the entire build context — consider a .dockerignore file"
756 .to_string(),
757 roast: "COPY . — dumping your entire project including node_modules, .git history, \
758 and that .env file with the production database password into the image. \
759 Bold. Reckless. Very DevOps of you."
760 .to_string(),
761 })
762 .collect()
763}
764
765fn rule_cd_instead_of_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
766 let re = Regex::new(r"\bcd\s+[^\s;|&]+").unwrap();
767 instrs_of(instrs, "RUN")
768 .into_iter()
769 .filter(|i| re.is_match(&i.arguments))
770 .map(|i| Finding {
771 rule: "DF008",
772 severity: Severity::Info,
773 line: i.line,
774 message: "Using 'cd' in RUN — prefer WORKDIR instruction".to_string(),
775 roast: "`cd` in a RUN instruction: not wrong, but every new RUN starts fresh anyway, \
776 so you're cosplaying as a shell script when you should be writing a Dockerfile. \
777 WORKDIR is your friend.".to_string(),
778 })
779 .collect()
780}
781
782fn rule_relative_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
783 instrs_of(instrs, "WORKDIR")
784 .into_iter()
785 .filter(|i| !i.arguments.trim().starts_with('/') && !i.arguments.trim().starts_with('$'))
786 .map(|i| Finding {
787 rule: "DF009",
788 severity: Severity::Warning,
789 line: i.line,
790 message: format!(
791 "WORKDIR '{}' is relative — use an absolute path",
792 i.arguments.trim()
793 ),
794 roast: "A relative WORKDIR? You're setting your working directory relative to... \
795 what, exactly? Hope? Dreams? Use an absolute path like a grown-up."
796 .to_string(),
797 })
798 .collect()
799}
800
801fn rule_sudo_usage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
802 let re = Regex::new(r"\bsudo\b").unwrap();
803 instrs_of(instrs, "RUN")
804 .into_iter()
805 .filter(|i| re.is_match(&i.arguments))
806 .map(|i| Finding {
807 rule: "DF010",
808 severity: Severity::Warning,
809 line: i.line,
810 message: "sudo used inside a container — likely unnecessary".to_string(),
811 roast: "sudo inside a Docker container? You're already root (probably). sudo is \
812 just a formality at this point, like putting a 'Wet Floor' sign in the ocean."
813 .to_string(),
814 })
815 .collect()
816}
817
818fn rule_no_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
819 if has_instr(instrs, "HEALTHCHECK") {
820 return vec![];
821 }
822 if !has_instr(instrs, "EXPOSE") && !has_instr(instrs, "CMD") {
823 return vec![];
824 }
825 vec![Finding {
826 rule: "DF012",
827 severity: Severity::Info,
828 line: 0,
829 message: "No HEALTHCHECK defined".to_string(),
830 roast: "No HEALTHCHECK? Your container is basically on the honor system. 'It's fine, \
831 I'm sure it's fine.' Meanwhile Kubernetes is just restarting it every 30 seconds \
832 wondering what went wrong."
833 .to_string(),
834 }]
835}
836
837fn rule_cmd_without_entrypoint(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
838 vec![]
839}
840
841fn rule_shell_form_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
842 instrs_of(instrs, "ENTRYPOINT")
843 .into_iter()
844 .filter(|i| !i.arguments.trim().starts_with('['))
845 .map(|i| Finding {
846 rule: "DF018",
847 severity: Severity::Warning,
848 line: i.line,
849 message: "ENTRYPOINT in shell form prevents signal propagation".to_string(),
850 roast: "Shell-form ENTRYPOINT means your app runs as a child of /bin/sh. When \
851 Kubernetes sends SIGTERM, your app doesn't get it — /bin/sh does, and \
852 /bin/sh doesn't care. Use exec form: ENTRYPOINT [\"cmd\", \"arg\"]."
853 .to_string(),
854 })
855 .collect()
856}
857
858fn rule_deprecated_maintainer(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
859 instrs_of(instrs, "MAINTAINER")
860 .into_iter()
861 .map(|i| Finding {
862 rule: "DF019",
863 severity: Severity::Warning,
864 line: i.line,
865 message: "MAINTAINER is deprecated".to_string(),
866 roast: "MAINTAINER has been deprecated since Docker 1.13. That was 2017. \
867 Your Dockerfile is old enough to be in middle school. \
868 Use LABEL maintainer=\"...\" like the rest of us."
869 .to_string(),
870 })
871 .collect()
872}
873
874fn rule_no_expose(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
875 if has_instr(instrs, "EXPOSE") {
876 return vec![];
877 }
878 if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
879 return vec![];
880 }
881 vec![Finding {
882 rule: "DF022",
883 severity: Severity::Info,
884 line: 0,
885 message: "No EXPOSE instruction — consider documenting which ports this service uses"
886 .to_string(),
887 roast: "No EXPOSE? Your container is a mystery box. Is it a web server? A database? \
888 A very slow random number generator? EXPOSE is documentation — it tells the \
889 next developer which port to knock on."
890 .to_string(),
891 }]
892}
893
894fn rule_multiple_from_no_alias(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
895 let froms: Vec<_> = instrs_of(instrs, "FROM");
896 if froms.len() <= 1 {
897 return vec![];
898 }
899 froms
900 .into_iter()
901 .skip(1)
902 .filter(|i| parse_from_arguments(&i.arguments).is_some_and(|from| from.alias.is_none()))
903 .map(|i| Finding {
904 rule: "DF023",
905 severity: Severity::Warning,
906 line: i.line,
907 message: "Multi-stage FROM without AS alias — hard to reference later".to_string(),
908 roast: "Multi-stage FROM without an alias. How will you COPY --from=... this? \
909 By index? \"--from=2\"? That's fragile. Give your stages names like \
910 a civilized person. FROM golang:1.21 AS builder."
911 .to_string(),
912 })
913 .collect()
914}
915
916fn rule_from_latest_alias(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
917 vec![]
918}
919
920fn rule_shell_form_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
921 instrs_of(instrs, "CMD")
922 .into_iter()
923 .filter(|i| !i.arguments.trim().starts_with('['))
924 .map(|i| Finding {
925 rule: "DF025",
926 severity: Severity::Warning,
927 line: i.line,
928 message: "CMD in shell form — prefer exec form [\"executable\", \"arg\"]".to_string(),
929 roast: "Shell-form CMD wraps your process in /bin/sh -c, which means PID 1 is the \
930 shell, not your app. Signal handling breaks, graceful shutdown breaks, and \
931 your ops team breaks (emotionally). Use exec form."
932 .to_string(),
933 })
934 .collect()
935}
936
937fn rule_copy_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
938 instrs_of(instrs, "COPY")
939 .into_iter()
940 .filter(|i| {
941 let a = i.arguments.trim();
942 a.ends_with(" /") || a.contains(" / ") || a.ends_with("/.")
943 })
944 .map(|i| Finding {
945 rule: "DF026",
946 severity: Severity::Warning,
947 line: i.line,
948 message: "COPY to filesystem root — this may overwrite system files".to_string(),
949 roast: "Copying files directly to /? Brave. Reckless. Chaotic. You're one typo away \
950 from overwriting /bin/sh and creating a container that doesn't even boot. \
951 Use a dedicated app directory."
952 .to_string(),
953 })
954 .collect()
955}
956
957fn rule_pip_no_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
958 instrs_of(instrs, "RUN")
959 .into_iter()
960 .filter(|i| {
961 let a = &i.arguments;
962 (a.contains("pip install") || a.contains("pip3 install"))
963 && !a.contains("--no-cache-dir")
964 })
965 .map(|i| Finding {
966 rule: "DF030",
967 severity: Severity::Info,
968 line: i.line,
969 message: "pip install without --no-cache-dir wastes space in the image layer"
970 .to_string(),
971 roast: "pip install without --no-cache-dir? You're carrying around a pip cache in \
972 your production image like a tourist with a suitcase full of hotel shampoos. \
973 You don't need those. Add --no-cache-dir."
974 .to_string(),
975 })
976 .collect()
977}
978
979fn rule_npm_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
980 let npm_install = Regex::new(r"\bnpm\s+install\b").expect("valid npm install regex");
981 instrs_of(instrs, "RUN")
982 .into_iter()
983 .filter(|i| {
984 let a = &i.arguments;
985 npm_install.is_match(a) && !a.contains("--production") && !a.contains("--omit=dev")
986 })
987 .map(|i| Finding {
988 rule: "DF031",
989 severity: Severity::Info,
990 line: i.line,
991 message: "npm install used — consider npm ci for reproducible builds".to_string(),
992 roast: "`npm install` in a Dockerfile: non-deterministic, slower than `npm ci`, \
993 and potentially installs different versions than your lockfile specifies. \
994 `npm ci` exists specifically for CI/CD and containers. Use it."
995 .to_string(),
996 })
997 .collect()
998}
999
1000fn rule_python_env_vars(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1001 let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
1002 Some(f) => f,
1003 None => return vec![],
1004 };
1005 if !first_from.arguments.to_lowercase().contains("python") {
1006 return vec![];
1007 }
1008 let env_args: String = instrs_of(instrs, "ENV")
1009 .iter()
1010 .map(|i| i.arguments.as_str())
1011 .collect::<Vec<_>>()
1012 .join(" ");
1013 let mut findings = Vec::new();
1014 if !env_args.contains("PYTHONDONTWRITEBYTECODE") {
1015 findings.push(Finding {
1016 rule: "DF032",
1017 severity: Severity::Info,
1018 line: 0,
1019 message: "PYTHONDONTWRITEBYTECODE not set — Python will write .pyc files to the image"
1020 .to_string(),
1021 roast: "Python is quietly writing .pyc bytecode files all over your image. \
1022 Set PYTHONDONTWRITEBYTECODE=1 and stop Python from hoarding compiled cache \
1023 files in your container like a digital hoarder."
1024 .to_string(),
1025 });
1026 }
1027 if !env_args.contains("PYTHONUNBUFFERED") {
1028 findings.push(Finding {
1029 rule: "DF032",
1030 severity: Severity::Info,
1031 line: 0,
1032 message: "PYTHONUNBUFFERED not set — Python output may not appear in logs".to_string(),
1033 roast: "PYTHONUNBUFFERED not set? Your Python app is buffering stdout, meaning \
1034 logs disappear into the void and you won't see output until the buffer \
1035 flushes — which is never, because your container crashed. Set PYTHONUNBUFFERED=1.".to_string(),
1036 });
1037 }
1038 findings
1039}
1040
1041fn rule_no_dockerignore(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1042 vec![]
1043}
1044
1045fn rule_chmod_777(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1046 let re = Regex::new(r"chmod\s+([-R\s]*)777").unwrap();
1047 instrs_of(instrs, "RUN")
1048 .into_iter()
1049 .filter(|i| re.is_match(&i.arguments))
1050 .map(|i| Finding {
1051 rule: "DF034",
1052 severity: Severity::Error,
1053 line: i.line,
1054 message: "chmod 777 grants world-writable permissions — overly permissive".to_string(),
1055 roast: "chmod 777? Giving everyone read, write, and execute access is the filesystem \
1056 equivalent of leaving your front door open with a sign that says \
1057 'free stuff inside'. Minimum permissions, please."
1058 .to_string(),
1059 })
1060 .collect()
1061}
1062
1063fn rule_curl_no_fail(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1064 instrs_of(instrs, "RUN")
1065 .into_iter()
1066 .filter(|i| {
1067 let a = &i.arguments;
1068 let has_url = a.contains("http://") || a.contains("https://") || a.contains("ftp://");
1070 has_url
1071 && a.contains("curl")
1072 && !a.contains("--fail")
1073 && !a.contains("-fsSL")
1074 && !a.contains("-fsS")
1075 && !a.contains("-fL")
1076 && !a.contains("-fs ")
1077 && !{
1078 let mut found = false;
1079 for part in a.split_whitespace() {
1080 if part.starts_with('-') && !part.starts_with("--") && part.contains('f') {
1081 found = true;
1082 break;
1083 }
1084 }
1085 found
1086 }
1087 })
1088 .map(|i| Finding {
1089 rule: "DF035",
1090 severity: Severity::Info,
1091 line: i.line,
1092 message: "curl without --fail — HTTP errors won't cause the RUN step to fail"
1093 .to_string(),
1094 roast: "curl without --fail means a 404 or 500 response silently succeeds. \
1095 Your build will happily continue after downloading an error page and \
1096 treating it as a binary. Add --fail and save yourself a 2am debugging session."
1097 .to_string(),
1098 })
1099 .collect()
1100}
1101
1102fn rule_no_cmd_or_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1103 if has_instr(instrs, "CMD") || has_instr(instrs, "ENTRYPOINT") {
1104 return vec![];
1105 }
1106 if instrs.len() < 3 {
1107 return vec![];
1108 }
1109 vec![Finding {
1110 rule: "DF036",
1111 severity: Severity::Warning,
1112 line: 0,
1113 message: "No CMD or ENTRYPOINT defined — the container has no default command".to_string(),
1114 roast: "No CMD or ENTRYPOINT? This container starts, does nothing, and immediately exits \
1115 like an intern on their first day who didn't read the onboarding docs. \
1116 Tell it what to run."
1117 .to_string(),
1118 }]
1119}
1120
1121fn rule_uncleaned_package_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1122 let apt_distclean = Regex::new(r"\bapt-get\s+distclean\b").expect("valid apt distclean regex");
1123 let mut findings = Vec::new();
1124 for i in instrs_of(instrs, "RUN") {
1125 let arg = &i.arguments;
1126 let has_apt = arg.contains("apt-get install") || arg.contains("apt install");
1127 let has_yum = arg.contains("yum install") || arg.contains("dnf install");
1128 let has_apk = arg.contains("apk add") && !arg.contains("--no-cache");
1129 let cleans_apt_lists =
1130 arg.contains("rm -rf /var/lib/apt/lists") || apt_distclean.is_match(arg);
1131 if has_apt && !cleans_apt_lists {
1132 findings.push(Finding {
1133 rule: "DF004",
1134 severity: Severity::Warning,
1135 line: i.line,
1136 message: "apt cache not cleaned after install — adds unnecessary layer size"
1137 .to_string(),
1138 roast: "Not cleaning the apt cache is like finishing a meal and leaving all the \
1139 wrappers in the container. Your image is now a trash can. A very expensive \
1140 trash can stored in ECR."
1141 .to_string(),
1142 });
1143 }
1144 if has_yum && !arg.contains("yum clean all") && !arg.contains("dnf clean all") {
1145 findings.push(Finding {
1146 rule: "DF004",
1147 severity: Severity::Warning,
1148 line: i.line,
1149 message: "yum/dnf cache not cleaned after install".to_string(),
1150 roast: "You installed packages with yum but didn't clean up. Every megabyte of \
1151 cache you leave is a megabyte of shame floating in your registry."
1152 .to_string(),
1153 });
1154 }
1155 if has_apk {
1156 findings.push(Finding {
1157 rule: "DF029",
1158 severity: Severity::Warning,
1159 line: i.line,
1160 message: "apk add without --no-cache flag".to_string(),
1161 roast: "Using `apk add` without `--no-cache`? You chose Alpine to save space and \
1162 then immediately gained it all back. That's impressive, in a bad way."
1163 .to_string(),
1164 });
1165 }
1166 }
1167 findings
1168}
1169
1170fn rule_unpinned_packages(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1171 let re_yum = Regex::new(r"yum install[^&|;]*").unwrap();
1172 let mut findings = Vec::new();
1173 for i in instrs_of(instrs, "RUN") {
1174 if apt_install_commands(&i.arguments)
1175 .iter()
1176 .any(|(tokens, install_index)| apt_has_unpinned_package(tokens, *install_index))
1177 {
1178 findings.push(Finding {
1179 rule: "DF005",
1180 severity: Severity::Info,
1181 line: i.line,
1182 message: "apt-get install without pinned package versions".to_string(),
1183 roast: "Unpinned packages: a bold way to ensure your build is different \
1184 every single time. 'It worked on my machine' is a lifestyle choice, \
1185 not a deployment strategy."
1186 .to_string(),
1187 });
1188 }
1189 if re_yum.find(&i.arguments).is_some() {
1190 findings.push(Finding {
1191 rule: "DF005",
1192 severity: Severity::Info,
1193 line: i.line,
1194 message: "yum install without pinned package versions".to_string(),
1195 roast: "Your yum packages are pinned to 'whatever yum feels like today'. \
1196 Reproducibility called — it's going to voicemail."
1197 .to_string(),
1198 });
1199 }
1200 }
1201 findings
1202}
1203
1204fn apt_install_commands(arguments: &str) -> Vec<(Vec<&str>, usize)> {
1205 arguments
1206 .split(['&', '|', ';'])
1207 .filter_map(|segment| {
1208 let segment_tokens: Vec<_> = segment.split_whitespace().collect();
1209 let apt_index = segment_tokens
1210 .iter()
1211 .position(|token| matches!(*token, "apt" | "apt-get"))?;
1212 let tokens = segment_tokens[apt_index + 1..].to_vec();
1213 let install_index = tokens.iter().position(|token| *token == "install")?;
1214 Some((tokens, install_index))
1215 })
1216 .collect()
1217}
1218
1219fn apt_has_unpinned_package(tokens: &[&str], install_index: usize) -> bool {
1220 if tokens.contains(&"--only-upgrade") {
1221 return false;
1222 }
1223
1224 let options_with_values = [
1225 "-a",
1226 "--host-architecture",
1227 "-c",
1228 "--config-file",
1229 "-o",
1230 "--option",
1231 "-q",
1232 "--quiet",
1233 "-t",
1234 "--target-release",
1235 ];
1236 let mut skip_option_value = false;
1237 for token in tokens.iter().skip(install_index + 1) {
1238 if skip_option_value {
1239 skip_option_value = false;
1240 continue;
1241 }
1242 if options_with_values.contains(token) {
1243 skip_option_value = true;
1244 continue;
1245 }
1246 if token.starts_with('-') {
1247 continue;
1248 }
1249 if !token.contains('=') {
1250 return true;
1251 }
1252 }
1253 false
1254}
1255
1256fn apt_assumes_yes(tokens: &[&str]) -> bool {
1257 for (index, token) in tokens.iter().enumerate() {
1258 if matches!(*token, "--yes" | "--assume-yes") {
1259 return true;
1260 }
1261 if let Some(short_options) = token.strip_prefix('-').filter(|_| !token.starts_with("--")) {
1262 if short_options.contains('y')
1263 || short_options.chars().filter(|c| *c == 'q').count() >= 2
1264 {
1265 return true;
1266 }
1267 if short_options
1268 .strip_prefix("q=")
1269 .and_then(|level| level.parse::<u8>().ok())
1270 .is_some_and(|level| level >= 2)
1271 {
1272 return true;
1273 }
1274 }
1275 if token
1276 .strip_prefix("--quiet=")
1277 .and_then(|level| level.parse::<u8>().ok())
1278 .is_some_and(|level| level >= 2)
1279 {
1280 return true;
1281 }
1282 if matches!(*token, "-q" | "--quiet")
1283 && tokens
1284 .get(index + 1)
1285 .and_then(|level| level.parse::<u8>().ok())
1286 .is_some_and(|level| level >= 2)
1287 {
1288 return true;
1289 }
1290 }
1291 false
1292}
1293
1294fn rule_apt_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1295 instrs_of(instrs, "RUN")
1296 .into_iter()
1297 .filter(|i| {
1298 let a = &i.arguments;
1299 apt_install_commands(a)
1300 .iter()
1301 .any(|(tokens, _)| !apt_assumes_yes(tokens))
1302 && !a.contains("DEBIAN_FRONTEND=noninteractive")
1303 })
1304 .map(|i| Finding {
1305 rule: "DF015",
1306 severity: Severity::Error,
1307 line: i.line,
1308 message: "apt-get install without -y flag will hang waiting for user input".to_string(),
1309 roast: "apt-get install without -y? Your build is going to sit there, patiently \
1310 waiting for a 'yes' that will never come, like a golden retriever waiting \
1311 for an owner who's on a cruise ship."
1312 .to_string(),
1313 })
1314 .collect()
1315}
1316
1317fn rule_apt_recommends(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1318 instrs_of(instrs, "RUN")
1319 .into_iter()
1320 .filter(|i| {
1321 let a = &i.arguments;
1322 (a.contains("apt-get install") || a.contains("apt install"))
1323 && !a.contains("--no-install-recommends")
1324 })
1325 .map(|i| Finding {
1326 rule: "DF016",
1327 severity: Severity::Info,
1328 line: i.line,
1329 message: "apt-get install without --no-install-recommends installs extra packages"
1330 .to_string(),
1331 roast: "Installing without --no-install-recommends? apt is now installing packages \
1332 you didn't ask for, like a waiter who brings you a full bread basket when \
1333 you said you're gluten-free. `--no-install-recommends` is right there."
1334 .to_string(),
1335 })
1336 .collect()
1337}
1338
1339fn rule_yum_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1340 instrs_of(instrs, "RUN")
1341 .into_iter()
1342 .filter(|i| {
1343 let a = &i.arguments;
1344 (a.contains("yum install") || a.contains("dnf install"))
1345 && !a.contains("-y")
1346 && !a.contains("--assumeyes")
1347 })
1348 .map(|i| Finding {
1349 rule: "DF027",
1350 severity: Severity::Error,
1351 line: i.line,
1352 message: "yum/dnf install without -y flag will hang waiting for user input".to_string(),
1353 roast: "yum install without -y. Your build will hang indefinitely, \
1354 waiting for input in a non-interactive environment. \
1355 It's not coming. Add -y and move on."
1356 .to_string(),
1357 })
1358 .collect()
1359}
1360
1361fn rule_apt_get_update_alone(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1362 let mut findings = Vec::new();
1363 let mut prev_was_update = false;
1364 let mut update_line = 0;
1365 for i in instrs {
1366 if i.instruction == "RUN" {
1367 let a = &i.arguments;
1368 let has_update = a.contains("apt-get update") || a.contains("apt update");
1369 let has_install = a.contains("apt-get install") || a.contains("apt install");
1370 if has_update && !has_install {
1371 prev_was_update = true;
1372 update_line = i.line;
1373 } else if has_install && !has_update && prev_was_update {
1374 findings.push(Finding {
1375 rule: "DF028",
1376 severity: Severity::Warning,
1377 line: update_line,
1378 message: "apt-get update in a separate RUN from apt-get install causes cache poisoning".to_string(),
1379 roast: "Splitting `apt-get update` and `apt-get install` into separate RUN \
1380 layers is a classic mistake. Docker caches the update layer and \
1381 your install may use a stale index. Combine them with && or enjoy \
1382 mysterious 404 errors.".to_string(),
1383 });
1384 prev_was_update = false;
1385 } else {
1386 prev_was_update = false;
1387 }
1388 }
1389 }
1390 findings
1391}
1392
1393fn rule_apk_no_cache(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1394 vec![]
1396}
1397
1398fn rule_secrets_in_env(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1399 let secret_patterns = [
1400 "password",
1401 "passwd",
1402 "secret",
1403 "token",
1404 "api_key",
1405 "apikey",
1406 "private_key",
1407 "auth_token",
1408 "access_key",
1409 "secret_key",
1410 "db_pass",
1411 "database_password",
1412 ];
1413 let mut findings = Vec::new();
1414 for i in instrs_of(instrs, "ENV") {
1415 let lower = i.arguments.to_lowercase();
1416 for pat in &secret_patterns {
1417 if lower.contains(pat) {
1418 findings.push(Finding {
1419 rule: "DF013",
1420 severity: Severity::Error,
1421 line: i.line,
1422 message: format!("Potential secret in ENV variable (matched: '{}')", pat),
1423 roast: format!(
1424 "You put a '{}' in an ENV instruction. Congratulations — it's now \
1425 immortalized in your image layers, your registry, your CI logs, \
1426 and probably a security audit finding. Use Docker secrets or a vault.",
1427 pat
1428 ),
1429 });
1430 break;
1431 }
1432 }
1433 }
1434 findings
1435}
1436
1437fn rule_hardcoded_secrets(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1438 let re = Regex::new(r"(?i)(password|secret|token|key|passwd)\s*=\s*\S+").unwrap();
1439 let mut findings = Vec::new();
1440 for i in instrs
1441 .iter()
1442 .filter(|i| i.instruction == "ARG" || i.instruction == "ENV")
1443 {
1444 if let Some(cap) = re.find(&i.arguments) {
1445 let parts: Vec<&str> = cap.as_str().splitn(2, '=').collect();
1446 if parts.len() == 2 {
1447 let val = parts[1].trim();
1448 if !val.is_empty() && !val.starts_with('$') && val != "\"\"" && val != "''" {
1449 findings.push(Finding {
1450 rule: "DF014",
1451 severity: Severity::Error,
1452 line: i.line,
1453 message: "Hardcoded secret value detected in ARG/ENV".to_string(),
1454 roast: "A hardcoded secret! How delightfully naive. It's in your git \
1455 history forever now. Have fun rotating that. Maybe consider \
1456 build secrets or runtime injection next time?"
1457 .to_string(),
1458 });
1459 }
1460 }
1461 }
1462 }
1463 findings
1464}
1465
1466fn rule_curl_pipe_sh(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1467 let re = Regex::new(r"(curl|wget)[^|]*\|\s*(bash|sh|ash|zsh|fish)").unwrap();
1468 instrs_of(instrs, "RUN")
1469 .into_iter()
1470 .filter(|i| re.is_match(&i.arguments))
1471 .map(|i| Finding {
1472 rule: "DF021",
1473 severity: Severity::Error,
1474 line: i.line,
1475 message: "Piping remote script directly to shell (curl/wget | sh)".to_string(),
1476 roast: "curl | sh: the technical equivalent of 'hold my beer'. You're downloading \
1477 code from the internet and executing it blind, inside your container, \
1478 and shipping it to prod. Your threat model is vibes."
1479 .to_string(),
1480 })
1481 .collect()
1482}
1483
1484fn rule_apt_instead_of_apt_get(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1485 let re =
1486 Regex::new(r"\bapt\s+(install|remove|update|upgrade|list|search|show|purge)\b").unwrap();
1487 instrs_of(instrs, "RUN")
1488 .into_iter()
1489 .filter(|i| re.is_match(&i.arguments))
1490 .map(|i| Finding {
1491 rule: "DF059",
1492 severity: Severity::Warning,
1493 line: i.line,
1494 message:
1495 "apt used instead of apt-get — apt is an end-user tool, not suited for scripts"
1496 .to_string(),
1497 roast: "`apt` is designed for humans: it has progress bars, color output, and a \
1498 warning that says 'do not use in scripts'. You are in a script. \
1499 Use apt-get or apt-cache instead."
1500 .to_string(),
1501 })
1502 .collect()
1503}
1504
1505fn rule_useless_commands(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1506 let useless = [
1507 "ssh ",
1508 "vim ",
1509 "nano ",
1510 "emacs ",
1511 "shutdown",
1512 "reboot",
1513 "service ",
1514 "systemctl ",
1515 "ifconfig ",
1516 "iwconfig",
1517 "free ",
1518 "top ",
1519 "htop ",
1520 "mount ",
1521 "umount ",
1522 ];
1523 let mut findings = Vec::new();
1524 for i in instrs_of(instrs, "RUN") {
1525 for cmd in &useless {
1526 if i.arguments.contains(cmd) {
1527 findings.push(Finding {
1528 rule: "DF060",
1529 severity: Severity::Info,
1530 line: i.line,
1531 message: format!(
1532 "Command '{}' makes little sense inside a container",
1533 cmd.trim()
1534 ),
1535 roast: format!(
1536 "`{}` in a Dockerfile: you're running a command that assumes a full \
1537 interactive OS environment inside a container. It doesn't apply here. \
1538 Containers are not VMs.",
1539 cmd.trim()
1540 ),
1541 });
1542 break;
1543 }
1544 }
1545 }
1546 findings
1547}
1548
1549fn rule_from_platform_flag(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1550 instrs_of(instrs, "FROM")
1551 .into_iter()
1552 .filter(|i| i.arguments.contains("--platform"))
1553 .map(|i| Finding {
1554 rule: "DF061",
1555 severity: Severity::Warning,
1556 line: i.line,
1557 message: "FROM uses --platform flag — consider whether cross-platform targeting is intentional".to_string(),
1558 roast: "--platform in FROM forces a specific architecture. If you're building for \
1559 amd64 but deploying on arm64, your image will be slow or broken. \
1560 Make sure this is intentional and documented.".to_string(),
1561 })
1562 .collect()
1563}
1564
1565fn rule_env_self_reference(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1566 let re = Regex::new(r"(\w+)=\s*[\x22\x27]?\$\{?(\w+)\}?").unwrap();
1567 let mut findings = Vec::new();
1568 for i in instrs_of(instrs, "ENV") {
1569 for cap in re.captures_iter(&i.arguments) {
1570 let defined = &cap[1];
1571 let referenced = &cap[2];
1572 if defined == referenced {
1573 findings.push(Finding {
1574 rule: "DF062",
1575 severity: Severity::Error,
1576 line: i.line,
1577 message: format!(
1578 "ENV variable '{}' references itself in the same statement",
1579 defined
1580 ),
1581 roast: format!(
1582 "ENV {}=${{{}}} — you're defining a variable using itself. \
1583 It hasn't been set yet at this point in the same ENV instruction. \
1584 The result will be an empty string. Split it into two ENV statements.",
1585 defined, referenced
1586 ),
1587 });
1588 break;
1589 }
1590 }
1591 }
1592 findings
1593}
1594
1595fn rule_copy_relative_no_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1596 let mut stage_workdirs: std::collections::HashMap<String, bool> =
1597 std::collections::HashMap::new();
1598 let mut current_alias: Option<String> = None;
1599 let mut workdir_set = false;
1600 let mut findings = Vec::new();
1601 for i in instrs {
1602 if i.instruction == "FROM" {
1603 if let Some(from) = parse_from_arguments(&i.arguments) {
1604 workdir_set = stage_workdirs
1605 .get(&from.image.to_lowercase())
1606 .copied()
1607 .unwrap_or(false);
1608 current_alias = from.alias.map(str::to_lowercase);
1609 if let Some(alias) = ¤t_alias {
1610 stage_workdirs.insert(alias.clone(), workdir_set);
1611 }
1612 } else {
1613 workdir_set = false;
1614 current_alias = None;
1615 }
1616 } else if i.instruction == "WORKDIR" {
1617 workdir_set = true;
1618 if let Some(alias) = ¤t_alias {
1619 stage_workdirs.insert(alias.clone(), true);
1620 }
1621 } else if i.instruction == "COPY" {
1622 let args: Vec<&str> = i
1623 .arguments
1624 .split_whitespace()
1625 .filter(|t| !t.starts_with("--"))
1626 .collect();
1627 if let Some(dest) = args.last() {
1628 if !dest.starts_with('/') && !dest.starts_with('$') && !workdir_set {
1629 findings.push(Finding {
1630 rule: "DF063",
1631 severity: Severity::Warning,
1632 line: i.line,
1633 message: format!(
1634 "COPY to relative destination '{}' but no WORKDIR has been set",
1635 dest
1636 ),
1637 roast: format!(
1638 "COPY to '{}' with no WORKDIR set. Relative destinations depend on \
1639 the working directory, which defaults to /. \
1640 Set WORKDIR explicitly before using relative paths.",
1641 dest
1642 ),
1643 });
1644 }
1645 }
1646 }
1647 }
1648 findings
1649}
1650
1651fn rule_useradd_no_l(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1652 let re = Regex::new(r"\buseradd\b").unwrap();
1653 instrs_of(instrs, "RUN")
1654 .into_iter()
1655 .filter(|i| {
1656 re.is_match(&i.arguments)
1657 && !i.arguments.contains(" -l")
1658 && !i.arguments.contains("--no-log-init")
1659 })
1660 .map(|i| Finding {
1661 rule: "DF064",
1662 severity: Severity::Warning,
1663 line: i.line,
1664 message:
1665 "useradd without -l flag — high UIDs create oversized /var/log/lastlog entries"
1666 .to_string(),
1667 roast: "useradd without -l (--no-log-init): with a high UID, this creates a sparse \
1668 file in /var/log/lastlog that can balloon your image size by gigabytes. \
1669 Add -l or use --no-log-init."
1670 .to_string(),
1671 })
1672 .collect()
1673}
1674
1675fn rule_copy_archive_use_add(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1676 const ARCHIVE_EXTS: &[&str] = &[".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar"];
1677 instrs_of(instrs, "COPY")
1678 .into_iter()
1679 .filter(|i| {
1680 if i.arguments.contains("--from=") || i.arguments.contains("--from =") {
1682 return false;
1683 }
1684 let sources: Vec<&str> = i
1685 .arguments
1686 .split_whitespace()
1687 .filter(|t| !t.starts_with("--"))
1688 .collect();
1689 if sources.len() < 2 {
1691 return false;
1692 }
1693 sources[..sources.len() - 1]
1695 .iter()
1696 .any(|s| ARCHIVE_EXTS.iter().any(|ext| s.ends_with(ext)))
1697 })
1698 .map(|i| Finding {
1699 rule: "DF067",
1700 severity: Severity::Info,
1701 line: i.line,
1702 message: "COPY of archive file — consider ADD which auto-extracts local tarballs"
1703 .to_string(),
1704 roast: "COPY drops the compressed archive as-is; you'll need a separate \
1705 RUN tar -xzf layer to unpack it. ADD auto-extracts local tarballs into \
1706 the destination directory and saves you the extra layer. \
1707 Yes, this is the one situation where ADD is actually the right choice."
1708 .to_string(),
1709 })
1710 .collect()
1711}
1712
1713fn rule_onbuild_forbidden(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1714 const FORBIDDEN: &[&str] = &["FROM", "ONBUILD", "MAINTAINER"];
1715 let mut findings = Vec::new();
1716 for i in instrs_of(instrs, "ONBUILD") {
1717 let triggered = i
1718 .arguments
1719 .split_whitespace()
1720 .next()
1721 .unwrap_or("")
1722 .to_uppercase();
1723 if FORBIDDEN.contains(&triggered.as_str()) {
1724 findings.push(Finding {
1725 rule: "DF068",
1726 severity: Severity::Error,
1727 line: i.line,
1728 message: format!(
1729 "ONBUILD {} is forbidden — {} cannot be used as an ONBUILD trigger",
1730 triggered, triggered
1731 ),
1732 roast: format!(
1733 "ONBUILD {} is explicitly prohibited by Docker. \
1734 FROM would create a recursive inheritance loop, \
1735 ONBUILD ONBUILD is a depth-2 trap nobody asked for, \
1736 and MAINTAINER is deprecated everywhere, including here. \
1737 This fails at build time.",
1738 triggered
1739 ),
1740 });
1741 }
1742 }
1743 findings
1744}
1745
1746fn rule_bash_syntax_no_shell(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1747 if has_instr(instrs, "SHELL") {
1749 return vec![];
1750 }
1751 const BASH_ONLY: &[(&str, &str)] = &[
1753 ("[[ ", "double-bracket conditional"),
1754 ("source ", "source builtin (use '.' in POSIX sh)"),
1755 ("declare ", "declare builtin"),
1756 ("mapfile ", "mapfile builtin"),
1757 ("readarray ", "readarray builtin"),
1758 ("${!", "indirect variable expansion"),
1759 ];
1760 let mut findings = Vec::new();
1761 for i in instrs_of(instrs, "RUN") {
1762 for (pattern, label) in BASH_ONLY {
1763 if i.arguments.contains(pattern) {
1764 findings.push(Finding {
1765 rule: "DF066",
1766 severity: Severity::Warning,
1767 line: i.line,
1768 message: format!(
1769 "RUN uses bash-specific syntax ({}) but no SHELL instruction is set",
1770 label
1771 ),
1772 roast: format!(
1773 "'{}' is bash syntax. The default shell is /bin/sh, which on Alpine, \
1774 Debian-slim, and distroless is NOT bash. Add \
1775 `SHELL [\"/bin/bash\", \"-c\"]` before this RUN or your build \
1776 will fail in ways that are confusing to debug.",
1777 pattern.trim()
1778 ),
1779 });
1780 break;
1781 }
1782 }
1783 }
1784 findings
1785}
1786
1787fn rule_untrusted_registry(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1788 const TRUSTED: &[&str] = &[
1789 "docker.io",
1790 "registry-1.docker.io",
1791 "ghcr.io",
1792 "gcr.io",
1793 "quay.io",
1794 "mcr.microsoft.com",
1795 "registry.access.redhat.com",
1796 "public.ecr.aws",
1797 "registry.k8s.io",
1798 "k8s.gcr.io",
1799 ];
1800 let mut findings = Vec::new();
1801 for i in instrs_of(instrs, "FROM") {
1802 let image = match i
1804 .arguments
1805 .split_whitespace()
1806 .find(|t| !t.starts_with("--"))
1807 {
1808 Some(img) => img,
1809 None => continue,
1810 };
1811 if image.eq_ignore_ascii_case("scratch") {
1812 continue;
1813 }
1814 if !image.contains('/') {
1818 continue;
1819 }
1820 let first = image
1821 .split('@')
1822 .next()
1823 .unwrap_or(image)
1824 .split('/')
1825 .next()
1826 .unwrap_or("");
1827 if (first.contains('.') || first.contains(':') || first == "localhost")
1828 && !TRUSTED.iter().any(|t| first.eq_ignore_ascii_case(t))
1829 {
1830 findings.push(Finding {
1831 rule: "DF065",
1832 severity: Severity::Warning,
1833 line: i.line,
1834 message: format!("FROM pulls from unrecognised registry '{}'", first),
1835 roast: format!(
1836 "Pulling base images from '{}' — a registry you don't hear about at \
1837 KubeCon. Supply-chain attacks love Dockerfiles that blindly trust \
1838 random registries. Verify this is intentional and pin to a digest.",
1839 first
1840 ),
1841 });
1842 }
1843 }
1844 findings
1845}
1846
1847fn rule_pipefail_missing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1848 instrs_of(instrs, "RUN")
1849 .into_iter()
1850 .filter(|i| {
1851 let a = &i.arguments;
1852 a.contains(" | ")
1854 && !a.contains("pipefail")
1855 && !a.contains("set -o pipefail")
1856 && !a.contains("set -eo pipefail")
1857 && !a.contains("set -euo pipefail")
1858 && !a.trim_start().starts_with("set ")
1860 })
1861 .map(|i| Finding {
1862 rule: "DF057",
1863 severity: Severity::Warning,
1864 line: i.line,
1865 message:
1866 "RUN with pipe but no pipefail — failed commands in the pipe are silently ignored"
1867 .to_string(),
1868 roast: "A pipe in RUN without `set -o pipefail`. If the left side of that pipe fails, \
1869 bash shrugs and moves on. The exit code is whatever the last command returns. \
1870 Add `set -o pipefail` at the start of the RUN."
1871 .to_string(),
1872 })
1873 .collect()
1874}
1875
1876fn rule_wget_and_curl(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1877 let uses_wget = instrs_of(instrs, "RUN")
1878 .iter()
1879 .any(|i| i.arguments.contains("wget "));
1880 let uses_curl = instrs_of(instrs, "RUN")
1881 .iter()
1882 .any(|i| i.arguments.contains("curl "));
1883 if uses_wget && uses_curl {
1884 return vec![Finding {
1885 rule: "DF058",
1886 severity: Severity::Warning,
1887 line: 0,
1888 message: "Both wget and curl are used — pick one and use it consistently".to_string(),
1889 roast: "You're using both wget and curl in the same Dockerfile. They do the same \
1890 thing. Pick one. Commit to it. Your image doesn't need two download tools \
1891 any more than it needs two fire extinguishers."
1892 .to_string(),
1893 }];
1894 }
1895 vec![]
1896}
1897
1898fn rule_yarn_cache_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1899 instrs_of(instrs, "RUN")
1900 .into_iter()
1901 .filter(|i| {
1902 let a = &i.arguments;
1903 (a.contains("yarn install") || a.contains("yarn add"))
1904 && !a.contains("yarn cache clean")
1905 })
1906 .map(|i| Finding {
1907 rule: "DF055",
1908 severity: Severity::Info,
1909 line: i.line,
1910 message: "yarn install without yarn cache clean — yarn cache is left in the image"
1911 .to_string(),
1912 roast: "yarn install without cleaning the cache. Yarn dutifully stores downloaded \
1913 packages in a cache that you are now shipping to production. \
1914 Add `&& yarn cache clean` after install."
1915 .to_string(),
1916 })
1917 .collect()
1918}
1919
1920fn rule_wget_no_progress(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1921 instrs_of(instrs, "RUN")
1922 .into_iter()
1923 .filter(|i| {
1924 let a = &i.arguments;
1925 a.contains("wget ")
1926 && !a.contains("--progress")
1927 && !a.contains("-q")
1928 && !a.contains("--quiet")
1929 && (a.contains("http://") || a.contains("https://") || a.contains("ftp://"))
1930 })
1931 .map(|i| Finding {
1932 rule: "DF056",
1933 severity: Severity::Info,
1934 line: i.line,
1935 message: "wget without --progress flag produces verbose progress output in build logs"
1936 .to_string(),
1937 roast: "wget without --progress=dot:giga will spam your build logs with a progress \
1938 bar that looks great locally and fills 50MB of CI log storage. \
1939 Use --progress=dot:giga or -q to stay quiet."
1940 .to_string(),
1941 })
1942 .collect()
1943}
1944
1945fn rule_pip_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1946 instrs_of(instrs, "RUN")
1947 .into_iter()
1948 .filter(|i| {
1949 let a = &i.arguments;
1950 (a.contains("pip install") || a.contains("pip3 install"))
1951 && !a.contains("-r ")
1952 && !a.contains("--requirement")
1953 && !a.contains("==")
1954 && !a.contains(">=")
1955 && !a.contains("<=")
1956 && !a.contains("~=")
1957 && !a.contains(".txt")
1958 })
1959 .map(|i| Finding {
1960 rule: "DF051",
1961 severity: Severity::Warning,
1962 line: i.line,
1963 message:
1964 "pip install without version pinning — use package==version for reproducibility"
1965 .to_string(),
1966 roast: "pip install with no version pins. Every build pulls 'latest' and \
1967 one day something breaks and you spend three hours bisecting which \
1968 transitive dependency changed. Use package==version."
1969 .to_string(),
1970 })
1971 .collect()
1972}
1973
1974fn rule_apk_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1975 instrs_of(instrs, "RUN")
1976 .into_iter()
1977 .filter(|i| {
1978 let a = &i.arguments;
1979 if !a.contains("apk add") {
1980 return false;
1981 }
1982 let after_add = match a.find("apk add") {
1984 Some(pos) => &a[pos + 7..],
1985 None => return false,
1986 };
1987 after_add
1988 .split_whitespace()
1989 .filter(|t| !t.starts_with('-') && !t.is_empty())
1990 .any(|t| !t.contains('=') && !t.contains('>') && !t.contains('<'))
1991 })
1992 .map(|i| Finding {
1993 rule: "DF052",
1994 severity: Severity::Warning,
1995 line: i.line,
1996 message: "apk add without version pinning — use package=version for reproducibility"
1997 .to_string(),
1998 roast: "apk add with no version? You chose Alpine to be minimal and fast, then \
1999 immediately added unpinned packages. Your builds are non-deterministic \
2000 by design now. Use package=version."
2001 .to_string(),
2002 })
2003 .collect()
2004}
2005
2006fn rule_gem_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2007 instrs_of(instrs, "RUN")
2008 .into_iter()
2009 .filter(|i| {
2010 let a = &i.arguments;
2011 a.contains("gem install")
2012 && !a.contains(" -v ")
2013 && !a.contains("--version")
2014 && !a.contains(':')
2015 })
2016 .map(|i| Finding {
2017 rule: "DF053",
2018 severity: Severity::Warning,
2019 line: i.line,
2020 message: "gem install without version pinning — use gem install <gem>:<version>"
2021 .to_string(),
2022 roast: "gem install with no version. RubyGems will grab whatever's latest today. \
2023 Next week it grabs something else. Your builds are a dice roll. \
2024 Use gem install name:version."
2025 .to_string(),
2026 })
2027 .collect()
2028}
2029
2030fn rule_go_install_version(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2031 instrs_of(instrs, "RUN")
2032 .into_iter()
2033 .filter(|i| {
2034 let a = &i.arguments;
2035 a.contains("go install") && !a.contains("@latest") && !a.contains('@')
2036 })
2037 .map(|i| Finding {
2038 rule: "DF054",
2039 severity: Severity::Warning,
2040 line: i.line,
2041 message: "go install without @version — use go install package@version".to_string(),
2042 roast: "go install without @version. The Go toolchain requires a version suffix \
2043 in module-aware mode. Use `go install pkg@v1.2.3` or at minimum `@latest` \
2044 if you enjoy living dangerously."
2045 .to_string(),
2046 })
2047 .collect()
2048}
2049
2050fn rule_copy_multi_arg_slash(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2051 instrs_of(instrs, "COPY")
2052 .into_iter()
2053 .filter(|i| {
2054 let args: Vec<&str> = i
2055 .arguments
2056 .split_whitespace()
2057 .filter(|t| !t.starts_with("--"))
2058 .collect();
2059 if args.len() > 2 {
2060 let dest = args.last().unwrap_or(&"");
2061 !dest.ends_with('/')
2062 } else {
2063 false
2064 }
2065 })
2066 .map(|i| Finding {
2067 rule: "DF048",
2068 severity: Severity::Error,
2069 line: i.line,
2070 message: "COPY with multiple sources requires the destination to end with /"
2071 .to_string(),
2072 roast: "COPY with multiple sources and a destination that doesn't end with /? \
2073 Docker will complain. Or worse, silently do something weird. \
2074 Add a trailing slash to the destination."
2075 .to_string(),
2076 })
2077 .collect()
2078}
2079
2080fn rule_copy_from_undefined_stage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2081 let mut defined_aliases: Vec<String> = Vec::new();
2082 let mut findings = Vec::new();
2083 let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
2084 for i in instrs {
2085 if i.instruction == "FROM" {
2086 if let Some(alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias) {
2087 defined_aliases.push(alias.to_lowercase());
2088 }
2089 } else if i.instruction == "COPY" {
2090 if let Some(cap) = re_from.captures(&i.arguments) {
2091 let from_ref = cap[1].to_lowercase();
2092 if from_ref.parse::<usize>().is_ok() {
2094 continue;
2095 }
2096 if !defined_aliases.contains(&from_ref) {
2097 findings.push(Finding {
2098 rule: "DF049",
2099 severity: Severity::Warning,
2100 line: i.line,
2101 message: format!(
2102 "COPY --from={} references an undefined build stage",
2103 &cap[1]
2104 ),
2105 roast: format!(
2106 "COPY --from={} and there's no FROM ... AS {} anywhere above. \
2107 Copying from thin air. Docker will reject this.",
2108 &cap[1], &cap[1]
2109 ),
2110 });
2111 }
2112 }
2113 }
2114 }
2115 findings
2116}
2117
2118fn rule_copy_from_self(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2119 let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
2120 let mut current_alias: Option<String> = None;
2121 let mut findings = Vec::new();
2122 for i in instrs {
2123 if i.instruction == "FROM" {
2124 current_alias = parse_from_arguments(&i.arguments)
2125 .and_then(|from| from.alias)
2126 .map(str::to_lowercase);
2127 } else if i.instruction == "COPY" {
2128 if let Some(cap) = re_from.captures(&i.arguments) {
2129 let from_ref = cap[1].to_lowercase();
2130 if let Some(ref alias) = current_alias {
2131 if &from_ref == alias {
2132 findings.push(Finding {
2133 rule: "DF050",
2134 severity: Severity::Error,
2135 line: i.line,
2136 message: format!(
2137 "COPY --from={} references the current build stage — circular dependency",
2138 &cap[1]
2139 ),
2140 roast: format!(
2141 "COPY --from={} inside the same stage named {}. \
2142 That's a circular reference. Docker cannot copy from itself. \
2143 This will fail at build time.",
2144 &cap[1], &cap[1]
2145 ),
2146 });
2147 }
2148 }
2149 }
2150 }
2151 }
2152 findings
2153}
2154
2155fn rule_dnf_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2156 instrs_of(instrs, "RUN")
2157 .into_iter()
2158 .filter(|i| {
2159 let a = &i.arguments;
2160 a.contains("dnf install") && !a.contains("dnf clean all") && !a.contains("dnf clean")
2161 })
2162 .map(|i| Finding {
2163 rule: "DF046",
2164 severity: Severity::Warning,
2165 line: i.line,
2166 message: "dnf clean all missing after dnf install — RPM cache bloats the image"
2167 .to_string(),
2168 roast: "dnf install without `dnf clean all` afterwards? You're shipping RPM cache \
2169 metadata to production. That's not a feature. Add `&& dnf clean all`."
2170 .to_string(),
2171 })
2172 .collect()
2173}
2174
2175fn rule_yum_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2176 instrs_of(instrs, "RUN")
2177 .into_iter()
2178 .filter(|i| {
2179 let a = &i.arguments;
2180 a.contains("yum install") && !a.contains("yum clean all") && !a.contains("yum clean")
2181 })
2182 .map(|i| Finding {
2183 rule: "DF047",
2184 severity: Severity::Warning,
2185 line: i.line,
2186 message: "yum clean all missing after yum install — cache stays in the image"
2187 .to_string(),
2188 roast: "yum install without cleanup is just permanently housing the package cache in \
2189 your image. Every MB of yum cache is a MB of shame in your registry."
2190 .to_string(),
2191 })
2192 .collect()
2193}
2194
2195fn rule_zypper_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2196 instrs_of(instrs, "RUN")
2197 .into_iter()
2198 .filter(|i| {
2199 let a = &i.arguments;
2200 (a.contains("zypper install") || a.contains("zypper in "))
2201 && !a.contains("-y")
2202 && !a.contains("--non-interactive")
2203 && !a.contains(" -n ")
2204 && !a.contains(" -n\n")
2205 && !a.starts_with("-n ")
2206 })
2207 .map(|i| Finding {
2208 rule: "DF043",
2209 severity: Severity::Warning,
2210 line: i.line,
2211 message: "zypper install without non-interactive flag (-y) will hang in a build"
2212 .to_string(),
2213 roast: "zypper install without -y in a container build? It'll wait for input that \
2214 will never arrive, like a chatbot asking for emotional validation."
2215 .to_string(),
2216 })
2217 .collect()
2218}
2219
2220fn rule_zypper_dist_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2221 instrs_of(instrs, "RUN")
2222 .into_iter()
2223 .filter(|i| {
2224 i.arguments.contains("zypper dist-upgrade") || i.arguments.contains("zypper dup")
2225 })
2226 .map(|i| Finding {
2227 rule: "DF044",
2228 severity: Severity::Warning,
2229 line: i.line,
2230 message:
2231 "zypper dist-upgrade upgrades all packages unpredictably — avoid in Dockerfiles"
2232 .to_string(),
2233 roast: "zypper dist-upgrade: the 'nuke everything and hope for the best' approach to \
2234 package management. Your image will be different every single build. Congrats."
2235 .to_string(),
2236 })
2237 .collect()
2238}
2239
2240fn rule_zypper_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2241 instrs_of(instrs, "RUN")
2242 .into_iter()
2243 .filter(|i| {
2244 let a = &i.arguments;
2245 (a.contains("zypper install") || a.contains("zypper in "))
2246 && !a.contains("zypper clean")
2247 && !a.contains("zypper cc")
2248 })
2249 .map(|i| Finding {
2250 rule: "DF045",
2251 severity: Severity::Info,
2252 line: i.line,
2253 message: "zypper cache not cleaned after install — adds unnecessary image bloat"
2254 .to_string(),
2255 roast:
2256 "zypper install without `zypper clean --all` afterwards. You're hoarding package \
2257 metadata in your image. Clean it up."
2258 .to_string(),
2259 })
2260 .collect()
2261}
2262
2263fn rule_expose_port_range(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2264 let mut findings = Vec::new();
2265 for i in instrs_of(instrs, "EXPOSE") {
2266 for port_spec in i.arguments.split_whitespace() {
2267 let port_str = port_spec.split('/').next().unwrap_or(port_spec);
2268 if let Ok(port) = port_str.parse::<u32>() {
2269 if port > 65535 {
2270 findings.push(Finding {
2271 rule: "DF040",
2272 severity: Severity::Error,
2273 line: i.line,
2274 message: format!("EXPOSE port {} is out of valid range (0-65535)", port),
2275 roast: format!(
2276 "Port {}? That's not a port, that's a zip code. \
2277 Valid UNIX ports are 0-65535. Pick a real one.",
2278 port
2279 ),
2280 });
2281 }
2282 }
2283 }
2284 }
2285 findings
2286}
2287
2288fn rule_multiple_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2289 let checks: Vec<_> = instrs_of(instrs, "HEALTHCHECK");
2290 if checks.len() <= 1 {
2291 return vec![];
2292 }
2293 checks[1..]
2294 .iter()
2295 .map(|i| Finding {
2296 rule: "DF041",
2297 severity: Severity::Error,
2298 line: i.line,
2299 message: "Multiple HEALTHCHECK instructions — only the last one applies".to_string(),
2300 roast: "Multiple HEALTHCHECKs but only the last one counts. The earlier ones are \
2301 haunting your image for no reason. One health check, one truth."
2302 .to_string(),
2303 })
2304 .collect()
2305}
2306
2307fn rule_unique_stage_aliases(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2308 let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2309 let mut findings = Vec::new();
2310 for i in instrs_of(instrs, "FROM") {
2311 if let Some(original_alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias)
2312 {
2313 let alias = original_alias.to_lowercase();
2314 if let Some(&prev_line) = seen.get(&alias) {
2315 findings.push(Finding {
2316 rule: "DF042",
2317 severity: Severity::Error,
2318 line: i.line,
2319 message: format!(
2320 "FROM alias '{}' is already defined on line {}",
2321 original_alias, prev_line
2322 ),
2323 roast: format!(
2324 "Two stages named '{}'. Docker uses the last one; the first is dead code. \
2325 Give your stages unique names.",
2326 original_alias
2327 ),
2328 });
2329 } else {
2330 seen.insert(alias, i.line);
2331 }
2332 }
2333 }
2334 findings
2335}
2336
2337fn rule_invalid_instruction_order(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2338 if instrs.is_empty() {
2339 return vec![];
2340 }
2341 let first = &instrs[0];
2342 if first.instruction != "FROM" && first.instruction != "ARG" {
2343 return vec![Finding {
2344 rule: "DF037",
2345 severity: Severity::Error,
2346 line: first.line,
2347 message: format!(
2348 "'{}' before FROM — Dockerfile must begin with FROM, ARG, or a comment",
2349 first.instruction
2350 ),
2351 roast: "Your Dockerfile doesn't start with FROM. That's like starting a recipe with \
2352 'season to taste' before listing any ingredients. Docker is confused. So am I."
2353 .to_string(),
2354 }];
2355 }
2356 vec![]
2357}
2358
2359fn rule_multiple_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2360 let cmds: Vec<_> = instrs_of(instrs, "CMD");
2361 if cmds.len() <= 1 {
2362 return vec![];
2363 }
2364 cmds[1..]
2365 .iter()
2366 .map(|i| Finding {
2367 rule: "DF038",
2368 severity: Severity::Warning,
2369 line: i.line,
2370 message: "Multiple CMD instructions — only the last one takes effect".to_string(),
2371 roast:
2372 "Multiple CMDs and only the last one counts. The others are ghosts haunting your \
2373 Dockerfile, contributing nothing except confusion. Pick one."
2374 .to_string(),
2375 })
2376 .collect()
2377}
2378
2379fn rule_multiple_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2380 let eps: Vec<_> = instrs_of(instrs, "ENTRYPOINT");
2381 if eps.len() <= 1 {
2382 return vec![];
2383 }
2384 eps[1..]
2385 .iter()
2386 .map(|i| Finding {
2387 rule: "DF039",
2388 severity: Severity::Error,
2389 line: i.line,
2390 message: "Multiple ENTRYPOINT instructions — only the last one takes effect"
2391 .to_string(),
2392 roast: "Two ENTRYPOINTs. Bold. Only the last one runs; the first is just expensive \
2393 furniture. Delete it."
2394 .to_string(),
2395 })
2396 .collect()
2397}
2398
2399fn rule_no_user_instruction(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2400 if has_instr(instrs, "USER") {
2401 return vec![];
2402 }
2403 if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
2404 return vec![];
2405 }
2406 vec![Finding {
2407 rule: "DF020",
2408 severity: Severity::Warning,
2409 line: 0,
2410 message: "No USER instruction found — container will run as root by default".to_string(),
2411 roast: "No USER set? Bold strategy. Running everything as root in prod is a great way \
2412 to ensure job security — for your incident response team."
2413 .to_string(),
2414 }]
2415}
2416
2417fn rule_apt_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2418 let re = Regex::new(r"\bapt(-get)?\s+(dist-upgrade|upgrade)\b").unwrap();
2419 instrs_of(instrs, "RUN")
2420 .into_iter()
2421 .filter(|i| re.is_match(&i.arguments))
2422 .map(|i| Finding {
2423 rule: "DF069",
2424 severity: Severity::Warning,
2425 line: i.line,
2426 message: "apt-get upgrade/dist-upgrade makes builds non-reproducible".to_string(),
2427 roast:
2428 "apt-get upgrade: 'let's upgrade everything and see what breaks in six months'. \
2429 Your image will be different every time you build it. \
2430 Pin the packages you actually need instead of upgrading everything blindly."
2431 .to_string(),
2432 })
2433 .collect()
2434}
2435
2436fn rule_copy_before_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2437 const PKG_CMDS: &[&str] = &[
2438 "npm install",
2439 "npm ci",
2440 "pip install",
2441 "pip3 install",
2442 "yarn install",
2443 "yarn add",
2444 "bundle install",
2445 "composer install",
2446 "pnpm install",
2447 "bun install",
2448 ];
2449 let mut findings = Vec::new();
2450 let mut broad_copy_line: Option<usize> = None;
2451
2452 for i in instrs {
2453 match i.instruction.as_str() {
2454 "FROM" => {
2455 broad_copy_line = None;
2456 }
2457 "COPY" => {
2458 let tokens: Vec<&str> = i
2459 .arguments
2460 .split_whitespace()
2461 .filter(|t| !t.starts_with("--"))
2462 .collect();
2463 if tokens.len() >= 2 && (tokens[0] == "." || tokens[0].ends_with("/.")) {
2464 broad_copy_line = Some(i.line);
2465 }
2466 }
2467 "RUN" => {
2468 if let Some(copy_line) = broad_copy_line {
2469 if PKG_CMDS.iter().any(|cmd| i.arguments.contains(cmd)) {
2470 findings.push(Finding {
2471 rule: "DF070",
2472 severity: Severity::Warning,
2473 line: copy_line,
2474 message: "COPY . before package install — invalidates Docker layer cache on every source change".to_string(),
2475 roast: "COPY . . before npm/pip install means every code change rebuilds \
2476 dependencies from scratch. Copy just the manifest first \
2477 (e.g. COPY package.json ./), run the install, then COPY . . — \
2478 now the install layer is cached between source changes.".to_string(),
2479 });
2480 broad_copy_line = None;
2481 }
2482 }
2483 }
2484 _ => {}
2485 }
2486 }
2487 findings
2488}