Skip to main content

dockerfile_roast/
rules.rs

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