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