Skip to main content

dockerfile_roast/
rules.rs

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