Skip to main content

dockerfile_roast/
rules.rs

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