Skip to main content

dockerfile_roast/
rules.rs

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