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: "COPY --from must reference a previously defined stage",
391            func: rule_copy_from_undefined_stage,
392        },
393        Rule {
394            id: "DF050",
395            severity: Severity::Error,
396            description: "COPY --from cannot reference the current stage",
397            func: rule_copy_from_self,
398        },
399        Rule {
400            id: "DF051",
401            severity: Severity::Warning,
402            description: "Pin versions in pip install",
403            func: rule_pip_version_pinning,
404        },
405        Rule {
406            id: "DF052",
407            severity: Severity::Warning,
408            description: "Pin versions in apk add",
409            func: rule_apk_version_pinning,
410        },
411        Rule {
412            id: "DF053",
413            severity: Severity::Warning,
414            description: "Pin versions in gem install",
415            func: rule_gem_version_pinning,
416        },
417        Rule {
418            id: "DF054",
419            severity: Severity::Warning,
420            description: "Pin versions in go install with @version",
421            func: rule_go_install_version,
422        },
423        Rule {
424            id: "DF055",
425            severity: Severity::Info,
426            description: "Run yarn cache clean after yarn install",
427            func: rule_yarn_cache_clean,
428        },
429        Rule {
430            id: "DF056",
431            severity: Severity::Info,
432            description: "Use wget --progress=dot:giga to avoid bloated build logs",
433            func: rule_wget_no_progress,
434        },
435        Rule {
436            id: "DF057",
437            severity: Severity::Warning,
438            description: "Set -o pipefail before RUN commands that use pipes",
439            func: rule_pipefail_missing,
440        },
441        Rule {
442            id: "DF058",
443            severity: Severity::Warning,
444            description: "Use either wget or curl consistently, not both",
445            func: rule_wget_and_curl,
446        },
447        Rule {
448            id: "DF059",
449            severity: Severity::Warning,
450            description: "Use apt-get or apt-cache instead of apt in scripts",
451            func: rule_apt_instead_of_apt_get,
452        },
453        Rule {
454            id: "DF060",
455            severity: Severity::Info,
456            description: "Avoid running pointless interactive commands inside containers",
457            func: rule_useless_commands,
458        },
459        Rule {
460            id: "DF061",
461            severity: Severity::Warning,
462            description: "Do not use --platform in FROM unless required",
463            func: rule_from_platform_flag,
464        },
465        Rule {
466            id: "DF062",
467            severity: Severity::Error,
468            description: "ENV variable must not reference itself in the same statement",
469            func: rule_env_self_reference,
470        },
471        Rule {
472            id: "DF063",
473            severity: Severity::Warning,
474            description: "COPY to relative destination requires WORKDIR to be set first",
475            func: rule_copy_relative_no_workdir,
476        },
477        Rule {
478            id: "DF064",
479            severity: Severity::Warning,
480            description: "useradd without -l flag may create excessively large images",
481            func: rule_useradd_no_l,
482        },
483        Rule {
484            id: "DF065",
485            severity: Severity::Warning,
486            description: "FROM uses an unrecognised image registry",
487            func: rule_untrusted_registry,
488        },
489        Rule {
490            id: "DF066",
491            severity: Severity::Warning,
492            description: "Bash-specific syntax used without a SHELL instruction",
493            func: rule_bash_syntax_no_shell,
494        },
495        Rule {
496            id: "DF067",
497            severity: Severity::Info,
498            description: "COPY of a local archive — ADD auto-extracts tarballs",
499            func: rule_copy_archive_use_add,
500        },
501        Rule {
502            id: "DF068",
503            severity: Severity::Error,
504            description: "FROM, ONBUILD, and MAINTAINER are forbidden as ONBUILD triggers",
505            func: rule_onbuild_forbidden,
506        },
507        Rule {
508            id: "DF069",
509            severity: Severity::Warning,
510            description: "Avoid apt-get upgrade / dist-upgrade — makes builds non-reproducible",
511            func: rule_apt_upgrade,
512        },
513        Rule {
514            id: "DF070",
515            severity: Severity::Warning,
516            description: "Avoid broad COPY before package install — invalidates Docker layer cache",
517            func: rule_copy_before_install,
518        },
519        Rule {
520            id: "DF071",
521            severity: Severity::Error,
522            description: "Dockerfile syntax must be valid",
523            func: rule_parser_syntax,
524        },
525        Rule {
526            id: "DF072",
527            severity: Severity::Error,
528            description: "Suppression directives must satisfy policy",
529            func: rule_configured_policy,
530        },
531        Rule {
532            id: "DF073",
533            severity: Severity::Error,
534            description: "Base images must satisfy the approved image policy",
535            func: rule_configured_policy,
536        },
537        Rule {
538            id: "DF074",
539            severity: Severity::Error,
540            description: "Image labels must satisfy the configured schema",
541            func: rule_configured_policy,
542        },
543        Rule {
544            id: "DF075",
545            severity: Severity::Info,
546            description: "Containerfile.in must be linted after Podman CPP preprocessing",
547            func: rule_configured_policy,
548        },
549        Rule {
550            id: "DF076",
551            severity: Severity::Warning,
552            description: "Use a consistent casing style for Dockerfile instructions",
553            func: rule_consistent_instruction_casing,
554        },
555        Rule {
556            id: "DF077",
557            severity: Severity::Error,
558            description: "Do not COPY or ADD files excluded from the build context",
559            func: rule_configured_policy,
560        },
561        Rule {
562            id: "DF078",
563            severity: Severity::Warning,
564            description: "Use lowercase protocol names in EXPOSE",
565            func: rule_expose_proto_casing,
566        },
567        Rule {
568            id: "DF079",
569            severity: Severity::Warning,
570            description: "Match AS casing to FROM in multi-stage builds",
571            func: rule_from_as_casing,
572        },
573        Rule {
574            id: "DF082",
575            severity: Severity::Warning,
576            description: "Use key=value syntax for ENV and LABEL",
577            func: rule_legacy_key_value_format,
578        },
579        Rule {
580            id: "DF083",
581            severity: Severity::Warning,
582            description: "Do not set FROM --platform to the default target platform",
583            func: rule_redundant_target_platform,
584        },
585        Rule {
586            id: "DF084",
587            severity: Severity::Error,
588            description: "Do not use reserved Dockerfile stage names",
589            func: rule_reserved_stage_name,
590        },
591        Rule {
592            id: "DF085",
593            severity: Severity::Warning,
594            description: "Use lowercase multi-stage build names",
595            func: rule_stage_name_casing,
596        },
597        Rule {
598            id: "DF086",
599            severity: Severity::Error,
600            description: "Declare ARG variables used by FROM before the first FROM",
601            func: rule_undefined_arg_in_from,
602        },
603        Rule {
604            id: "DF087",
605            severity: Severity::Error,
606            description: "Declare Dockerfile variables before using them",
607            func: rule_undefined_variable,
608        },
609    ]
610}
611
612fn rule_configured_policy(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
613    Vec::new()
614}
615
616fn finding_at_span(rule: &str, severity: Severity, span: SourceSpan, message: String, roast: &str) -> Finding {
617    Finding {
618        rule: rule.into(),
619        severity,
620        line: span.start.line,
621        column: span.start.column,
622        end_line: span.end.line,
623        end_column: span.end.column,
624        message,
625        roast: roast.into(),
626    }
627}
628
629fn rule_consistent_instruction_casing(instrs: &[Instruction], raw: &str) -> Vec<Finding> {
630    let mut expected_uppercase = None;
631    let mut findings = Vec::new();
632    for instruction in instrs {
633        let spelling = instruction.keyword_span.text(raw);
634        let is_uppercase = spelling.bytes().all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase());
635        let is_lowercase = spelling.bytes().all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase());
636        if !is_uppercase && !is_lowercase {
637            findings.push(finding_at_span("DF076", Severity::Warning, instruction.keyword_span,
638                format!("Instruction '{}' uses mixed casing", spelling),
639                "Pick all-uppercase or all-lowercase Dockerfile instructions so the file stops shouting in two dialects."));
640            continue;
641        }
642        if let Some(expected) = expected_uppercase {
643            if expected != is_uppercase {
644                findings.push(finding_at_span("DF076", Severity::Warning, instruction.keyword_span,
645                    format!("Instruction '{}' does not match the file's instruction casing", spelling),
646                    "Your Dockerfile switches typography halfway through the build. Pick one instruction casing and commit to it."));
647            }
648        } else {
649            expected_uppercase = Some(is_uppercase);
650        }
651    }
652    findings
653}
654
655fn rule_expose_proto_casing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
656    instrs_of(instrs, "EXPOSE").into_iter().flat_map(|instruction| {
657        instruction.words.iter().filter_map(|word| {
658            let (_, protocol) = word.value.rsplit_once('/')?;
659            (protocol != protocol.to_ascii_lowercase()).then(|| finding_at_span("DF078", Severity::Warning, word.span,
660                format!("EXPOSE protocol '{}' should be lowercase", protocol),
661                "TCP and UDP are protocols, not acronyms in a ransom note. Use lowercase in EXPOSE."))
662        }).collect::<Vec<_>>()
663    }).collect()
664}
665
666fn rule_from_as_casing(instrs: &[Instruction], raw: &str) -> Vec<Finding> {
667    instrs_of(instrs, "FROM").into_iter().filter_map(|instruction| {
668        let from_spelling = instruction.keyword_span.text(raw);
669        let expected_uppercase = from_spelling.bytes().all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase());
670        instruction.words.iter().find(|word| word.value.eq_ignore_ascii_case("as")).and_then(|word| {
671            let is_uppercase = word.raw.bytes().all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase());
672            (expected_uppercase != is_uppercase).then(|| finding_at_span("DF079", Severity::Warning, word.span,
673                format!("'{}' should use the same casing as '{}'", word.raw, from_spelling),
674                "FROM and AS are on the same team. Give them matching uniforms."))
675        })
676    }).collect()
677}
678
679fn rule_legacy_key_value_format(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
680    instrs.iter().filter(|instruction| matches!(instruction.instruction.as_str(), "ENV" | "LABEL"))
681        .filter_map(|instruction| {
682            let words = &instruction.words;
683            (words.len() >= 2 && !words[0].value.contains('='))
684                .then(|| finding_at_span("DF082", Severity::Warning, words[0].span,
685                    format!("{} uses legacy space-separated key/value syntax", instruction.instruction),
686                    "Space-separated ENV and LABEL values are vintage Dockerfile syntax. Use key=value before it starts growing sideburns."))
687        }).collect()
688}
689
690fn rule_redundant_target_platform(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
691    instrs_of(instrs, "FROM").into_iter().flat_map(|instruction| instruction.flags.iter().filter_map(|flag| {
692        (flag.name.eq_ignore_ascii_case("platform") && flag.value.as_deref() == Some("$TARGETPLATFORM"))
693            .then(|| finding_at_span("DF083", Severity::Warning, flag.span,
694                "FROM --platform=$TARGETPLATFORM is redundant because it is the default".into(),
695                "That platform flag repeats Docker's default. The Dockerfile is narrating what Docker already knows."))
696    }).collect::<Vec<_>>()).collect()
697}
698
699fn rule_reserved_stage_name(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
700    instrs_of(instrs, "FROM").into_iter().filter_map(|instruction| {
701        let alias = parse_from_arguments(&instruction.arguments)?.alias?;
702        (alias.eq_ignore_ascii_case("scratch")).then(|| instruction.words.iter().find(|word| word.value == alias).map(|word|
703            finding_at_span("DF084", Severity::Error, word.span, "Stage name 'scratch' is reserved".into(),
704                "Calling a stage scratch is asking Docker to confuse your named stage with its special empty image."))).flatten()
705    }).collect()
706}
707
708fn rule_stage_name_casing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
709    instrs_of(instrs, "FROM").into_iter().filter_map(|instruction| {
710        let alias = parse_from_arguments(&instruction.arguments)?.alias?;
711        (alias != alias.to_ascii_lowercase()).then(|| instruction.words.iter().find(|word| word.value == alias).map(|word|
712            finding_at_span("DF085", Severity::Warning, word.span, format!("Stage name '{}' should be lowercase", alias),
713                "Stage names are case-sensitive, which is a terrible place for a surprise. Keep them lowercase."))).flatten()
714    }).collect()
715}
716
717fn global_args(instrs: &[Instruction]) -> std::collections::HashSet<String> {
718    instrs.iter().take_while(|instruction| instruction.instruction != "FROM")
719        .filter(|instruction| instruction.instruction == "ARG")
720        .filter_map(|instruction| instruction.words.first())
721        .map(|word| word.value.split('=').next().unwrap_or(&word.value).to_string())
722        .collect()
723}
724
725fn known_build_variable(name: &str) -> bool {
726    matches!(name, "BUILDPLATFORM" | "BUILDOS" | "BUILDARCH" | "BUILDVARIANT" | "TARGETPLATFORM" | "TARGETOS" | "TARGETARCH" | "TARGETVARIANT" | "HTTP_PROXY" | "HTTPS_PROXY" | "FTP_PROXY" | "NO_PROXY" | "ALL_PROXY")
727}
728
729fn rule_undefined_arg_in_from(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
730    let declared = global_args(instrs);
731    instrs_of(instrs, "FROM").into_iter().flat_map(|instruction| instruction.variables.iter().filter_map(|variable| {
732        (!declared.contains(&variable.name) && !known_build_variable(&variable.name)).then(|| finding_at_span("DF086", Severity::Error, variable.span,
733            format!("FROM references undefined ARG '{}'; declare it before the first FROM", variable.name),
734            "This FROM variable has no global ARG declaration. Docker cannot build an image from vibes."))
735    }).collect::<Vec<_>>()).collect()
736}
737
738fn rule_undefined_variable(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
739    let mut declared = global_args(instrs);
740    let mut findings = Vec::new();
741    for instruction in instrs {
742        if instruction.instruction == "ARG" || instruction.instruction == "ENV" {
743            for word in &instruction.words {
744                declared.insert(word.value.split('=').next().unwrap_or(&word.value).to_string());
745            }
746            continue;
747        }
748        if instruction.instruction == "RUN" { continue; }
749        for variable in &instruction.variables {
750            if !declared.contains(&variable.name) && !known_build_variable(&variable.name) {
751                findings.push(finding_at_span("DF087", Severity::Error, variable.span,
752                    format!("{} references undefined variable '{}'", instruction.instruction, variable.name),
753                    "That variable appears from nowhere. Declare it with ARG or ENV before Docker starts improvising."));
754            }
755        }
756    }
757    findings
758}
759
760fn rule_parser_syntax(_instrs: &[Instruction], raw: &str) -> Vec<Finding> {
761    parse_document(raw)
762        .diagnostics
763        .into_iter()
764        .map(|diagnostic| Finding {
765            column: 0,
766            end_line: 0,
767            end_column: 0,
768            rule: "DF071".into(),
769            severity: match diagnostic.severity {
770                DiagnosticSeverity::Warning => Severity::Warning,
771                DiagnosticSeverity::Error => Severity::Error,
772            },
773            line: diagnostic.span.start.line,
774            message: diagnostic.message,
775            roast: "The Dockerfile parser could not interpret this reliably; fix the syntax before trusting downstream lint results.".to_string(),
776        })
777        .collect()
778}
779
780fn instrs_of<'a>(instrs: &'a [Instruction], name: &str) -> Vec<&'a Instruction> {
781    instrs.iter().filter(|i| i.instruction == name).collect()
782}
783
784fn has_instr(instrs: &[Instruction], name: &str) -> bool {
785    instrs.iter().any(|i| i.instruction == name)
786}
787
788#[derive(Clone, Copy, Debug, PartialEq, Eq)]
789struct FromArguments<'a> {
790    image: &'a str,
791    alias: Option<&'a str>,
792}
793
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    instrs_of(instrs, "FROM")
1919        .into_iter()
1920        .filter(|i| i.arguments.contains("--platform"))
1921        .map(|i| Finding {
1922            column: 0,
1923            end_line: 0,
1924            end_column: 0,
1925            rule: "DF061".into(),
1926            severity: Severity::Warning,
1927            line: i.line,
1928            message: "FROM uses --platform flag — consider whether cross-platform targeting is intentional".to_string(),
1929            roast: "--platform in FROM forces a specific architecture. If you're building for \
1930                    amd64 but deploying on arm64, your image will be slow or broken. \
1931                    Make sure this is intentional and documented.".to_string(),
1932        })
1933        .collect()
1934}
1935
1936fn rule_env_self_reference(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1937    let re = Regex::new(r"(\w+)=\s*[\x22\x27]?\$\{?(\w+)\}?").unwrap();
1938    let mut findings = Vec::new();
1939    let mut stage_args = std::collections::HashSet::new();
1940    for i in instrs {
1941        if i.instruction == "FROM" {
1942            stage_args.clear();
1943            continue;
1944        }
1945        if i.instruction == "ARG" {
1946            if let Some(name) = i.arguments.split('=').next().and_then(|arg| arg.split_whitespace().next()) {
1947                stage_args.insert(name.to_string());
1948            }
1949            continue;
1950        }
1951        if i.instruction != "ENV" {
1952            continue;
1953        }
1954        for cap in re.captures_iter(&i.arguments) {
1955            let defined = &cap[1];
1956            let referenced = &cap[2];
1957            if defined == referenced && !stage_args.contains(referenced) {
1958                findings.push(Finding {
1959                    column: 0,
1960                    end_line: 0,
1961                    end_column: 0,
1962                    rule: "DF062".into(),
1963                    severity: Severity::Error,
1964                    line: i.line,
1965                    message: format!(
1966                        "ENV variable '{}' references itself in the same statement",
1967                        defined
1968                    ),
1969                    roast: format!(
1970                        "ENV {}=${{{}}} — you're defining a variable using itself. \
1971                         It hasn't been set yet at this point in the same ENV instruction. \
1972                         The result will be an empty string. Split it into two ENV statements.",
1973                        defined, referenced
1974                    ),
1975                });
1976                break;
1977            }
1978        }
1979    }
1980    findings
1981}
1982
1983fn rule_copy_relative_no_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1984    let mut stage_workdirs: std::collections::HashMap<String, bool> =
1985        std::collections::HashMap::new();
1986    let mut current_alias: Option<String> = None;
1987    let mut workdir_set = false;
1988    let mut findings = Vec::new();
1989    for i in instrs {
1990        if i.instruction == "FROM" {
1991            if let Some(from) = parse_from_arguments(&i.arguments) {
1992                workdir_set = stage_workdirs
1993                    .get(&from.image.to_lowercase())
1994                    .copied()
1995                    .unwrap_or(false);
1996                current_alias = from.alias.map(str::to_lowercase);
1997                if let Some(alias) = &current_alias {
1998                    stage_workdirs.insert(alias.clone(), workdir_set);
1999                }
2000            } else {
2001                workdir_set = false;
2002                current_alias = None;
2003            }
2004        } else if i.instruction == "WORKDIR" {
2005            workdir_set = true;
2006            if let Some(alias) = &current_alias {
2007                stage_workdirs.insert(alias.clone(), true);
2008            }
2009        } else if i.instruction == "COPY" {
2010            let args: Vec<&str> = i
2011                .arguments
2012                .split_whitespace()
2013                .filter(|t| !t.starts_with("--"))
2014                .collect();
2015            if let Some(dest) = args.last() {
2016                if !dest.starts_with('/') && !dest.starts_with('$') && !workdir_set {
2017                    findings.push(Finding {
2018                        column: 0,
2019                        end_line: 0,
2020                        end_column: 0,
2021                        rule: "DF063".into(),
2022                        severity: Severity::Warning,
2023                        line: i.line,
2024                        message: format!(
2025                            "COPY to relative destination '{}' but no WORKDIR has been set",
2026                            dest
2027                        ),
2028                        roast: format!(
2029                            "COPY to '{}' with no WORKDIR set. Relative destinations depend on \
2030                             the working directory, which defaults to /. \
2031                             Set WORKDIR explicitly before using relative paths.",
2032                            dest
2033                        ),
2034                    });
2035                }
2036            }
2037        }
2038    }
2039    findings
2040}
2041
2042fn rule_useradd_no_l(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2043    let re = Regex::new(r"\buseradd\b").unwrap();
2044    instrs_of(instrs, "RUN")
2045        .into_iter()
2046        .filter(|i| {
2047            re.is_match(&i.arguments)
2048                && !i.arguments.contains(" -l")
2049                && !i.arguments.contains("--no-log-init")
2050        })
2051        .map(|i| Finding {
2052            column: 0,
2053            end_line: 0,
2054            end_column: 0,
2055            rule: "DF064".into(),
2056            severity: Severity::Warning,
2057            line: i.line,
2058            message:
2059                "useradd without -l flag — high UIDs create oversized /var/log/lastlog entries"
2060                    .to_string(),
2061            roast: "useradd without -l (--no-log-init): with a high UID, this creates a sparse \
2062                    file in /var/log/lastlog that can balloon your image size by gigabytes. \
2063                    Add -l or use --no-log-init."
2064                .to_string(),
2065        })
2066        .collect()
2067}
2068
2069fn rule_copy_archive_use_add(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2070    const ARCHIVE_EXTS: &[&str] = &[".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar"];
2071    instrs_of(instrs, "COPY")
2072        .into_iter()
2073        .filter(|i| {
2074            // Ignore multi-stage COPY --from=... (the source is a container path, not a local file)
2075            if i.arguments.contains("--from=") || i.arguments.contains("--from =") {
2076                return false;
2077            }
2078            let sources: Vec<&str> = i
2079                .arguments
2080                .split_whitespace()
2081                .filter(|t| !t.starts_with("--"))
2082                .collect();
2083            // Need at least one source and one destination
2084            if sources.len() < 2 {
2085                return false;
2086            }
2087            // Check if any source (all but last) is an archive
2088            sources[..sources.len() - 1]
2089                .iter()
2090                .any(|s| ARCHIVE_EXTS.iter().any(|ext| s.ends_with(ext)))
2091        })
2092        .map(|i| Finding {
2093            column: 0,
2094            end_line: 0,
2095            end_column: 0,
2096            rule: "DF067".into(),
2097            severity: Severity::Info,
2098            line: i.line,
2099            message: "COPY of archive file — consider ADD which auto-extracts local tarballs"
2100                .to_string(),
2101            roast: "COPY drops the compressed archive as-is; you'll need a separate \
2102                    RUN tar -xzf layer to unpack it. ADD auto-extracts local tarballs into \
2103                    the destination directory and saves you the extra layer. \
2104                    Yes, this is the one situation where ADD is actually the right choice."
2105                .to_string(),
2106        })
2107        .collect()
2108}
2109
2110fn rule_onbuild_forbidden(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2111    const FORBIDDEN: &[&str] = &["FROM", "ONBUILD", "MAINTAINER"];
2112    let mut findings = Vec::new();
2113    for i in instrs_of(instrs, "ONBUILD") {
2114        let triggered = i
2115            .arguments
2116            .split_whitespace()
2117            .next()
2118            .unwrap_or("")
2119            .to_uppercase();
2120        if FORBIDDEN.contains(&triggered.as_str()) {
2121            findings.push(Finding {
2122                column: 0,
2123                end_line: 0,
2124                end_column: 0,
2125                rule: "DF068".into(),
2126                severity: Severity::Error,
2127                line: i.line,
2128                message: format!(
2129                    "ONBUILD {} is forbidden — {} cannot be used as an ONBUILD trigger",
2130                    triggered, triggered
2131                ),
2132                roast: format!(
2133                    "ONBUILD {} is explicitly prohibited by Docker. \
2134                     FROM would create a recursive inheritance loop, \
2135                     ONBUILD ONBUILD is a depth-2 trap nobody asked for, \
2136                     and MAINTAINER is deprecated everywhere, including here. \
2137                     This fails at build time.",
2138                    triggered
2139                ),
2140            });
2141        }
2142    }
2143    findings
2144}
2145
2146fn rule_bash_syntax_no_shell(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2147    // If an explicit SHELL instruction is present, the developer knows what they're doing
2148    if has_instr(instrs, "SHELL") {
2149        return vec![];
2150    }
2151    // Patterns that are valid bash but not POSIX sh — meaningless or broken on /bin/sh
2152    const BASH_ONLY: &[(&str, &str)] = &[
2153        ("[[ ", "double-bracket conditional"),
2154        ("source ", "source builtin (use '.' in POSIX sh)"),
2155        ("declare ", "declare builtin"),
2156        ("mapfile ", "mapfile builtin"),
2157        ("readarray ", "readarray builtin"),
2158        ("${!", "indirect variable expansion"),
2159    ];
2160    let mut findings = Vec::new();
2161    for i in instrs_of(instrs, "RUN") {
2162        for (pattern, label) in BASH_ONLY {
2163            if i.arguments.contains(pattern) {
2164                findings.push(Finding {
2165                    column: 0,
2166                    end_line: 0,
2167                    end_column: 0,
2168                    rule: "DF066".into(),
2169                    severity: Severity::Warning,
2170                    line: i.line,
2171                    message: format!(
2172                        "RUN uses bash-specific syntax ({}) but no SHELL instruction is set",
2173                        label
2174                    ),
2175                    roast: format!(
2176                        "'{}' is bash syntax. The default shell is /bin/sh, which on Alpine, \
2177                         Debian-slim, and distroless is NOT bash. Add \
2178                         `SHELL [\"/bin/bash\", \"-c\"]` before this RUN or your build \
2179                         will fail in ways that are confusing to debug.",
2180                        pattern.trim()
2181                    ),
2182                });
2183                break;
2184            }
2185        }
2186    }
2187    findings
2188}
2189
2190fn rule_untrusted_registry(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2191    const TRUSTED: &[&str] = &[
2192        "docker.io",
2193        "registry-1.docker.io",
2194        "ghcr.io",
2195        "gcr.io",
2196        "quay.io",
2197        "mcr.microsoft.com",
2198        "registry.access.redhat.com",
2199        "public.ecr.aws",
2200        "registry.k8s.io",
2201        "k8s.gcr.io",
2202    ];
2203    let mut findings = Vec::new();
2204    for i in instrs_of(instrs, "FROM") {
2205        // Skip --platform=... flags to find the actual image reference
2206        let image = match i
2207            .arguments
2208            .split_whitespace()
2209            .find(|t| !t.starts_with("--"))
2210        {
2211            Some(img) => img,
2212            None => continue,
2213        };
2214        if image.eq_ignore_ascii_case("scratch") {
2215            continue;
2216        }
2217        // The registry is the first path component when it contains a dot or colon,
2218        // or is the literal "localhost". Plain names like "ubuntu" or "ubuntu:22.04"
2219        // with no slash imply docker.io — the colon there is the tag separator, not a port.
2220        if !image.contains('/') {
2221            continue;
2222        }
2223        let first = image
2224            .split('@')
2225            .next()
2226            .unwrap_or(image)
2227            .split('/')
2228            .next()
2229            .unwrap_or("");
2230        if (first.contains('.') || first.contains(':') || first == "localhost")
2231            && !TRUSTED.iter().any(|t| first.eq_ignore_ascii_case(t))
2232        {
2233            findings.push(Finding {
2234                column: 0,
2235                end_line: 0,
2236                end_column: 0,
2237                rule: "DF065".into(),
2238                severity: Severity::Warning,
2239                line: i.line,
2240                message: format!("FROM pulls from unrecognised registry '{}'", first),
2241                roast: format!(
2242                    "Pulling base images from '{}' — a registry you don't hear about at \
2243                     KubeCon. Supply-chain attacks love Dockerfiles that blindly trust \
2244                     random registries. Verify this is intentional and pin to a digest.",
2245                    first
2246                ),
2247            });
2248        }
2249    }
2250    findings
2251}
2252
2253fn rule_pipefail_missing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2254    let mut shell_has_pipefail = false;
2255    let mut findings = Vec::new();
2256
2257    for instruction in instrs {
2258        match instruction.instruction.as_str() {
2259            "FROM" => shell_has_pipefail = false,
2260            "SHELL" => {
2261                shell_has_pipefail = match &instruction.form {
2262                    InstructionForm::Json(arguments) => {
2263                        arguments
2264                            .windows(2)
2265                            .any(|pair| pair[0] == "-o" && pair[1] == "pipefail")
2266                            || arguments.iter().any(|argument| argument == "-opipefail")
2267                    }
2268                    _ => false,
2269                };
2270            }
2271            "RUN" => {
2272                let a = &instruction.arguments;
2273                // has a pipe that isn't curl|sh (that's covered separately) and isn't pipefail already set
2274                if a.contains(" | ")
2275                    && !shell_has_pipefail
2276                    && !a.contains("pipefail")
2277                    && !a.contains("set -o pipefail")
2278                    && !a.contains("set -eo pipefail")
2279                    && !a.contains("set -euo pipefail")
2280                    // only flag if it's not a trivial pipe to tee/grep/wc for log filtering
2281                    && !a.trim_start().starts_with("set ")
2282                {
2283                    findings.push(Finding {
2284                        column: 0,
2285                        end_line: 0,
2286                        end_column: 0,
2287                        rule: "DF057".into(),
2288                        severity: Severity::Warning,
2289                        line: instruction.line,
2290                        message: "RUN with pipe but no pipefail — failed commands in the pipe are silently ignored"
2291                            .to_string(),
2292                        roast: "A pipe in RUN without `set -o pipefail`. If the left side of that pipe fails, \
2293                                bash shrugs and moves on. The exit code is whatever the last command returns. \
2294                                Add `set -o pipefail` at the start of the RUN."
2295                            .to_string(),
2296                    });
2297                }
2298            }
2299            _ => {}
2300        }
2301    }
2302
2303    findings
2304}
2305
2306fn rule_wget_and_curl(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2307    let uses_wget = instrs_of(instrs, "RUN")
2308        .iter()
2309        .any(|i| i.arguments.contains("wget "));
2310    let uses_curl = instrs_of(instrs, "RUN")
2311        .iter()
2312        .any(|i| i.arguments.contains("curl "));
2313    if uses_wget && uses_curl {
2314        return vec![Finding {
2315            column: 0,
2316            end_line: 0,
2317            end_column: 0,
2318            rule: "DF058".into(),
2319            severity: Severity::Warning,
2320            line: 0,
2321            message: "Both wget and curl are used — pick one and use it consistently".to_string(),
2322            roast: "You're using both wget and curl in the same Dockerfile. They do the same \
2323                    thing. Pick one. Commit to it. Your image doesn't need two download tools \
2324                    any more than it needs two fire extinguishers."
2325                .to_string(),
2326        }];
2327    }
2328    vec![]
2329}
2330
2331fn rule_yarn_cache_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2332    instrs_of(instrs, "RUN")
2333        .into_iter()
2334        .filter(|i| {
2335            let a = &i.arguments;
2336            (a.contains("yarn install") || a.contains("yarn add"))
2337                && !a.contains("yarn cache clean")
2338        })
2339        .map(|i| Finding {
2340            column: 0,
2341            end_line: 0,
2342            end_column: 0,
2343            rule: "DF055".into(),
2344            severity: Severity::Info,
2345            line: i.line,
2346            message: "yarn install without yarn cache clean — yarn cache is left in the image"
2347                .to_string(),
2348            roast: "yarn install without cleaning the cache. Yarn dutifully stores downloaded \
2349                    packages in a cache that you are now shipping to production. \
2350                    Add `&& yarn cache clean` after install."
2351                .to_string(),
2352        })
2353        .collect()
2354}
2355
2356fn rule_wget_no_progress(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2357    instrs_of(instrs, "RUN")
2358        .into_iter()
2359        .filter(|i| {
2360            let a = &i.arguments;
2361            a.contains("wget ")
2362                && !a.contains("--progress")
2363                && !a.contains("-q")
2364                && !a.contains("--quiet")
2365                && (a.contains("http://") || a.contains("https://") || a.contains("ftp://"))
2366        })
2367        .map(|i| Finding {
2368            column: 0,
2369            end_line: 0,
2370            end_column: 0,
2371            rule: "DF056".into(),
2372            severity: Severity::Info,
2373            line: i.line,
2374            message: "wget without --progress flag produces verbose progress output in build logs"
2375                .to_string(),
2376            roast: "wget without --progress=dot:giga will spam your build logs with a progress \
2377                    bar that looks great locally and fills 50MB of CI log storage. \
2378                    Use --progress=dot:giga or -q to stay quiet."
2379                .to_string(),
2380        })
2381        .collect()
2382}
2383
2384fn rule_pip_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2385    instrs_of(instrs, "RUN")
2386        .into_iter()
2387        .filter(|i| {
2388            let a = &i.arguments;
2389            (a.contains("pip install") || a.contains("pip3 install"))
2390                && !a.contains("-r ")
2391                && !a.contains("--requirement")
2392                && !a.contains("==")
2393                && !a.contains(">=")
2394                && !a.contains("<=")
2395                && !a.contains("~=")
2396                && !a.contains(".txt")
2397                && !is_local_pip_install(a)
2398        })
2399        .map(|i| Finding {
2400            column: 0,
2401            end_line: 0,
2402            end_column: 0,
2403            rule: "DF051".into(),
2404            severity: Severity::Warning,
2405            line: i.line,
2406            message:
2407                "pip install without version pinning — use package==version for reproducibility"
2408                    .to_string(),
2409            roast: "pip install with no version pins. Every build pulls 'latest' and \
2410                    one day something breaks and you spend three hours bisecting which \
2411                    transitive dependency changed. Use package==version."
2412                .to_string(),
2413        })
2414        .collect()
2415}
2416
2417fn is_local_pip_install(command: &str) -> bool {
2418    let Some(install) = command.find("pip install") else {
2419        return false;
2420    };
2421    command[install + "pip install".len()..]
2422        .split_whitespace()
2423        .filter(|argument| !argument.starts_with('-'))
2424        .any(|argument| {
2425            matches!(argument, "." | "./")
2426                || argument.starts_with("./")
2427                || argument.starts_with("../")
2428                || argument.starts_with("file:")
2429        })
2430}
2431
2432fn rule_apk_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2433    instrs_of(instrs, "RUN")
2434        .into_iter()
2435        .filter(|i| {
2436            let a = &i.arguments;
2437            if !a.contains("apk add") {
2438                return false;
2439            }
2440            // check if any non-flag arg after "add" has no = for version pinning
2441            let after_add = match a.find("apk add") {
2442                Some(pos) => &a[pos + 7..],
2443                None => return false,
2444            };
2445            after_add
2446                .split_whitespace()
2447                .filter(|t| !t.starts_with('-') && !t.is_empty())
2448                .any(|t| !t.contains('=') && !t.contains('>') && !t.contains('<'))
2449        })
2450        .map(|i| Finding {
2451            column: 0,
2452            end_line: 0,
2453            end_column: 0,
2454            rule: "DF052".into(),
2455            severity: Severity::Warning,
2456            line: i.line,
2457            message: "apk add without version pinning — use package=version for reproducibility"
2458                .to_string(),
2459            roast: "apk add with no version? You chose Alpine to be minimal and fast, then \
2460                    immediately added unpinned packages. Your builds are non-deterministic \
2461                    by design now. Use package=version."
2462                .to_string(),
2463        })
2464        .collect()
2465}
2466
2467fn rule_gem_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2468    instrs_of(instrs, "RUN")
2469        .into_iter()
2470        .filter(|i| {
2471            let a = &i.arguments;
2472            a.contains("gem install")
2473                && !a.contains(" -v ")
2474                && !a.contains("--version")
2475                && !a.contains(':')
2476        })
2477        .map(|i| Finding {
2478            column: 0,
2479            end_line: 0,
2480            end_column: 0,
2481            rule: "DF053".into(),
2482            severity: Severity::Warning,
2483            line: i.line,
2484            message: "gem install without version pinning — use gem install <gem>:<version>"
2485                .to_string(),
2486            roast: "gem install with no version. RubyGems will grab whatever's latest today. \
2487                    Next week it grabs something else. Your builds are a dice roll. \
2488                    Use gem install name:version."
2489                .to_string(),
2490        })
2491        .collect()
2492}
2493
2494fn rule_go_install_version(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2495    instrs_of(instrs, "RUN")
2496        .into_iter()
2497        .filter(|i| {
2498            i.arguments
2499                .split(['&', '|', ';'])
2500                .any(|segment| {
2501                    let mut words = segment.split_whitespace();
2502
2503                    // Environment assignments may precede the command, but the
2504                    // executable itself must be `go`; a substring match would
2505                    // mistake `cargo install` for `go install`.
2506                    let executable = loop {
2507                        match words.next() {
2508                            Some(word)
2509                                if word.contains('=')
2510                                    && !word.starts_with('=')
2511                                    && !word.contains('/') => continue,
2512                            word => break word,
2513                        }
2514                    };
2515
2516                    executable == Some("go")
2517                        && words.next() == Some("install")
2518                        && !segment.contains('@')
2519                })
2520        })
2521        .map(|i| Finding {
2522            column: 0,
2523            end_line: 0,
2524            end_column: 0,
2525            rule: "DF054".into(),
2526            severity: Severity::Warning,
2527            line: i.line,
2528            message: "go install without @version — use go install package@version".to_string(),
2529            roast: "go install without @version. The Go toolchain requires a version suffix \
2530                    in module-aware mode. Use `go install pkg@v1.2.3` or at minimum `@latest` \
2531                    if you enjoy living dangerously."
2532                .to_string(),
2533        })
2534        .collect()
2535}
2536
2537fn rule_copy_multi_arg_slash(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2538    instrs_of(instrs, "COPY")
2539        .into_iter()
2540        .filter(|i| {
2541            let args: Vec<&str> = i
2542                .arguments
2543                .split_whitespace()
2544                .filter(|t| !t.starts_with("--"))
2545                .collect();
2546            if args.len() > 2 {
2547                let dest = args.last().unwrap_or(&"");
2548                !dest.ends_with('/')
2549            } else {
2550                false
2551            }
2552        })
2553        .map(|i| Finding {
2554            column: 0,
2555            end_line: 0,
2556            end_column: 0,
2557            rule: "DF048".into(),
2558            severity: Severity::Error,
2559            line: i.line,
2560            message: "COPY with multiple sources requires the destination to end with /"
2561                .to_string(),
2562            roast: "COPY with multiple sources and a destination that doesn't end with /? \
2563                    Docker will complain. Or worse, silently do something weird. \
2564                    Add a trailing slash to the destination."
2565                .to_string(),
2566        })
2567        .collect()
2568}
2569
2570fn rule_copy_from_undefined_stage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2571    let mut defined_aliases: Vec<String> = Vec::new();
2572    let mut findings = Vec::new();
2573    let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
2574    for i in instrs {
2575        if i.instruction == "FROM" {
2576            if let Some(alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias) {
2577                defined_aliases.push(alias.to_lowercase());
2578            }
2579        } else if i.instruction == "COPY" {
2580            if let Some(cap) = re_from.captures(&i.arguments) {
2581                let from_ref = cap[1].to_lowercase();
2582                // skip numeric references like --from=0
2583                if from_ref.parse::<usize>().is_ok() {
2584                    continue;
2585                }
2586                if !defined_aliases.contains(&from_ref) {
2587                    findings.push(Finding {
2588                        column: 0,
2589                        end_line: 0,
2590                        end_column: 0,
2591                        rule: "DF049".into(),
2592                        severity: Severity::Warning,
2593                        line: i.line,
2594                        message: format!(
2595                            "COPY --from={} references an undefined build stage",
2596                            &cap[1]
2597                        ),
2598                        roast: format!(
2599                            "COPY --from={} and there's no FROM ... AS {} anywhere above. \
2600                             Copying from thin air. Docker will reject this.",
2601                            &cap[1], &cap[1]
2602                        ),
2603                    });
2604                }
2605            }
2606        }
2607    }
2608    findings
2609}
2610
2611fn rule_copy_from_self(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2612    let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
2613    let mut current_alias: Option<String> = None;
2614    let mut findings = Vec::new();
2615    for i in instrs {
2616        if i.instruction == "FROM" {
2617            current_alias = parse_from_arguments(&i.arguments)
2618                .and_then(|from| from.alias)
2619                .map(str::to_lowercase);
2620        } else if i.instruction == "COPY" {
2621            if let Some(cap) = re_from.captures(&i.arguments) {
2622                let from_ref = cap[1].to_lowercase();
2623                if let Some(ref alias) = current_alias {
2624                    if &from_ref == alias {
2625                        findings.push(Finding {
2626            column: 0,
2627            end_line: 0,
2628            end_column: 0,
2629                            rule: "DF050".into(),
2630                            severity: Severity::Error,
2631                            line: i.line,
2632                            message: format!(
2633                                "COPY --from={} references the current build stage — circular dependency",
2634                                &cap[1]
2635                            ),
2636                            roast: format!(
2637                                "COPY --from={} inside the same stage named {}. \
2638                                 That's a circular reference. Docker cannot copy from itself. \
2639                                 This will fail at build time.",
2640                                &cap[1], &cap[1]
2641                            ),
2642                        });
2643                    }
2644                }
2645            }
2646        }
2647    }
2648    findings
2649}
2650
2651fn rule_dnf_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2652    instrs_of(instrs, "RUN")
2653        .into_iter()
2654        .filter(|i| {
2655            let a = &i.arguments;
2656            a.contains("dnf install") && !a.contains("dnf clean all") && !a.contains("dnf clean")
2657        })
2658        .map(|i| Finding {
2659            column: 0,
2660            end_line: 0,
2661            end_column: 0,
2662            rule: "DF046".into(),
2663            severity: Severity::Warning,
2664            line: i.line,
2665            message: "dnf clean all missing after dnf install — RPM cache bloats the image"
2666                .to_string(),
2667            roast: "dnf install without `dnf clean all` afterwards? You're shipping RPM cache \
2668                    metadata to production. That's not a feature. Add `&& dnf clean all`."
2669                .to_string(),
2670        })
2671        .collect()
2672}
2673
2674fn rule_yum_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2675    instrs_of(instrs, "RUN")
2676        .into_iter()
2677        .filter(|i| {
2678            let a = &i.arguments;
2679            a.contains("yum install") && !a.contains("yum clean all") && !a.contains("yum clean")
2680        })
2681        .map(|i| Finding {
2682            column: 0,
2683            end_line: 0,
2684            end_column: 0,
2685            rule: "DF047".into(),
2686            severity: Severity::Warning,
2687            line: i.line,
2688            message: "yum clean all missing after yum install — cache stays in the image"
2689                .to_string(),
2690            roast: "yum install without cleanup is just permanently housing the package cache in \
2691                    your image. Every MB of yum cache is a MB of shame in your registry."
2692                .to_string(),
2693        })
2694        .collect()
2695}
2696
2697fn rule_zypper_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2698    instrs_of(instrs, "RUN")
2699        .into_iter()
2700        .filter(|i| {
2701            let a = &i.arguments;
2702            (a.contains("zypper install") || a.contains("zypper in "))
2703                && !a.contains("-y")
2704                && !a.contains("--non-interactive")
2705                && !a.contains(" -n ")
2706                && !a.contains(" -n\n")
2707                && !a.starts_with("-n ")
2708        })
2709        .map(|i| Finding {
2710            column: 0,
2711            end_line: 0,
2712            end_column: 0,
2713            rule: "DF043".into(),
2714            severity: Severity::Warning,
2715            line: i.line,
2716            message: "zypper install without non-interactive flag (-y) will hang in a build"
2717                .to_string(),
2718            roast: "zypper install without -y in a container build? It'll wait for input that \
2719                    will never arrive, like a chatbot asking for emotional validation."
2720                .to_string(),
2721        })
2722        .collect()
2723}
2724
2725fn rule_zypper_dist_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2726    instrs_of(instrs, "RUN")
2727        .into_iter()
2728        .filter(|i| {
2729            i.arguments.contains("zypper dist-upgrade") || i.arguments.contains("zypper dup")
2730        })
2731        .map(|i| Finding {
2732            column: 0,
2733            end_line: 0,
2734            end_column: 0,
2735            rule: "DF044".into(),
2736            severity: Severity::Warning,
2737            line: i.line,
2738            message:
2739                "zypper dist-upgrade upgrades all packages unpredictably — avoid in Dockerfiles"
2740                    .to_string(),
2741            roast: "zypper dist-upgrade: the 'nuke everything and hope for the best' approach to \
2742                    package management. Your image will be different every single build. Congrats."
2743                .to_string(),
2744        })
2745        .collect()
2746}
2747
2748fn rule_zypper_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2749    instrs_of(instrs, "RUN")
2750        .into_iter()
2751        .filter(|i| {
2752            let a = &i.arguments;
2753            (a.contains("zypper install") || a.contains("zypper in "))
2754                && !a.contains("zypper clean")
2755                && !a.contains("zypper cc")
2756        })
2757        .map(|i| Finding {
2758            column: 0,
2759            end_line: 0,
2760            end_column: 0,
2761            rule: "DF045".into(),
2762            severity: Severity::Info,
2763            line: i.line,
2764            message: "zypper cache not cleaned after install — adds unnecessary image bloat"
2765                .to_string(),
2766            roast:
2767                "zypper install without `zypper clean --all` afterwards. You're hoarding package \
2768                    metadata in your image. Clean it up."
2769                    .to_string(),
2770        })
2771        .collect()
2772}
2773
2774fn rule_expose_port_range(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2775    let mut findings = Vec::new();
2776    for i in instrs_of(instrs, "EXPOSE") {
2777        for port_spec in i.arguments.split_whitespace() {
2778            let port_str = port_spec.split('/').next().unwrap_or(port_spec);
2779            if let Ok(port) = port_str.parse::<u32>() {
2780                if port > 65535 {
2781                    findings.push(Finding {
2782                        column: 0,
2783                        end_line: 0,
2784                        end_column: 0,
2785                        rule: "DF040".into(),
2786                        severity: Severity::Error,
2787                        line: i.line,
2788                        message: format!("EXPOSE port {} is out of valid range (0-65535)", port),
2789                        roast: format!(
2790                            "Port {}? That's not a port, that's a zip code. \
2791                             Valid UNIX ports are 0-65535. Pick a real one.",
2792                            port
2793                        ),
2794                    });
2795                }
2796            }
2797        }
2798    }
2799    findings
2800}
2801
2802fn rule_multiple_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2803    let checks: Vec<_> = instrs_of(instrs, "HEALTHCHECK");
2804    if checks.len() <= 1 {
2805        return vec![];
2806    }
2807    checks[1..]
2808        .iter()
2809        .map(|i| Finding {
2810            column: 0,
2811            end_line: 0,
2812            end_column: 0,
2813            rule: "DF041".into(),
2814            severity: Severity::Error,
2815            line: i.line,
2816            message: "Multiple HEALTHCHECK instructions — only the last one applies".to_string(),
2817            roast: "Multiple HEALTHCHECKs but only the last one counts. The earlier ones are \
2818                haunting your image for no reason. One health check, one truth."
2819                .to_string(),
2820        })
2821        .collect()
2822}
2823
2824fn rule_unique_stage_aliases(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2825    let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2826    let mut findings = Vec::new();
2827    for i in instrs_of(instrs, "FROM") {
2828        if let Some(original_alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias)
2829        {
2830            let alias = original_alias.to_lowercase();
2831            if let Some(&prev_line) = seen.get(&alias) {
2832                findings.push(Finding {
2833                    column: 0,
2834                    end_line: 0,
2835                    end_column: 0,
2836                    rule: "DF042".into(),
2837                    severity: Severity::Error,
2838                    line: i.line,
2839                    message: format!(
2840                        "FROM alias '{}' is already defined on line {}",
2841                        original_alias, prev_line
2842                    ),
2843                    roast: format!(
2844                        "Two stages named '{}'. Docker uses the last one; the first is dead code. \
2845                         Give your stages unique names.",
2846                        original_alias
2847                    ),
2848                });
2849            } else {
2850                seen.insert(alias, i.line);
2851            }
2852        }
2853    }
2854    findings
2855}
2856
2857fn rule_invalid_instruction_order(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2858    if instrs.is_empty() {
2859        return vec![];
2860    }
2861    let first = &instrs[0];
2862    if first.instruction != "FROM" && first.instruction != "ARG" {
2863        return vec![Finding {
2864            column: 0,
2865            end_line: 0,
2866            end_column: 0,
2867            rule: "DF037".into(),
2868            severity: Severity::Error,
2869            line: first.line,
2870            message: format!(
2871                "'{}' before FROM — Dockerfile must begin with FROM, ARG, or a comment",
2872                first.instruction
2873            ),
2874            roast: "Your Dockerfile doesn't start with FROM. That's like starting a recipe with \
2875                    'season to taste' before listing any ingredients. Docker is confused. So am I."
2876                .to_string(),
2877        }];
2878    }
2879    vec![]
2880}
2881
2882fn rule_multiple_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2883    let mut findings = Vec::new();
2884    let mut cmds = Vec::new();
2885    let mut report_duplicates = |cmds: &mut Vec<&Instruction>| {
2886        if cmds.len() > 1 {
2887            findings.extend(cmds.iter().skip(1).map(|i| Finding {
2888                column: 0,
2889                end_line: 0,
2890                end_column: 0,
2891                rule: "DF038".into(),
2892                severity: Severity::Warning,
2893                line: i.line,
2894                message: "Multiple CMD instructions — only the last one takes effect".to_string(),
2895                roast:
2896                    "Multiple CMDs and only the last one counts. The others are ghosts haunting your \
2897                    Dockerfile, contributing nothing except confusion. Pick one."
2898                        .to_string(),
2899            }));
2900        }
2901        cmds.clear();
2902    };
2903    for instruction in instrs {
2904        if instruction.instruction == "FROM" {
2905            report_duplicates(&mut cmds);
2906        }
2907        if instruction.instruction == "CMD" {
2908            cmds.push(instruction);
2909        }
2910    }
2911    report_duplicates(&mut cmds);
2912    findings
2913}
2914
2915fn rule_multiple_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2916    let eps: Vec<_> = instrs_of(instrs, "ENTRYPOINT");
2917    if eps.len() <= 1 {
2918        return vec![];
2919    }
2920    eps[1..]
2921        .iter()
2922        .map(|i| Finding {
2923            column: 0,
2924            end_line: 0,
2925            end_column: 0,
2926            rule: "DF039".into(),
2927            severity: Severity::Error,
2928            line: i.line,
2929            message: "Multiple ENTRYPOINT instructions — only the last one takes effect"
2930                .to_string(),
2931            roast: "Two ENTRYPOINTs. Bold. Only the last one runs; the first is just expensive \
2932                furniture. Delete it."
2933                .to_string(),
2934        })
2935        .collect()
2936}
2937
2938fn rule_no_user_instruction(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2939    if has_instr(instrs, "USER") {
2940        return vec![];
2941    }
2942    if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
2943        return vec![];
2944    }
2945    vec![Finding {
2946        column: 0,
2947        end_line: 0,
2948        end_column: 0,
2949        rule: "DF020".into(),
2950        severity: Severity::Warning,
2951        line: 0,
2952        message: "No USER instruction found — container will run as root by default".to_string(),
2953        roast: "No USER set? Bold strategy. Running everything as root in prod is a great way \
2954                to ensure job security — for your incident response team."
2955            .to_string(),
2956    }]
2957}
2958
2959fn rule_apt_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2960    let re = Regex::new(r"\bapt(-get)?\s+(dist-upgrade|upgrade)\b").unwrap();
2961    instrs_of(instrs, "RUN")
2962        .into_iter()
2963        .filter(|i| re.is_match(&i.arguments))
2964        .map(|i| Finding {
2965            column: 0,
2966            end_line: 0,
2967            end_column: 0,
2968            rule: "DF069".into(),
2969            severity: Severity::Warning,
2970            line: i.line,
2971            message: "apt-get upgrade/dist-upgrade makes builds non-reproducible".to_string(),
2972            roast:
2973                "apt-get upgrade: 'let's upgrade everything and see what breaks in six months'. \
2974                    Your image will be different every time you build it. \
2975                    Pin the packages you actually need instead of upgrading everything blindly."
2976                    .to_string(),
2977        })
2978        .collect()
2979}
2980
2981fn rule_copy_before_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2982    const PKG_CMDS: &[&str] = &[
2983        "npm install",
2984        "npm ci",
2985        "pip install",
2986        "pip3 install",
2987        "yarn install",
2988        "yarn add",
2989        "bundle install",
2990        "composer install",
2991        "pnpm install",
2992        "bun install",
2993    ];
2994    let mut findings = Vec::new();
2995    let mut broad_copy_line: Option<usize> = None;
2996
2997    for i in instrs {
2998        match i.instruction.as_str() {
2999            "FROM" => {
3000                broad_copy_line = None;
3001            }
3002            "COPY" => {
3003                let tokens: Vec<&str> = i
3004                    .arguments
3005                    .split_whitespace()
3006                    .filter(|t| !t.starts_with("--"))
3007                    .collect();
3008                if tokens.len() >= 2 && (tokens[0] == "." || tokens[0].ends_with("/.")) {
3009                    broad_copy_line = Some(i.line);
3010                }
3011            }
3012            "RUN" => {
3013                if let Some(copy_line) = broad_copy_line {
3014                    if PKG_CMDS.iter().any(|cmd| i.arguments.contains(cmd))
3015                        && !is_local_pip_install(&i.arguments)
3016                    {
3017                        findings.push(Finding {
3018            column: 0,
3019            end_line: 0,
3020            end_column: 0,
3021                            rule: "DF070".into(),
3022                            severity: Severity::Warning,
3023                            line: copy_line,
3024                            message: "COPY . before package install — invalidates Docker layer cache on every source change".to_string(),
3025                            roast: "COPY . . before npm/pip install means every code change rebuilds \
3026                                    dependencies from scratch. Copy just the manifest first \
3027                                    (e.g. COPY package.json ./), run the install, then COPY . . — \
3028                                    now the install layer is cached between source changes.".to_string(),
3029                        });
3030                        broad_copy_line = None;
3031                    }
3032                }
3033            }
3034            _ => {}
3035        }
3036    }
3037    findings
3038}