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