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