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 if base.contains('$') {
820 if let Some(alias) = from.alias {
821 stage_aliases.insert(alias.to_lowercase());
822 }
823 continue;
824 }
825 let is_previous_stage = stage_aliases.contains(&base.to_lowercase());
826 if !is_previous_stage
827 && !base.eq_ignore_ascii_case("scratch")
828 && (base.ends_with(":latest") || (!base.contains(':') && !base.contains('@')))
829 {
830 findings.push(Finding {
831 column: 0,
832 end_line: 0,
833 end_column: 0,
834 rule: "DF001".into(),
835 severity: Severity::Warning,
836 line: instruction.line,
837 message: format!("'{}' uses an unpinned image tag", base),
838 roast: "Pinning to 'latest' is like ordering 'whatever' at a restaurant and then \
839 complaining when your image breaks in prod. Use a real tag."
840 .to_string(),
841 });
842 }
843 if let Some(alias) = from.alias {
844 stage_aliases.insert(alias.to_lowercase());
845 }
846 }
847
848 findings
849}
850
851fn rule_running_as_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
852 let mut findings = Vec::new();
853 let mut effective_user: Option<&Instruction> = None;
854 let mut report_root_user = |user: Option<&Instruction>| {
855 if let Some(u) = user.filter(|user| is_root_user(&user.arguments)) {
856 findings.push(Finding {
857 column: 0,
858 end_line: 0,
859 end_column: 0,
860 rule: "DF002".into(),
861 severity: Severity::Error,
862 line: u.line,
863 message: "Container is explicitly set to run as root".to_string(),
864 roast: "Congratulations, you're running as root. Your security team is crying, \
865 your CISO is drafting a strongly-worded email, and a hacker somewhere \
866 just smiled."
867 .to_string(),
868 });
869 }
870 };
871 for instruction in instrs {
872 match instruction.instruction.as_str() {
873 "FROM" => {
874 report_root_user(effective_user);
875 effective_user = None;
876 }
877 "USER" => effective_user = Some(instruction),
878 _ => {}
879 }
880 }
881 report_root_user(effective_user);
882 findings
883}
884
885fn is_root_user(value: &str) -> bool {
886 matches!(value.trim().to_lowercase().as_str(), "root" | "0" | "0:0" | "root:root")
887}
888
889fn rule_no_multistage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
890 let from_count = instrs_of(instrs, "FROM").len();
891 if from_count > 1 {
892 return vec![];
893 }
894 let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
895 Some(f) => f,
896 None => return vec![],
897 };
898 let build_images = [
899 "golang", "node", "rust", "maven", "gradle", "openjdk", "python", "dotnet", "gcc",
900 ];
901 let img = first_from.arguments.to_lowercase();
902 if build_images.iter().any(|b| img.contains(b)) {
903 return vec![Finding {
904 column: 0,
905 end_line: 0,
906 end_column: 0,
907 rule: "DF011".into(),
908 severity: Severity::Warning,
909 line: first_from.line,
910 message: "Single-stage build with a heavy build image — consider multi-stage builds"
911 .to_string(),
912 roast: "Shipping your entire build toolchain to production? Your 2GB Go image is \
913 basically a free gift to anyone who gets shell access. Multi-stage builds \
914 exist. They're fantastic. Use them."
915 .to_string(),
916 }];
917 }
918 vec![]
919}
920
921fn rule_many_run_layers(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
922 let mut findings = Vec::new();
923 let mut consecutive = 0usize;
924 let mut start_line = 0usize;
925 for i in instrs {
926 if i.instruction == "RUN" {
927 if consecutive == 0 {
928 start_line = i.line;
929 }
930 consecutive += 1;
931 } else if i.instruction == "FROM" {
932 consecutive = 0;
933 } else if consecutive > 0 {
934 if consecutive >= 4 {
935 findings.push(Finding {
936 column: 0,
937 end_line: 0,
938 end_column: 0,
939 rule: "DF003".into(),
940 severity: Severity::Warning,
941 line: start_line,
942 message: format!(
943 "{} consecutive RUN instructions could be merged into one",
944 consecutive
945 ),
946 roast: format!(
947 "{} separate RUN layers? Your image has more layers than a mid-2000s emo \
948 band. Combine them with && and save everyone's bandwidth.",
949 consecutive
950 ),
951 });
952 }
953 consecutive = 0;
954 }
955 }
956 if consecutive >= 4 {
957 findings.push(Finding {
958 column: 0,
959 end_line: 0,
960 end_column: 0,
961 rule: "DF003".into(),
962 severity: Severity::Warning,
963 line: start_line,
964 message: format!("{} consecutive RUN instructions could be merged into one", consecutive),
965 roast: format!(
966 "{} separate RUN layers? Your image is basically an onion — except nobody's \
967 crying because it's beautiful; they're crying because it takes 10 minutes to pull.", consecutive
968 ),
969 });
970 }
971 findings
972}
973
974fn rule_add_instead_of_copy(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
975 instrs_of(instrs, "ADD")
976 .into_iter()
977 .filter(|i| {
978 let source = match i
980 .arguments
981 .split_whitespace()
982 .find(|t| !t.starts_with("--"))
983 {
984 Some(s) => s,
985 None => return false,
986 };
987 let is_url = source.contains("://");
988 let is_archive = source.ends_with(".tar.gz")
989 || source.ends_with(".tgz")
990 || source.ends_with(".tar.xz")
991 || source.ends_with(".tar.bz2")
992 || source.ends_with(".tar");
993 !is_url && !is_archive
994 })
995 .map(|i| Finding {
996 column: 0,
997 end_line: 0,
998 end_column: 0,
999 rule: "DF006".into(),
1000 severity: Severity::Warning,
1001 line: i.line,
1002 message: "ADD used for local file — prefer COPY".to_string(),
1003 roast: "Using ADD to copy local files is like taking a helicopter to cross the \
1004 street. COPY exists, it's right there, it's boring and correct — which is \
1005 everything you want in infrastructure."
1006 .to_string(),
1007 })
1008 .collect()
1009}
1010
1011fn rule_copy_all(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1012 instrs_of(instrs, "COPY")
1013 .into_iter()
1014 .filter(|i| {
1015 let a = i.arguments.trim();
1016 a.starts_with(". ") || a == "."
1017 })
1018 .map(|i| Finding {
1019 column: 0,
1020 end_line: 0,
1021 end_column: 0,
1022 rule: "DF007".into(),
1023 severity: Severity::Warning,
1024 line: i.line,
1025 message: "COPY . copies the entire build context — consider a .dockerignore file"
1026 .to_string(),
1027 roast: "COPY . — dumping your entire project including node_modules, .git history, \
1028 and that .env file with the production database password into the image. \
1029 Bold. Reckless. Very DevOps of you."
1030 .to_string(),
1031 })
1032 .collect()
1033}
1034
1035fn rule_cd_instead_of_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1036 let re = Regex::new(r"\bcd\s+[^\s;|&]+").unwrap();
1037 instrs_of(instrs, "RUN")
1038 .into_iter()
1039 .filter(|i| re.is_match(&i.arguments))
1040 .map(|i| Finding {
1041 column: 0,
1042 end_line: 0,
1043 end_column: 0,
1044 rule: "DF008".into(),
1045 severity: Severity::Info,
1046 line: i.line,
1047 message: "Using 'cd' in RUN — prefer WORKDIR instruction".to_string(),
1048 roast: "`cd` in a RUN instruction: not wrong, but every new RUN starts fresh anyway, \
1049 so you're cosplaying as a shell script when you should be writing a Dockerfile. \
1050 WORKDIR is your friend.".to_string(),
1051 })
1052 .collect()
1053}
1054
1055fn rule_relative_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1056 instrs_of(instrs, "WORKDIR")
1057 .into_iter()
1058 .filter(|i| {
1059 let path = i.arguments.trim().trim_matches(['\'', '"']);
1060 !path.starts_with('/') && !path.starts_with('$')
1061 })
1062 .map(|i| Finding {
1063 column: 0,
1064 end_line: 0,
1065 end_column: 0,
1066 rule: "DF009".into(),
1067 severity: Severity::Warning,
1068 line: i.line,
1069 message: format!(
1070 "WORKDIR '{}' is relative — use an absolute path",
1071 i.arguments.trim()
1072 ),
1073 roast: "A relative WORKDIR? You're setting your working directory relative to... \
1074 what, exactly? Hope? Dreams? Use an absolute path like a grown-up."
1075 .to_string(),
1076 })
1077 .collect()
1078}
1079
1080fn rule_sudo_usage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1081 let re = Regex::new(r"\bsudo\b").unwrap();
1082 instrs_of(instrs, "RUN")
1083 .into_iter()
1084 .filter(|i| re.is_match(&i.arguments))
1085 .map(|i| Finding {
1086 column: 0,
1087 end_line: 0,
1088 end_column: 0,
1089 rule: "DF010".into(),
1090 severity: Severity::Warning,
1091 line: i.line,
1092 message: "sudo used inside a container — likely unnecessary".to_string(),
1093 roast: "sudo inside a Docker container? You're already root (probably). sudo is \
1094 just a formality at this point, like putting a 'Wet Floor' sign in the ocean."
1095 .to_string(),
1096 })
1097 .collect()
1098}
1099
1100fn rule_no_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1101 if has_instr(instrs, "HEALTHCHECK") {
1102 return vec![];
1103 }
1104 if !has_instr(instrs, "EXPOSE") && !has_instr(instrs, "CMD") {
1105 return vec![];
1106 }
1107 vec![Finding {
1108 column: 0,
1109 end_line: 0,
1110 end_column: 0,
1111 rule: "DF012".into(),
1112 severity: Severity::Info,
1113 line: 0,
1114 message: "No HEALTHCHECK defined".to_string(),
1115 roast: "No HEALTHCHECK? Your container is basically on the honor system. 'It's fine, \
1116 I'm sure it's fine.' Meanwhile Kubernetes is just restarting it every 30 seconds \
1117 wondering what went wrong."
1118 .to_string(),
1119 }]
1120}
1121
1122fn rule_cmd_without_entrypoint(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1123 vec![]
1124}
1125
1126fn rule_shell_form_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1127 instrs_of(instrs, "ENTRYPOINT")
1128 .into_iter()
1129 .filter(|i| !i.arguments.trim().starts_with('['))
1130 .map(|i| Finding {
1131 column: 0,
1132 end_line: 0,
1133 end_column: 0,
1134 rule: "DF018".into(),
1135 severity: Severity::Warning,
1136 line: i.line,
1137 message: "ENTRYPOINT in shell form prevents signal propagation".to_string(),
1138 roast: "Shell-form ENTRYPOINT means your app runs as a child of /bin/sh. When \
1139 Kubernetes sends SIGTERM, your app doesn't get it — /bin/sh does, and \
1140 /bin/sh doesn't care. Use exec form: ENTRYPOINT [\"cmd\", \"arg\"]."
1141 .to_string(),
1142 })
1143 .collect()
1144}
1145
1146fn rule_deprecated_maintainer(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1147 instrs_of(instrs, "MAINTAINER")
1148 .into_iter()
1149 .map(|i| Finding {
1150 column: 0,
1151 end_line: 0,
1152 end_column: 0,
1153 rule: "DF019".into(),
1154 severity: Severity::Warning,
1155 line: i.line,
1156 message: "MAINTAINER is deprecated".to_string(),
1157 roast: "MAINTAINER has been deprecated since Docker 1.13. That was 2017. \
1158 Your Dockerfile is old enough to be in middle school. \
1159 Use LABEL maintainer=\"...\" like the rest of us."
1160 .to_string(),
1161 })
1162 .collect()
1163}
1164
1165fn rule_no_expose(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1166 if has_instr(instrs, "EXPOSE") {
1167 return vec![];
1168 }
1169 if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
1170 return vec![];
1171 }
1172 vec![Finding {
1173 column: 0,
1174 end_line: 0,
1175 end_column: 0,
1176 rule: "DF022".into(),
1177 severity: Severity::Info,
1178 line: 0,
1179 message: "No EXPOSE instruction — consider documenting which ports this service uses"
1180 .to_string(),
1181 roast: "No EXPOSE? Your container is a mystery box. Is it a web server? A database? \
1182 A very slow random number generator? EXPOSE is documentation — it tells the \
1183 next developer which port to knock on."
1184 .to_string(),
1185 }]
1186}
1187
1188fn rule_multiple_from_no_alias(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1189 let froms: Vec<_> = instrs_of(instrs, "FROM");
1190 let stage_count = froms.len();
1191 if stage_count <= 1 {
1192 return vec![];
1193 }
1194
1195 froms
1198 .into_iter()
1199 .take(stage_count - 1)
1200 .filter(|i| parse_from_arguments(&i.arguments).is_some_and(|from| from.alias.is_none()))
1201 .map(|i| Finding {
1202 column: 0,
1203 end_line: 0,
1204 end_column: 0,
1205 rule: "DF023".into(),
1206 severity: Severity::Warning,
1207 line: i.line,
1208 message: "Multi-stage FROM without AS alias — hard to reference later".to_string(),
1209 roast: "Multi-stage FROM without an alias. How will you COPY --from=... this? \
1210 By index? \"--from=2\"? That's fragile. Give your stages names like \
1211 a civilized person. FROM golang:1.21 AS builder."
1212 .to_string(),
1213 })
1214 .collect()
1215}
1216
1217fn rule_from_latest_alias(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1218 vec![]
1219}
1220
1221fn rule_shell_form_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1222 instrs_of(instrs, "CMD")
1223 .into_iter()
1224 .filter(|i| !i.arguments.trim().starts_with('['))
1225 .map(|i| Finding {
1226 column: 0,
1227 end_line: 0,
1228 end_column: 0,
1229 rule: "DF025".into(),
1230 severity: Severity::Warning,
1231 line: i.line,
1232 message: "CMD in shell form — prefer exec form [\"executable\", \"arg\"]".to_string(),
1233 roast: "Shell-form CMD wraps your process in /bin/sh -c, which means PID 1 is the \
1234 shell, not your app. Signal handling breaks, graceful shutdown breaks, and \
1235 your ops team breaks (emotionally). Use exec form."
1236 .to_string(),
1237 })
1238 .collect()
1239}
1240
1241fn rule_copy_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1242 instrs_of(instrs, "COPY")
1243 .into_iter()
1244 .filter(|i| {
1245 let a = i.arguments.trim();
1246 a.ends_with(" /") || a.contains(" / ") || a.ends_with("/.")
1247 })
1248 .map(|i| Finding {
1249 column: 0,
1250 end_line: 0,
1251 end_column: 0,
1252 rule: "DF026".into(),
1253 severity: Severity::Warning,
1254 line: i.line,
1255 message: "COPY to filesystem root — this may overwrite system files".to_string(),
1256 roast: "Copying files directly to /? Brave. Reckless. Chaotic. You're one typo away \
1257 from overwriting /bin/sh and creating a container that doesn't even boot. \
1258 Use a dedicated app directory."
1259 .to_string(),
1260 })
1261 .collect()
1262}
1263
1264fn rule_pip_no_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1265 let mut stage_cache_settings = std::collections::HashMap::new();
1266 let mut current_stage = None;
1267 let mut pip_cache_disabled = false;
1268
1269 instrs
1270 .iter()
1271 .filter(|instruction| match instruction.instruction.as_str() {
1272 "FROM" => {
1273 let base = instruction.words.first().map(|word| word.value.as_str());
1274 pip_cache_disabled = base
1275 .and_then(|base| stage_cache_settings.get(base))
1276 .copied()
1277 .unwrap_or(false);
1278 current_stage = instruction
1279 .words
1280 .iter()
1281 .position(|word| word.value.eq_ignore_ascii_case("as"))
1282 .and_then(|position| instruction.words.get(position + 1))
1283 .map(|word| word.value.clone());
1284 if let Some(stage) = ¤t_stage {
1285 stage_cache_settings.insert(stage.clone(), pip_cache_disabled);
1286 }
1287 false
1288 }
1289 "ENV" => {
1290 for word in &instruction.words {
1291 if let Some(value) = word.value.strip_prefix("PIP_NO_CACHE_DIR=") {
1292 pip_cache_disabled = pip_boolean(value);
1293 if let Some(stage) = ¤t_stage {
1294 stage_cache_settings.insert(stage.clone(), pip_cache_disabled);
1295 }
1296 }
1297 }
1298 false
1299 }
1300 "RUN" => {
1301 let arguments = &instruction.arguments;
1302 if is_uv_pip_install(arguments) {
1303 !arguments.contains("--no-cache")
1304 } else {
1305 (arguments.contains("pip install") || arguments.contains("pip3 install"))
1306 && !arguments.contains("--no-cache-dir")
1307 && !pip_cache_disabled
1308 }
1309 }
1310 _ => false,
1311 })
1312 .map(|i| Finding {
1313 column: 0,
1314 end_line: 0,
1315 end_column: 0,
1316 rule: "DF030".into(),
1317 severity: Severity::Info,
1318 line: i.line,
1319 message: if is_uv_pip_install(&i.arguments) {
1320 "uv pip install without --no-cache wastes space in the image layer".to_string()
1321 } else {
1322 "pip install without --no-cache-dir wastes space in the image layer".to_string()
1323 },
1324 roast: "pip install without --no-cache-dir? You're carrying around a pip cache in \
1325 your production image like a tourist with a suitcase full of hotel shampoos. \
1326 You don't need those. Add the installer-specific no-cache flag."
1327 .to_string(),
1328 })
1329 .collect()
1330}
1331
1332fn pip_boolean(value: &str) -> bool {
1335 matches!(
1336 value.to_ascii_lowercase().as_str(),
1337 "1" | "true" | "yes" | "on"
1338 )
1339}
1340
1341fn is_uv_pip_install(command: &str) -> bool {
1342 Regex::new(r"(?:^|\s)uv\s+pip\s+install(?:\s|$)")
1343 .expect("valid uv pip install regex")
1344 .is_match(command)
1345}
1346
1347fn rule_npm_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1348 let npm_install = Regex::new(r"\bnpm\s+install\b").expect("valid npm install regex");
1349 instrs_of(instrs, "RUN")
1350 .into_iter()
1351 .filter(|i| {
1352 let a = &i.arguments;
1353 npm_install.is_match(a) && !a.contains("--production") && !a.contains("--omit=dev")
1354 })
1355 .map(|i| Finding {
1356 column: 0,
1357 end_line: 0,
1358 end_column: 0,
1359 rule: "DF031".into(),
1360 severity: Severity::Info,
1361 line: i.line,
1362 message: "npm install used — consider npm ci for reproducible builds".to_string(),
1363 roast: "`npm install` in a Dockerfile: non-deterministic, slower than `npm ci`, \
1364 and potentially installs different versions than your lockfile specifies. \
1365 `npm ci` exists specifically for CI/CD and containers. Use it."
1366 .to_string(),
1367 })
1368 .collect()
1369}
1370
1371fn rule_python_env_vars(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1372 let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
1373 Some(f) => f,
1374 None => return vec![],
1375 };
1376 if !first_from.arguments.to_lowercase().contains("python") {
1377 return vec![];
1378 }
1379 let env_args: String = instrs_of(instrs, "ENV")
1380 .iter()
1381 .map(|i| i.arguments.as_str())
1382 .collect::<Vec<_>>()
1383 .join(" ");
1384 let mut findings = Vec::new();
1385 if !env_args.contains("PYTHONDONTWRITEBYTECODE") {
1386 findings.push(Finding {
1387 column: 0,
1388 end_line: 0,
1389 end_column: 0,
1390 rule: "DF032".into(),
1391 severity: Severity::Info,
1392 line: 0,
1393 message: "PYTHONDONTWRITEBYTECODE not set — Python will write .pyc files to the image"
1394 .to_string(),
1395 roast: "Python is quietly writing .pyc bytecode files all over your image. \
1396 Set PYTHONDONTWRITEBYTECODE=1 and stop Python from hoarding compiled cache \
1397 files in your container like a digital hoarder."
1398 .to_string(),
1399 });
1400 }
1401 if !env_args.contains("PYTHONUNBUFFERED") {
1402 findings.push(Finding {
1403 column: 0,
1404 end_line: 0,
1405 end_column: 0,
1406 rule: "DF032".into(),
1407 severity: Severity::Info,
1408 line: 0,
1409 message: "PYTHONUNBUFFERED not set — Python output may not appear in logs".to_string(),
1410 roast: "PYTHONUNBUFFERED not set? Your Python app is buffering stdout, meaning \
1411 logs disappear into the void and you won't see output until the buffer \
1412 flushes — which is never, because your container crashed. Set PYTHONUNBUFFERED=1.".to_string(),
1413 });
1414 }
1415 findings
1416}
1417
1418fn rule_no_dockerignore(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1419 vec![]
1420}
1421
1422fn rule_chmod_777(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1423 let re = Regex::new(r"chmod\s+([-R\s]*)777").unwrap();
1424 instrs_of(instrs, "RUN")
1425 .into_iter()
1426 .filter(|i| re.is_match(&i.arguments))
1427 .map(|i| Finding {
1428 column: 0,
1429 end_line: 0,
1430 end_column: 0,
1431 rule: "DF034".into(),
1432 severity: Severity::Error,
1433 line: i.line,
1434 message: "chmod 777 grants world-writable permissions — overly permissive".to_string(),
1435 roast: "chmod 777? Giving everyone read, write, and execute access is the filesystem \
1436 equivalent of leaving your front door open with a sign that says \
1437 'free stuff inside'. Minimum permissions, please."
1438 .to_string(),
1439 })
1440 .collect()
1441}
1442
1443fn rule_curl_no_fail(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1444 instrs_of(instrs, "RUN")
1445 .into_iter()
1446 .filter(|i| {
1447 let a = &i.arguments;
1448 let has_url = a.contains("http://") || a.contains("https://") || a.contains("ftp://");
1450 has_url
1451 && a.contains("curl")
1452 && !a.contains("--fail")
1453 && !a.contains("-fsSL")
1454 && !a.contains("-fsS")
1455 && !a.contains("-fL")
1456 && !a.contains("-fs ")
1457 && !{
1458 let mut found = false;
1459 for part in a.split_whitespace() {
1460 if part.starts_with('-') && !part.starts_with("--") && part.contains('f') {
1461 found = true;
1462 break;
1463 }
1464 }
1465 found
1466 }
1467 })
1468 .map(|i| Finding {
1469 column: 0,
1470 end_line: 0,
1471 end_column: 0,
1472 rule: "DF035".into(),
1473 severity: Severity::Info,
1474 line: i.line,
1475 message: "curl without --fail — HTTP errors won't cause the RUN step to fail"
1476 .to_string(),
1477 roast: "curl without --fail means a 404 or 500 response silently succeeds. \
1478 Your build will happily continue after downloading an error page and \
1479 treating it as a binary. Add --fail and save yourself a 2am debugging session."
1480 .to_string(),
1481 })
1482 .collect()
1483}
1484
1485fn rule_no_cmd_or_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1486 if has_instr(instrs, "CMD") || has_instr(instrs, "ENTRYPOINT") {
1487 return vec![];
1488 }
1489 if instrs.len() < 3 {
1490 return vec![];
1491 }
1492 vec![Finding {
1493 column: 0,
1494 end_line: 0,
1495 end_column: 0,
1496 rule: "DF036".into(),
1497 severity: Severity::Warning,
1498 line: 0,
1499 message: "No CMD or ENTRYPOINT defined — the container has no default command".to_string(),
1500 roast: "No CMD or ENTRYPOINT? This container starts, does nothing, and immediately exits \
1501 like an intern on their first day who didn't read the onboarding docs. \
1502 Tell it what to run."
1503 .to_string(),
1504 }]
1505}
1506
1507fn rule_uncleaned_package_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1508 let apt_distclean = Regex::new(r"\bapt-get\s+distclean\b").expect("valid apt distclean regex");
1509 let mut findings = Vec::new();
1510 for i in instrs_of(instrs, "RUN") {
1511 let arg = &i.arguments;
1512 let has_apt = arg.contains("apt-get install") || arg.contains("apt install");
1513 let has_yum = arg.contains("yum install") || arg.contains("dnf install");
1514 let has_apk = arg.contains("apk add") && !arg.contains("--no-cache");
1515 let cleans_apt_lists =
1516 arg.contains("rm -rf /var/lib/apt/lists") || apt_distclean.is_match(arg);
1517 if has_apt && !cleans_apt_lists {
1518 findings.push(Finding {
1519 column: 0,
1520 end_line: 0,
1521 end_column: 0,
1522 rule: "DF004".into(),
1523 severity: Severity::Warning,
1524 line: i.line,
1525 message: "apt cache not cleaned after install — adds unnecessary layer size"
1526 .to_string(),
1527 roast: "Not cleaning the apt cache is like finishing a meal and leaving all the \
1528 wrappers in the container. Your image is now a trash can. A very expensive \
1529 trash can stored in ECR."
1530 .to_string(),
1531 });
1532 }
1533 if has_yum && !arg.contains("yum clean all") && !arg.contains("dnf clean all") {
1534 findings.push(Finding {
1535 column: 0,
1536 end_line: 0,
1537 end_column: 0,
1538 rule: "DF004".into(),
1539 severity: Severity::Warning,
1540 line: i.line,
1541 message: "yum/dnf cache not cleaned after install".to_string(),
1542 roast: "You installed packages with yum but didn't clean up. Every megabyte of \
1543 cache you leave is a megabyte of shame floating in your registry."
1544 .to_string(),
1545 });
1546 }
1547 if has_apk {
1548 findings.push(Finding {
1549 column: 0,
1550 end_line: 0,
1551 end_column: 0,
1552 rule: "DF029".into(),
1553 severity: Severity::Warning,
1554 line: i.line,
1555 message: "apk add without --no-cache flag".to_string(),
1556 roast: "Using `apk add` without `--no-cache`? You chose Alpine to save space and \
1557 then immediately gained it all back. That's impressive, in a bad way."
1558 .to_string(),
1559 });
1560 }
1561 }
1562 findings
1563}
1564
1565fn rule_unpinned_packages(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1566 let re_yum = Regex::new(r"yum install[^&|;]*").unwrap();
1567 let mut findings = Vec::new();
1568 for i in instrs_of(instrs, "RUN") {
1569 if apt_install_commands(&i.arguments)
1570 .iter()
1571 .any(|(tokens, install_index)| apt_has_unpinned_package(tokens, *install_index))
1572 {
1573 findings.push(Finding {
1574 column: 0,
1575 end_line: 0,
1576 end_column: 0,
1577 rule: "DF005".into(),
1578 severity: Severity::Info,
1579 line: i.line,
1580 message: "apt-get install without pinned package versions".to_string(),
1581 roast: "Unpinned packages: a bold way to ensure your build is different \
1582 every single time. 'It worked on my machine' is a lifestyle choice, \
1583 not a deployment strategy."
1584 .to_string(),
1585 });
1586 }
1587 if re_yum.find(&i.arguments).is_some() {
1588 findings.push(Finding {
1589 column: 0,
1590 end_line: 0,
1591 end_column: 0,
1592 rule: "DF005".into(),
1593 severity: Severity::Info,
1594 line: i.line,
1595 message: "yum install without pinned package versions".to_string(),
1596 roast: "Your yum packages are pinned to 'whatever yum feels like today'. \
1597 Reproducibility called — it's going to voicemail."
1598 .to_string(),
1599 });
1600 }
1601 }
1602 findings
1603}
1604
1605fn apt_install_commands(arguments: &str) -> Vec<(Vec<&str>, usize)> {
1606 arguments
1607 .split(['&', '|', ';'])
1608 .filter_map(|segment| {
1609 let segment_tokens: Vec<_> = segment.split_whitespace().collect();
1610 let apt_index = segment_tokens
1611 .iter()
1612 .position(|token| matches!(*token, "apt" | "apt-get"))?;
1613 let tokens = segment_tokens[apt_index + 1..].to_vec();
1614 let install_index = tokens.iter().position(|token| *token == "install")?;
1615 Some((tokens, install_index))
1616 })
1617 .collect()
1618}
1619
1620fn apt_has_unpinned_package(tokens: &[&str], install_index: usize) -> bool {
1621 if tokens.contains(&"--only-upgrade") {
1622 return false;
1623 }
1624
1625 let options_with_values = [
1626 "-a",
1627 "--host-architecture",
1628 "-c",
1629 "--config-file",
1630 "-o",
1631 "--option",
1632 "-q",
1633 "--quiet",
1634 "-t",
1635 "--target-release",
1636 ];
1637 let mut skip_option_value = false;
1638 for token in tokens.iter().skip(install_index + 1) {
1639 if skip_option_value {
1640 skip_option_value = false;
1641 continue;
1642 }
1643 if options_with_values.contains(token) {
1644 skip_option_value = true;
1645 continue;
1646 }
1647 if token.starts_with('-') {
1648 continue;
1649 }
1650 if !token.contains('=') {
1651 return true;
1652 }
1653 }
1654 false
1655}
1656
1657fn apt_assumes_yes(tokens: &[&str]) -> bool {
1658 for (index, token) in tokens.iter().enumerate() {
1659 if matches!(*token, "--yes" | "--assume-yes") {
1660 return true;
1661 }
1662 if let Some(short_options) = token.strip_prefix('-').filter(|_| !token.starts_with("--")) {
1663 if short_options.contains('y')
1664 || short_options.chars().filter(|c| *c == 'q').count() >= 2
1665 {
1666 return true;
1667 }
1668 if short_options
1669 .strip_prefix("q=")
1670 .and_then(|level| level.parse::<u8>().ok())
1671 .is_some_and(|level| level >= 2)
1672 {
1673 return true;
1674 }
1675 }
1676 if token
1677 .strip_prefix("--quiet=")
1678 .and_then(|level| level.parse::<u8>().ok())
1679 .is_some_and(|level| level >= 2)
1680 {
1681 return true;
1682 }
1683 if matches!(*token, "-q" | "--quiet")
1684 && tokens
1685 .get(index + 1)
1686 .and_then(|level| level.parse::<u8>().ok())
1687 .is_some_and(|level| level >= 2)
1688 {
1689 return true;
1690 }
1691 }
1692 false
1693}
1694
1695fn rule_apt_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1696 instrs_of(instrs, "RUN")
1697 .into_iter()
1698 .filter(|i| {
1699 let a = &i.arguments;
1700 apt_install_commands(a)
1701 .iter()
1702 .any(|(tokens, _)| !apt_assumes_yes(tokens))
1703 && !a.contains("DEBIAN_FRONTEND=noninteractive")
1704 })
1705 .map(|i| Finding {
1706 column: 0,
1707 end_line: 0,
1708 end_column: 0,
1709 rule: "DF015".into(),
1710 severity: Severity::Error,
1711 line: i.line,
1712 message: "apt-get install without -y flag will hang waiting for user input".to_string(),
1713 roast: "apt-get install without -y? Your build is going to sit there, patiently \
1714 waiting for a 'yes' that will never come, like a golden retriever waiting \
1715 for an owner who's on a cruise ship."
1716 .to_string(),
1717 })
1718 .collect()
1719}
1720
1721fn rule_apt_recommends(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1722 instrs_of(instrs, "RUN")
1723 .into_iter()
1724 .filter(|i| {
1725 let a = &i.arguments;
1726 (a.contains("apt-get install") || a.contains("apt install"))
1727 && !a.contains("--no-install-recommends")
1728 })
1729 .map(|i| Finding {
1730 column: 0,
1731 end_line: 0,
1732 end_column: 0,
1733 rule: "DF016".into(),
1734 severity: Severity::Info,
1735 line: i.line,
1736 message: "apt-get install without --no-install-recommends installs extra packages"
1737 .to_string(),
1738 roast: "Installing without --no-install-recommends? apt is now installing packages \
1739 you didn't ask for, like a waiter who brings you a full bread basket when \
1740 you said you're gluten-free. `--no-install-recommends` is right there."
1741 .to_string(),
1742 })
1743 .collect()
1744}
1745
1746fn rule_yum_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1747 instrs_of(instrs, "RUN")
1748 .into_iter()
1749 .filter(|i| {
1750 let a = &i.arguments;
1751 (a.contains("yum install") || a.contains("dnf install"))
1752 && !a.contains("-y")
1753 && !a.contains("--assumeyes")
1754 })
1755 .map(|i| Finding {
1756 column: 0,
1757 end_line: 0,
1758 end_column: 0,
1759 rule: "DF027".into(),
1760 severity: Severity::Error,
1761 line: i.line,
1762 message: "yum/dnf install without -y flag will hang waiting for user input".to_string(),
1763 roast: "yum install without -y. Your build will hang indefinitely, \
1764 waiting for input in a non-interactive environment. \
1765 It's not coming. Add -y and move on."
1766 .to_string(),
1767 })
1768 .collect()
1769}
1770
1771fn rule_apt_get_update_alone(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1772 let mut findings = Vec::new();
1773 let mut prev_was_update = false;
1774 let mut update_line = 0;
1775 for i in instrs {
1776 if i.instruction == "RUN" {
1777 let a = &i.arguments;
1778 let has_update = a.contains("apt-get update") || a.contains("apt update");
1779 let has_install = a.contains("apt-get install") || a.contains("apt install");
1780 if has_update && !has_install {
1781 prev_was_update = true;
1782 update_line = i.line;
1783 } else if has_install && !has_update && prev_was_update {
1784 findings.push(Finding {
1785 column: 0,
1786 end_line: 0,
1787 end_column: 0,
1788 rule: "DF028".into(),
1789 severity: Severity::Warning,
1790 line: update_line,
1791 message: "apt-get update in a separate RUN from apt-get install causes cache poisoning".to_string(),
1792 roast: "Splitting `apt-get update` and `apt-get install` into separate RUN \
1793 layers is a classic mistake. Docker caches the update layer and \
1794 your install may use a stale index. Combine them with && or enjoy \
1795 mysterious 404 errors.".to_string(),
1796 });
1797 prev_was_update = false;
1798 } else {
1799 prev_was_update = false;
1800 }
1801 }
1802 }
1803 findings
1804}
1805
1806fn rule_apk_no_cache(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1807 vec![]
1809}
1810
1811fn rule_secrets_in_env(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1812 let secret_patterns = [
1813 "password",
1814 "passwd",
1815 "secret",
1816 "token",
1817 "api_key",
1818 "apikey",
1819 "private_key",
1820 "auth_token",
1821 "access_key",
1822 "secret_key",
1823 "db_pass",
1824 "database_password",
1825 ];
1826 let mut findings = Vec::new();
1827 for i in instrs_of(instrs, "ENV") {
1828 let lower = i.arguments.to_lowercase();
1829 for pat in &secret_patterns {
1830 if lower.contains(pat) {
1831 findings.push(Finding {
1832 column: 0,
1833 end_line: 0,
1834 end_column: 0,
1835 rule: "DF013".into(),
1836 severity: Severity::Error,
1837 line: i.line,
1838 message: format!("Potential secret in ENV variable (matched: '{}')", pat),
1839 roast: format!(
1840 "You put a '{}' in an ENV instruction. Congratulations — it's now \
1841 immortalized in your image layers, your registry, your CI logs, \
1842 and probably a security audit finding. Use Docker secrets or a vault.",
1843 pat
1844 ),
1845 });
1846 break;
1847 }
1848 }
1849 }
1850 findings
1851}
1852
1853fn rule_hardcoded_secrets(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1854 let re = Regex::new(r"(?i)(password|secret|token|key|passwd)\s*=\s*\S+").unwrap();
1855 let mut findings = Vec::new();
1856 for i in instrs
1857 .iter()
1858 .filter(|i| i.instruction == "ARG" || i.instruction == "ENV")
1859 {
1860 if let Some(cap) = re.find(&i.arguments) {
1861 let parts: Vec<&str> = cap.as_str().splitn(2, '=').collect();
1862 if parts.len() == 2 {
1863 let val = parts[1].trim();
1864 if !val.is_empty() && !val.starts_with('$') && val != "\"\"" && val != "''" {
1865 findings.push(Finding {
1866 column: 0,
1867 end_line: 0,
1868 end_column: 0,
1869 rule: "DF014".into(),
1870 severity: Severity::Error,
1871 line: i.line,
1872 message: "Hardcoded secret value detected in ARG/ENV".to_string(),
1873 roast: "A hardcoded secret! How delightfully naive. It's in your git \
1874 history forever now. Have fun rotating that. Maybe consider \
1875 build secrets or runtime injection next time?"
1876 .to_string(),
1877 });
1878 }
1879 }
1880 }
1881 }
1882 findings
1883}
1884
1885fn rule_curl_pipe_sh(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1886 let re = Regex::new(r"(curl|wget)[^|]*\|\s*(bash|sh|ash|zsh|fish)\b").unwrap();
1887 instrs_of(instrs, "RUN")
1888 .into_iter()
1889 .filter(|i| re.is_match(&i.arguments))
1890 .map(|i| Finding {
1891 column: 0,
1892 end_line: 0,
1893 end_column: 0,
1894 rule: "DF021".into(),
1895 severity: Severity::Error,
1896 line: i.line,
1897 message: "Piping remote script directly to shell (curl/wget | sh)".to_string(),
1898 roast: "curl | sh: the technical equivalent of 'hold my beer'. You're downloading \
1899 code from the internet and executing it blind, inside your container, \
1900 and shipping it to prod. Your threat model is vibes."
1901 .to_string(),
1902 })
1903 .collect()
1904}
1905
1906fn rule_apt_instead_of_apt_get(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1907 let re =
1908 Regex::new(r"\bapt\s+(install|remove|update|upgrade|list|search|show|purge)\b").unwrap();
1909 instrs_of(instrs, "RUN")
1910 .into_iter()
1911 .filter(|i| re.is_match(&i.arguments))
1912 .map(|i| Finding {
1913 column: 0,
1914 end_line: 0,
1915 end_column: 0,
1916 rule: "DF059".into(),
1917 severity: Severity::Warning,
1918 line: i.line,
1919 message:
1920 "apt used instead of apt-get — apt is an end-user tool, not suited for scripts"
1921 .to_string(),
1922 roast: "`apt` is designed for humans: it has progress bars, color output, and a \
1923 warning that says 'do not use in scripts'. You are in a script. \
1924 Use apt-get or apt-cache instead."
1925 .to_string(),
1926 })
1927 .collect()
1928}
1929
1930fn rule_useless_commands(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1931 let useless = [
1932 "ssh ",
1933 "vim ",
1934 "nano ",
1935 "emacs ",
1936 "shutdown",
1937 "reboot",
1938 "service ",
1939 "systemctl ",
1940 "ifconfig ",
1941 "iwconfig",
1942 "free ",
1943 "top ",
1944 "htop ",
1945 "mount ",
1946 "umount ",
1947 ];
1948 let mut findings = Vec::new();
1949 for i in instrs_of(instrs, "RUN") {
1950 for cmd in &useless {
1951 if i.arguments.contains(cmd) {
1952 findings.push(Finding {
1953 column: 0,
1954 end_line: 0,
1955 end_column: 0,
1956 rule: "DF060".into(),
1957 severity: Severity::Info,
1958 line: i.line,
1959 message: format!(
1960 "Command '{}' makes little sense inside a container",
1961 cmd.trim()
1962 ),
1963 roast: format!(
1964 "`{}` in a Dockerfile: you're running a command that assumes a full \
1965 interactive OS environment inside a container. It doesn't apply here. \
1966 Containers are not VMs.",
1967 cmd.trim()
1968 ),
1969 });
1970 break;
1971 }
1972 }
1973 }
1974 findings
1975}
1976
1977fn rule_from_platform_flag(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1978 let froms = instrs_of(instrs, "FROM");
1979 let final_stage = froms.len().saturating_sub(1);
1980
1981 froms
1982 .into_iter()
1983 .enumerate()
1984 .filter(|(index, instruction)| {
1985 let has_platform_flag = instruction
1986 .flags
1987 .iter()
1988 .any(|flag| flag.name.eq_ignore_ascii_case("platform"));
1989 let is_native_builder = *index < final_stage
1990 && instruction.flags.iter().any(|flag| {
1991 flag.name.eq_ignore_ascii_case("platform")
1992 && matches!(flag.value.as_deref(), Some("$BUILDPLATFORM" | "${BUILDPLATFORM}"))
1993 });
1994 has_platform_flag && !is_native_builder
1995 })
1996 .map(|(_, i)| i)
1997 .map(|i| Finding {
1998 column: 0,
1999 end_line: 0,
2000 end_column: 0,
2001 rule: "DF061".into(),
2002 severity: Severity::Warning,
2003 line: i.line,
2004 message: "FROM uses --platform flag — consider whether cross-platform targeting is intentional".to_string(),
2005 roast: "--platform in FROM forces a specific architecture. If you're building for \
2006 amd64 but deploying on arm64, your image will be slow or broken. \
2007 Make sure this is intentional and documented.".to_string(),
2008 })
2009 .collect()
2010}
2011
2012fn rule_env_self_reference(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2013 let re = Regex::new(r"(\w+)=\s*[\x22\x27]?\$\{?(\w+)\}?").unwrap();
2014 let mut findings = Vec::new();
2015 let mut stage_args = std::collections::HashSet::new();
2016 for i in instrs {
2017 if i.instruction == "FROM" {
2018 stage_args.clear();
2019 continue;
2020 }
2021 if i.instruction == "ARG" {
2022 if let Some(name) = i.arguments.split('=').next().and_then(|arg| arg.split_whitespace().next()) {
2023 stage_args.insert(name.to_string());
2024 }
2025 continue;
2026 }
2027 if i.instruction != "ENV" {
2028 continue;
2029 }
2030 for cap in re.captures_iter(&i.arguments) {
2031 let defined = &cap[1];
2032 let referenced = &cap[2];
2033 if defined == referenced && !stage_args.contains(referenced) {
2034 findings.push(Finding {
2035 column: 0,
2036 end_line: 0,
2037 end_column: 0,
2038 rule: "DF062".into(),
2039 severity: Severity::Error,
2040 line: i.line,
2041 message: format!(
2042 "ENV variable '{}' references itself in the same statement",
2043 defined
2044 ),
2045 roast: format!(
2046 "ENV {}=${{{}}} — you're defining a variable using itself. \
2047 It hasn't been set yet at this point in the same ENV instruction. \
2048 The result will be an empty string. Split it into two ENV statements.",
2049 defined, referenced
2050 ),
2051 });
2052 break;
2053 }
2054 }
2055 }
2056 findings
2057}
2058
2059fn rule_copy_relative_no_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2060 let mut stage_workdirs: std::collections::HashMap<String, bool> =
2061 std::collections::HashMap::new();
2062 let mut current_alias: Option<String> = None;
2063 let mut workdir_set = false;
2064 let mut findings = Vec::new();
2065 for i in instrs {
2066 if i.instruction == "FROM" {
2067 if let Some(from) = parse_from_arguments(&i.arguments) {
2068 workdir_set = stage_workdirs
2069 .get(&from.image.to_lowercase())
2070 .copied()
2071 .unwrap_or(false);
2072 current_alias = from.alias.map(str::to_lowercase);
2073 if let Some(alias) = ¤t_alias {
2074 stage_workdirs.insert(alias.clone(), workdir_set);
2075 }
2076 } else {
2077 workdir_set = false;
2078 current_alias = None;
2079 }
2080 } else if i.instruction == "WORKDIR" {
2081 workdir_set = true;
2082 if let Some(alias) = ¤t_alias {
2083 stage_workdirs.insert(alias.clone(), true);
2084 }
2085 } else if i.instruction == "COPY" {
2086 let args: Vec<&str> = i
2087 .arguments
2088 .split_whitespace()
2089 .filter(|t| !t.starts_with("--"))
2090 .collect();
2091 if let Some(dest) = args.last() {
2092 if !dest.starts_with('/') && !dest.starts_with('$') && !workdir_set {
2093 findings.push(Finding {
2094 column: 0,
2095 end_line: 0,
2096 end_column: 0,
2097 rule: "DF063".into(),
2098 severity: Severity::Warning,
2099 line: i.line,
2100 message: format!(
2101 "COPY to relative destination '{}' but no WORKDIR has been set",
2102 dest
2103 ),
2104 roast: format!(
2105 "COPY to '{}' with no WORKDIR set. Relative destinations depend on \
2106 the working directory, which defaults to /. \
2107 Set WORKDIR explicitly before using relative paths.",
2108 dest
2109 ),
2110 });
2111 }
2112 }
2113 }
2114 }
2115 findings
2116}
2117
2118fn rule_useradd_no_l(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2119 let re = Regex::new(r"\buseradd\b").unwrap();
2120 instrs_of(instrs, "RUN")
2121 .into_iter()
2122 .filter(|i| {
2123 re.is_match(&i.arguments)
2124 && !i.arguments.contains(" -l")
2125 && !i.arguments.contains("--no-log-init")
2126 })
2127 .map(|i| Finding {
2128 column: 0,
2129 end_line: 0,
2130 end_column: 0,
2131 rule: "DF064".into(),
2132 severity: Severity::Warning,
2133 line: i.line,
2134 message:
2135 "useradd without -l flag — high UIDs create oversized /var/log/lastlog entries"
2136 .to_string(),
2137 roast: "useradd without -l (--no-log-init): with a high UID, this creates a sparse \
2138 file in /var/log/lastlog that can balloon your image size by gigabytes. \
2139 Add -l or use --no-log-init."
2140 .to_string(),
2141 })
2142 .collect()
2143}
2144
2145fn rule_copy_archive_use_add(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2146 const ARCHIVE_EXTS: &[&str] = &[".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar"];
2147 instrs_of(instrs, "COPY")
2148 .into_iter()
2149 .filter(|i| {
2150 if i.arguments.contains("--from=") || i.arguments.contains("--from =") {
2152 return false;
2153 }
2154 let sources: Vec<&str> = i
2155 .arguments
2156 .split_whitespace()
2157 .filter(|t| !t.starts_with("--"))
2158 .collect();
2159 if sources.len() < 2 {
2161 return false;
2162 }
2163 sources[..sources.len() - 1]
2165 .iter()
2166 .any(|s| ARCHIVE_EXTS.iter().any(|ext| s.ends_with(ext)))
2167 })
2168 .map(|i| Finding {
2169 column: 0,
2170 end_line: 0,
2171 end_column: 0,
2172 rule: "DF067".into(),
2173 severity: Severity::Info,
2174 line: i.line,
2175 message: "COPY of archive file — consider ADD which auto-extracts local tarballs"
2176 .to_string(),
2177 roast: "COPY drops the compressed archive as-is; you'll need a separate \
2178 RUN tar -xzf layer to unpack it. ADD auto-extracts local tarballs into \
2179 the destination directory and saves you the extra layer. \
2180 Yes, this is the one situation where ADD is actually the right choice."
2181 .to_string(),
2182 })
2183 .collect()
2184}
2185
2186fn rule_onbuild_forbidden(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2187 const FORBIDDEN: &[&str] = &["FROM", "ONBUILD", "MAINTAINER"];
2188 let mut findings = Vec::new();
2189 for i in instrs_of(instrs, "ONBUILD") {
2190 let triggered = i
2191 .arguments
2192 .split_whitespace()
2193 .next()
2194 .unwrap_or("")
2195 .to_uppercase();
2196 if FORBIDDEN.contains(&triggered.as_str()) {
2197 findings.push(Finding {
2198 column: 0,
2199 end_line: 0,
2200 end_column: 0,
2201 rule: "DF068".into(),
2202 severity: Severity::Error,
2203 line: i.line,
2204 message: format!(
2205 "ONBUILD {} is forbidden — {} cannot be used as an ONBUILD trigger",
2206 triggered, triggered
2207 ),
2208 roast: format!(
2209 "ONBUILD {} is explicitly prohibited by Docker. \
2210 FROM would create a recursive inheritance loop, \
2211 ONBUILD ONBUILD is a depth-2 trap nobody asked for, \
2212 and MAINTAINER is deprecated everywhere, including here. \
2213 This fails at build time.",
2214 triggered
2215 ),
2216 });
2217 }
2218 }
2219 findings
2220}
2221
2222fn rule_bash_syntax_no_shell(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2223 if has_instr(instrs, "SHELL") {
2225 return vec![];
2226 }
2227 const BASH_ONLY: &[(&str, &str)] = &[
2229 ("[[ ", "double-bracket conditional"),
2230 ("source ", "source builtin (use '.' in POSIX sh)"),
2231 ("declare ", "declare builtin"),
2232 ("mapfile ", "mapfile builtin"),
2233 ("readarray ", "readarray builtin"),
2234 ("${!", "indirect variable expansion"),
2235 ];
2236 let mut findings = Vec::new();
2237 for i in instrs_of(instrs, "RUN") {
2238 for (pattern, label) in BASH_ONLY {
2239 if i.arguments.contains(pattern) {
2240 findings.push(Finding {
2241 column: 0,
2242 end_line: 0,
2243 end_column: 0,
2244 rule: "DF066".into(),
2245 severity: Severity::Warning,
2246 line: i.line,
2247 message: format!(
2248 "RUN uses bash-specific syntax ({}) but no SHELL instruction is set",
2249 label
2250 ),
2251 roast: format!(
2252 "'{}' is bash syntax. The default shell is /bin/sh, which on Alpine, \
2253 Debian-slim, and distroless is NOT bash. Add \
2254 `SHELL [\"/bin/bash\", \"-c\"]` before this RUN or your build \
2255 will fail in ways that are confusing to debug.",
2256 pattern.trim()
2257 ),
2258 });
2259 break;
2260 }
2261 }
2262 }
2263 findings
2264}
2265
2266fn rule_untrusted_registry(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2267 const TRUSTED: &[&str] = &[
2268 "docker.io",
2269 "registry-1.docker.io",
2270 "ghcr.io",
2271 "gcr.io",
2272 "quay.io",
2273 "mcr.microsoft.com",
2274 "registry.access.redhat.com",
2275 "public.ecr.aws",
2276 "registry.k8s.io",
2277 "k8s.gcr.io",
2278 "dhi.io",
2279 ];
2280 let mut findings = Vec::new();
2281 for i in instrs_of(instrs, "FROM") {
2282 let image = match i
2284 .arguments
2285 .split_whitespace()
2286 .find(|t| !t.starts_with("--"))
2287 {
2288 Some(img) => img,
2289 None => continue,
2290 };
2291 if image.eq_ignore_ascii_case("scratch") {
2292 continue;
2293 }
2294 if !image.contains('/') {
2298 continue;
2299 }
2300 let first = image
2301 .split('@')
2302 .next()
2303 .unwrap_or(image)
2304 .split('/')
2305 .next()
2306 .unwrap_or("");
2307 if (first.contains('.') || first.contains(':') || first == "localhost")
2308 && !TRUSTED.iter().any(|t| first.eq_ignore_ascii_case(t))
2309 {
2310 findings.push(Finding {
2311 column: 0,
2312 end_line: 0,
2313 end_column: 0,
2314 rule: "DF065".into(),
2315 severity: Severity::Warning,
2316 line: i.line,
2317 message: format!("FROM pulls from unrecognised registry '{}'", first),
2318 roast: format!(
2319 "Pulling base images from '{}' — a registry you don't hear about at \
2320 KubeCon. Supply-chain attacks love Dockerfiles that blindly trust \
2321 random registries. Verify this is intentional and pin to a digest.",
2322 first
2323 ),
2324 });
2325 }
2326 }
2327 findings
2328}
2329
2330fn rule_pipefail_missing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2331 let mut shell_has_pipefail = false;
2332 let mut findings = Vec::new();
2333
2334 for instruction in instrs {
2335 match instruction.instruction.as_str() {
2336 "FROM" => shell_has_pipefail = false,
2337 "SHELL" => {
2338 shell_has_pipefail = match &instruction.form {
2339 InstructionForm::Json(arguments) => {
2340 arguments
2341 .windows(2)
2342 .any(|pair| pair[0] == "-o" && pair[1] == "pipefail")
2343 || arguments.iter().any(|argument| argument == "-opipefail")
2344 }
2345 _ => false,
2346 };
2347 }
2348 "RUN" if run_has_unprotected_pipeline(&instruction.command, shell_has_pipefail) => {
2349 findings.push(Finding {
2350 column: 0,
2351 end_line: 0,
2352 end_column: 0,
2353 rule: "DF057".into(),
2354 severity: Severity::Warning,
2355 line: instruction.line,
2356 message: "RUN with pipe but no pipefail — failed commands in the pipe are silently ignored"
2357 .to_string(),
2358 roast: "A pipe in RUN without `set -o pipefail`. If the left side of that pipe fails, \
2359 bash shrugs and moves on. The exit code is whatever the last command returns. \
2360 Add `set -o pipefail` at the start of the RUN."
2361 .to_string(),
2362 });
2363 }
2364 _ => {}
2365 }
2366 }
2367
2368 findings
2369}
2370
2371fn run_has_unprotected_pipeline(script: &str, initial_pipefail_enabled: bool) -> bool {
2378 let mut pipefail_enabled = initial_pipefail_enabled;
2379 let mut words = Vec::new();
2380 let mut current_word = String::new();
2381 let mut quote = None;
2382 let mut chars = script.chars().peekable();
2383
2384 while let Some(character) = chars.next() {
2385 if let Some(active_quote) = quote {
2386 if character == active_quote {
2387 quote = None;
2388 } else if character == '\\' && active_quote == '"' {
2389 if let Some(escaped) = chars.next() {
2390 current_word.push(escaped);
2391 }
2392 } else {
2393 current_word.push(character);
2394 }
2395 continue;
2396 }
2397
2398 match character {
2399 '\\' => {
2400 if let Some(escaped) = chars.next() {
2401 current_word.push(escaped);
2402 }
2403 }
2404 '\'' | '"' => quote = Some(character),
2405 character if character.is_whitespace() => flush_shell_word(&mut current_word, &mut words),
2406 '#' if current_word.is_empty() => break,
2407 ';' => finish_shell_command(&mut current_word, &mut words, &mut pipefail_enabled),
2408 '&' => {
2409 if chars.peek() == Some(&'&') {
2410 chars.next();
2411 }
2412 finish_shell_command(&mut current_word, &mut words, &mut pipefail_enabled);
2413 }
2414 '|' => {
2415 if chars.peek() == Some(&'|') {
2416 chars.next();
2417 finish_shell_command(&mut current_word, &mut words, &mut pipefail_enabled);
2418 } else {
2419 if !pipefail_enabled {
2422 return true;
2423 }
2424 current_word.clear();
2425 words.clear();
2426 }
2427 }
2428 _ => current_word.push(character),
2429 }
2430 }
2431
2432 false
2433}
2434
2435fn flush_shell_word(current_word: &mut String, words: &mut Vec<String>) {
2436 if !current_word.is_empty() {
2437 words.push(std::mem::take(current_word));
2438 }
2439}
2440
2441fn finish_shell_command(
2442 current_word: &mut String,
2443 words: &mut Vec<String>,
2444 pipefail_enabled: &mut bool,
2445) {
2446 flush_shell_word(current_word, words);
2447 if words.first().is_some_and(|word| word == "set") {
2448 let mut arguments = words[1..].iter();
2449 while let Some(argument) = arguments.next() {
2450 if (argument == "-o" || argument == "+o")
2451 && arguments.next().is_some_and(|value| value == "pipefail")
2452 {
2453 *pipefail_enabled = argument == "-o";
2454 words.clear();
2455 return;
2456 }
2457 if argument.starts_with('-')
2458 && argument[1..].contains('o')
2459 && arguments.next().is_some_and(|value| value == "pipefail")
2460 {
2461 *pipefail_enabled = true;
2462 words.clear();
2463 return;
2464 }
2465 }
2466 }
2467 words.clear();
2468}
2469
2470fn rule_wget_and_curl(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2471 let uses_wget = instrs_of(instrs, "RUN")
2472 .iter()
2473 .any(|i| i.arguments.contains("wget "));
2474 let uses_curl = instrs_of(instrs, "RUN")
2475 .iter()
2476 .any(|i| i.arguments.contains("curl "));
2477 if uses_wget && uses_curl {
2478 return vec![Finding {
2479 column: 0,
2480 end_line: 0,
2481 end_column: 0,
2482 rule: "DF058".into(),
2483 severity: Severity::Warning,
2484 line: 0,
2485 message: "Both wget and curl are used — pick one and use it consistently".to_string(),
2486 roast: "You're using both wget and curl in the same Dockerfile. They do the same \
2487 thing. Pick one. Commit to it. Your image doesn't need two download tools \
2488 any more than it needs two fire extinguishers."
2489 .to_string(),
2490 }];
2491 }
2492 vec![]
2493}
2494
2495fn rule_yarn_cache_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2496 instrs_of(instrs, "RUN")
2497 .into_iter()
2498 .filter(|i| {
2499 let a = &i.arguments;
2500 (a.contains("yarn install") || a.contains("yarn add"))
2501 && !a.contains("yarn cache clean")
2502 })
2503 .map(|i| Finding {
2504 column: 0,
2505 end_line: 0,
2506 end_column: 0,
2507 rule: "DF055".into(),
2508 severity: Severity::Info,
2509 line: i.line,
2510 message: "yarn install without yarn cache clean — yarn cache is left in the image"
2511 .to_string(),
2512 roast: "yarn install without cleaning the cache. Yarn dutifully stores downloaded \
2513 packages in a cache that you are now shipping to production. \
2514 Add `&& yarn cache clean` after install."
2515 .to_string(),
2516 })
2517 .collect()
2518}
2519
2520fn rule_wget_no_progress(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2521 instrs_of(instrs, "RUN")
2522 .into_iter()
2523 .filter(|i| {
2524 let a = &i.arguments;
2525 a.contains("wget ")
2526 && !a.contains("--progress")
2527 && !a.contains("-q")
2528 && !a.contains("--quiet")
2529 && (a.contains("http://") || a.contains("https://") || a.contains("ftp://"))
2530 })
2531 .map(|i| Finding {
2532 column: 0,
2533 end_line: 0,
2534 end_column: 0,
2535 rule: "DF056".into(),
2536 severity: Severity::Info,
2537 line: i.line,
2538 message: "wget without --progress flag produces verbose progress output in build logs"
2539 .to_string(),
2540 roast: "wget without --progress=dot:giga will spam your build logs with a progress \
2541 bar that looks great locally and fills 50MB of CI log storage. \
2542 Use --progress=dot:giga or -q to stay quiet."
2543 .to_string(),
2544 })
2545 .collect()
2546}
2547
2548fn rule_pip_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2549 instrs_of(instrs, "RUN")
2550 .into_iter()
2551 .filter(|i| {
2552 let a = &i.arguments;
2553 (a.contains("pip install") || a.contains("pip3 install"))
2554 && !a.contains("-r ")
2555 && !a.contains("--requirement")
2556 && !a.contains("==")
2557 && !a.contains(">=")
2558 && !a.contains("<=")
2559 && !a.contains("~=")
2560 && !a.contains(".txt")
2561 && !is_local_pip_install(a)
2562 })
2563 .map(|i| Finding {
2564 column: 0,
2565 end_line: 0,
2566 end_column: 0,
2567 rule: "DF051".into(),
2568 severity: Severity::Warning,
2569 line: i.line,
2570 message:
2571 "pip install without version pinning — use package==version for reproducibility"
2572 .to_string(),
2573 roast: "pip install with no version pins. Every build pulls 'latest' and \
2574 one day something breaks and you spend three hours bisecting which \
2575 transitive dependency changed. Use package==version."
2576 .to_string(),
2577 })
2578 .collect()
2579}
2580
2581fn is_local_pip_install(command: &str) -> bool {
2582 let Some(install) = command.find("pip install") else {
2583 return false;
2584 };
2585 command[install + "pip install".len()..]
2586 .split_whitespace()
2587 .filter(|argument| !argument.starts_with('-'))
2588 .any(|argument| {
2589 matches!(argument, "." | "./")
2590 || argument.starts_with("./")
2591 || argument.starts_with("../")
2592 || argument.starts_with("file:")
2593 })
2594}
2595
2596fn rule_apk_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2597 instrs_of(instrs, "RUN")
2598 .into_iter()
2599 .filter(|i| {
2600 let a = &i.arguments;
2601 if !a.contains("apk add") {
2602 return false;
2603 }
2604 let after_add = match a.find("apk add") {
2606 Some(pos) => &a[pos + 7..],
2607 None => return false,
2608 };
2609 after_add
2610 .split_whitespace()
2611 .filter(|t| !t.starts_with('-') && !t.is_empty())
2612 .any(|t| !t.contains('=') && !t.contains('>') && !t.contains('<'))
2613 })
2614 .map(|i| Finding {
2615 column: 0,
2616 end_line: 0,
2617 end_column: 0,
2618 rule: "DF052".into(),
2619 severity: Severity::Warning,
2620 line: i.line,
2621 message: "apk add without version pinning — use package=version for reproducibility"
2622 .to_string(),
2623 roast: "apk add with no version? You chose Alpine to be minimal and fast, then \
2624 immediately added unpinned packages. Your builds are non-deterministic \
2625 by design now. Use package=version."
2626 .to_string(),
2627 })
2628 .collect()
2629}
2630
2631fn rule_gem_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2632 instrs_of(instrs, "RUN")
2633 .into_iter()
2634 .filter(|i| {
2635 let a = &i.arguments;
2636 a.contains("gem install")
2637 && !a.contains(" -v ")
2638 && !a.contains("--version")
2639 && !a.contains(':')
2640 })
2641 .map(|i| Finding {
2642 column: 0,
2643 end_line: 0,
2644 end_column: 0,
2645 rule: "DF053".into(),
2646 severity: Severity::Warning,
2647 line: i.line,
2648 message: "gem install without version pinning — use gem install <gem>:<version>"
2649 .to_string(),
2650 roast: "gem install with no version. RubyGems will grab whatever's latest today. \
2651 Next week it grabs something else. Your builds are a dice roll. \
2652 Use gem install name:version."
2653 .to_string(),
2654 })
2655 .collect()
2656}
2657
2658fn rule_go_install_version(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2659 instrs_of(instrs, "RUN")
2660 .into_iter()
2661 .filter(|i| {
2662 i.arguments
2663 .split(['&', '|', ';'])
2664 .any(|segment| {
2665 let mut words = segment.split_whitespace();
2666
2667 let executable = loop {
2671 match words.next() {
2672 Some(word)
2673 if word.contains('=')
2674 && !word.starts_with('=')
2675 && !word.contains('/') => continue,
2676 word => break word,
2677 }
2678 };
2679
2680 executable == Some("go")
2681 && words.next() == Some("install")
2682 && !segment.contains('@')
2683 })
2684 })
2685 .map(|i| Finding {
2686 column: 0,
2687 end_line: 0,
2688 end_column: 0,
2689 rule: "DF054".into(),
2690 severity: Severity::Warning,
2691 line: i.line,
2692 message: "go install without @version — use go install package@version".to_string(),
2693 roast: "go install without @version. The Go toolchain requires a version suffix \
2694 in module-aware mode. Use `go install pkg@v1.2.3` or at minimum `@latest` \
2695 if you enjoy living dangerously."
2696 .to_string(),
2697 })
2698 .collect()
2699}
2700
2701fn rule_copy_multi_arg_slash(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2702 instrs_of(instrs, "COPY")
2703 .into_iter()
2704 .filter(|i| {
2705 let args: Vec<&str> = i
2706 .arguments
2707 .split_whitespace()
2708 .filter(|t| !t.starts_with("--"))
2709 .collect();
2710 if args.len() > 2 {
2711 let dest = args.last().unwrap_or(&"");
2712 !dest.ends_with('/')
2713 } else {
2714 false
2715 }
2716 })
2717 .map(|i| Finding {
2718 column: 0,
2719 end_line: 0,
2720 end_column: 0,
2721 rule: "DF048".into(),
2722 severity: Severity::Error,
2723 line: i.line,
2724 message: "COPY with multiple sources requires the destination to end with /"
2725 .to_string(),
2726 roast: "COPY with multiple sources and a destination that doesn't end with /? \
2727 Docker will complain. Or worse, silently do something weird. \
2728 Add a trailing slash to the destination."
2729 .to_string(),
2730 })
2731 .collect()
2732}
2733
2734fn rule_copy_from_undefined_stage(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2735 Vec::new()
2739}
2740
2741fn rule_copy_from_self(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2742 let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
2743 let mut current_alias: Option<String> = None;
2744 let mut findings = Vec::new();
2745 for i in instrs {
2746 if i.instruction == "FROM" {
2747 current_alias = parse_from_arguments(&i.arguments)
2748 .and_then(|from| from.alias)
2749 .map(str::to_lowercase);
2750 } else if i.instruction == "COPY" {
2751 if let Some(cap) = re_from.captures(&i.arguments) {
2752 let from_ref = cap[1].to_lowercase();
2753 if let Some(ref alias) = current_alias {
2754 if &from_ref == alias {
2755 findings.push(Finding {
2756 column: 0,
2757 end_line: 0,
2758 end_column: 0,
2759 rule: "DF050".into(),
2760 severity: Severity::Error,
2761 line: i.line,
2762 message: format!(
2763 "COPY --from={} references the current build stage — circular dependency",
2764 &cap[1]
2765 ),
2766 roast: format!(
2767 "COPY --from={} inside the same stage named {}. \
2768 That's a circular reference. Docker cannot copy from itself. \
2769 This will fail at build time.",
2770 &cap[1], &cap[1]
2771 ),
2772 });
2773 }
2774 }
2775 }
2776 }
2777 }
2778 findings
2779}
2780
2781fn rule_dnf_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2782 instrs_of(instrs, "RUN")
2783 .into_iter()
2784 .filter(|i| {
2785 let a = &i.arguments;
2786 a.contains("dnf install") && !a.contains("dnf clean all") && !a.contains("dnf clean")
2787 })
2788 .map(|i| Finding {
2789 column: 0,
2790 end_line: 0,
2791 end_column: 0,
2792 rule: "DF046".into(),
2793 severity: Severity::Warning,
2794 line: i.line,
2795 message: "dnf clean all missing after dnf install — RPM cache bloats the image"
2796 .to_string(),
2797 roast: "dnf install without `dnf clean all` afterwards? You're shipping RPM cache \
2798 metadata to production. That's not a feature. Add `&& dnf clean all`."
2799 .to_string(),
2800 })
2801 .collect()
2802}
2803
2804fn rule_yum_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2805 instrs_of(instrs, "RUN")
2806 .into_iter()
2807 .filter(|i| {
2808 let a = &i.arguments;
2809 a.contains("yum install") && !a.contains("yum clean all") && !a.contains("yum clean")
2810 })
2811 .map(|i| Finding {
2812 column: 0,
2813 end_line: 0,
2814 end_column: 0,
2815 rule: "DF047".into(),
2816 severity: Severity::Warning,
2817 line: i.line,
2818 message: "yum clean all missing after yum install — cache stays in the image"
2819 .to_string(),
2820 roast: "yum install without cleanup is just permanently housing the package cache in \
2821 your image. Every MB of yum cache is a MB of shame in your registry."
2822 .to_string(),
2823 })
2824 .collect()
2825}
2826
2827fn rule_zypper_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2828 instrs_of(instrs, "RUN")
2829 .into_iter()
2830 .filter(|i| {
2831 let a = &i.arguments;
2832 (a.contains("zypper install") || a.contains("zypper in "))
2833 && !a.contains("-y")
2834 && !a.contains("--non-interactive")
2835 && !a.contains(" -n ")
2836 && !a.contains(" -n\n")
2837 && !a.starts_with("-n ")
2838 })
2839 .map(|i| Finding {
2840 column: 0,
2841 end_line: 0,
2842 end_column: 0,
2843 rule: "DF043".into(),
2844 severity: Severity::Warning,
2845 line: i.line,
2846 message: "zypper install without non-interactive flag (-y) will hang in a build"
2847 .to_string(),
2848 roast: "zypper install without -y in a container build? It'll wait for input that \
2849 will never arrive, like a chatbot asking for emotional validation."
2850 .to_string(),
2851 })
2852 .collect()
2853}
2854
2855fn rule_zypper_dist_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2856 instrs_of(instrs, "RUN")
2857 .into_iter()
2858 .filter(|i| {
2859 i.arguments.contains("zypper dist-upgrade") || i.arguments.contains("zypper dup")
2860 })
2861 .map(|i| Finding {
2862 column: 0,
2863 end_line: 0,
2864 end_column: 0,
2865 rule: "DF044".into(),
2866 severity: Severity::Warning,
2867 line: i.line,
2868 message:
2869 "zypper dist-upgrade upgrades all packages unpredictably — avoid in Dockerfiles"
2870 .to_string(),
2871 roast: "zypper dist-upgrade: the 'nuke everything and hope for the best' approach to \
2872 package management. Your image will be different every single build. Congrats."
2873 .to_string(),
2874 })
2875 .collect()
2876}
2877
2878fn rule_zypper_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2879 instrs_of(instrs, "RUN")
2880 .into_iter()
2881 .filter(|i| {
2882 let a = &i.arguments;
2883 (a.contains("zypper install") || a.contains("zypper in "))
2884 && !a.contains("zypper clean")
2885 && !a.contains("zypper cc")
2886 })
2887 .map(|i| Finding {
2888 column: 0,
2889 end_line: 0,
2890 end_column: 0,
2891 rule: "DF045".into(),
2892 severity: Severity::Info,
2893 line: i.line,
2894 message: "zypper cache not cleaned after install — adds unnecessary image bloat"
2895 .to_string(),
2896 roast:
2897 "zypper install without `zypper clean --all` afterwards. You're hoarding package \
2898 metadata in your image. Clean it up."
2899 .to_string(),
2900 })
2901 .collect()
2902}
2903
2904fn rule_expose_port_range(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2905 let mut findings = Vec::new();
2906 for i in instrs_of(instrs, "EXPOSE") {
2907 for port_spec in i.arguments.split_whitespace() {
2908 let port_str = port_spec.split('/').next().unwrap_or(port_spec);
2909 if let Ok(port) = port_str.parse::<u32>() {
2910 if port > 65535 {
2911 findings.push(Finding {
2912 column: 0,
2913 end_line: 0,
2914 end_column: 0,
2915 rule: "DF040".into(),
2916 severity: Severity::Error,
2917 line: i.line,
2918 message: format!("EXPOSE port {} is out of valid range (0-65535)", port),
2919 roast: format!(
2920 "Port {}? That's not a port, that's a zip code. \
2921 Valid UNIX ports are 0-65535. Pick a real one.",
2922 port
2923 ),
2924 });
2925 }
2926 }
2927 }
2928 }
2929 findings
2930}
2931
2932fn rule_multiple_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2933 let checks: Vec<_> = instrs_of(instrs, "HEALTHCHECK");
2934 if checks.len() <= 1 {
2935 return vec![];
2936 }
2937 checks[1..]
2938 .iter()
2939 .map(|i| Finding {
2940 column: 0,
2941 end_line: 0,
2942 end_column: 0,
2943 rule: "DF041".into(),
2944 severity: Severity::Error,
2945 line: i.line,
2946 message: "Multiple HEALTHCHECK instructions — only the last one applies".to_string(),
2947 roast: "Multiple HEALTHCHECKs but only the last one counts. The earlier ones are \
2948 haunting your image for no reason. One health check, one truth."
2949 .to_string(),
2950 })
2951 .collect()
2952}
2953
2954fn rule_unique_stage_aliases(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2955 let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2956 let mut findings = Vec::new();
2957 for i in instrs_of(instrs, "FROM") {
2958 if let Some(original_alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias)
2959 {
2960 let alias = original_alias.to_lowercase();
2961 if let Some(&prev_line) = seen.get(&alias) {
2962 findings.push(Finding {
2963 column: 0,
2964 end_line: 0,
2965 end_column: 0,
2966 rule: "DF042".into(),
2967 severity: Severity::Error,
2968 line: i.line,
2969 message: format!(
2970 "FROM alias '{}' is already defined on line {}",
2971 original_alias, prev_line
2972 ),
2973 roast: format!(
2974 "Two stages named '{}'. Docker uses the last one; the first is dead code. \
2975 Give your stages unique names.",
2976 original_alias
2977 ),
2978 });
2979 } else {
2980 seen.insert(alias, i.line);
2981 }
2982 }
2983 }
2984 findings
2985}
2986
2987fn rule_invalid_instruction_order(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2988 if instrs.is_empty() {
2989 return vec![];
2990 }
2991 let first = &instrs[0];
2992 if first.instruction != "FROM" && first.instruction != "ARG" {
2993 return vec![Finding {
2994 column: 0,
2995 end_line: 0,
2996 end_column: 0,
2997 rule: "DF037".into(),
2998 severity: Severity::Error,
2999 line: first.line,
3000 message: format!(
3001 "'{}' before FROM — Dockerfile must begin with FROM, ARG, or a comment",
3002 first.instruction
3003 ),
3004 roast: "Your Dockerfile doesn't start with FROM. That's like starting a recipe with \
3005 'season to taste' before listing any ingredients. Docker is confused. So am I."
3006 .to_string(),
3007 }];
3008 }
3009 vec![]
3010}
3011
3012fn rule_multiple_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3013 let mut findings = Vec::new();
3014 let mut cmds = Vec::new();
3015 let mut report_duplicates = |cmds: &mut Vec<&Instruction>| {
3016 if cmds.len() > 1 {
3017 findings.extend(cmds.iter().skip(1).map(|i| Finding {
3018 column: 0,
3019 end_line: 0,
3020 end_column: 0,
3021 rule: "DF038".into(),
3022 severity: Severity::Warning,
3023 line: i.line,
3024 message: "Multiple CMD instructions — only the last one takes effect".to_string(),
3025 roast:
3026 "Multiple CMDs and only the last one counts. The others are ghosts haunting your \
3027 Dockerfile, contributing nothing except confusion. Pick one."
3028 .to_string(),
3029 }));
3030 }
3031 cmds.clear();
3032 };
3033 for instruction in instrs {
3034 if instruction.instruction == "FROM" {
3035 report_duplicates(&mut cmds);
3036 }
3037 if instruction.instruction == "CMD" {
3038 cmds.push(instruction);
3039 }
3040 }
3041 report_duplicates(&mut cmds);
3042 findings
3043}
3044
3045fn rule_multiple_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3046 let eps: Vec<_> = instrs_of(instrs, "ENTRYPOINT");
3047 if eps.len() <= 1 {
3048 return vec![];
3049 }
3050 eps[1..]
3051 .iter()
3052 .map(|i| Finding {
3053 column: 0,
3054 end_line: 0,
3055 end_column: 0,
3056 rule: "DF039".into(),
3057 severity: Severity::Error,
3058 line: i.line,
3059 message: "Multiple ENTRYPOINT instructions — only the last one takes effect"
3060 .to_string(),
3061 roast: "Two ENTRYPOINTs. Bold. Only the last one runs; the first is just expensive \
3062 furniture. Delete it."
3063 .to_string(),
3064 })
3065 .collect()
3066}
3067
3068fn rule_no_user_instruction(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3069 if has_instr(instrs, "USER") {
3070 return vec![];
3071 }
3072 if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
3073 return vec![];
3074 }
3075 vec![Finding {
3076 column: 0,
3077 end_line: 0,
3078 end_column: 0,
3079 rule: "DF020".into(),
3080 severity: Severity::Warning,
3081 line: 0,
3082 message: "No USER instruction found — container will run as root by default".to_string(),
3083 roast: "No USER set? Bold strategy. Running everything as root in prod is a great way \
3084 to ensure job security — for your incident response team."
3085 .to_string(),
3086 }]
3087}
3088
3089fn rule_apt_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3090 let re = Regex::new(r"\bapt(-get)?\s+(dist-upgrade|upgrade)\b").unwrap();
3091 instrs_of(instrs, "RUN")
3092 .into_iter()
3093 .filter(|i| re.is_match(&i.arguments))
3094 .map(|i| Finding {
3095 column: 0,
3096 end_line: 0,
3097 end_column: 0,
3098 rule: "DF069".into(),
3099 severity: Severity::Warning,
3100 line: i.line,
3101 message: "apt-get upgrade/dist-upgrade makes builds non-reproducible".to_string(),
3102 roast:
3103 "apt-get upgrade: 'let's upgrade everything and see what breaks in six months'. \
3104 Your image will be different every time you build it. \
3105 Pin the packages you actually need instead of upgrading everything blindly."
3106 .to_string(),
3107 })
3108 .collect()
3109}
3110
3111fn rule_copy_before_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3112 const PKG_CMDS: &[&str] = &[
3113 "npm install",
3114 "npm ci",
3115 "pip install",
3116 "pip3 install",
3117 "yarn install",
3118 "yarn add",
3119 "bundle install",
3120 "composer install",
3121 "pnpm install",
3122 "bun install",
3123 ];
3124 let mut findings = Vec::new();
3125 let mut broad_copy_line: Option<usize> = None;
3126
3127 for i in instrs {
3128 match i.instruction.as_str() {
3129 "FROM" => {
3130 broad_copy_line = None;
3131 }
3132 "COPY" => {
3133 let tokens: Vec<&str> = i
3134 .arguments
3135 .split_whitespace()
3136 .filter(|t| !t.starts_with("--"))
3137 .collect();
3138 if tokens.len() >= 2 && (tokens[0] == "." || tokens[0].ends_with("/.")) {
3139 broad_copy_line = Some(i.line);
3140 }
3141 }
3142 "RUN" => {
3143 if let Some(copy_line) = broad_copy_line {
3144 if PKG_CMDS.iter().any(|cmd| i.arguments.contains(cmd))
3145 && !is_local_pip_install(&i.arguments)
3146 {
3147 findings.push(Finding {
3148 column: 0,
3149 end_line: 0,
3150 end_column: 0,
3151 rule: "DF070".into(),
3152 severity: Severity::Warning,
3153 line: copy_line,
3154 message: "COPY . before package install — invalidates Docker layer cache on every source change".to_string(),
3155 roast: "COPY . . before npm/pip install means every code change rebuilds \
3156 dependencies from scratch. Copy just the manifest first \
3157 (e.g. COPY package.json ./), run the install, then COPY . . — \
3158 now the install layer is cached between source changes.".to_string(),
3159 });
3160 broad_copy_line = None;
3161 }
3162 }
3163 }
3164 _ => {}
3165 }
3166 }
3167 findings
3168}