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        // A build argument can supply either a tagged image or a digest at
818        // build time. Do not claim it is unpinned when that value is unknown.
819        if base.contains('$') {
820            if let Some(alias) = from.alias {
821                stage_aliases.insert(alias.to_lowercase());
822            }
823            continue;
824        }
825        let is_previous_stage = stage_aliases.contains(&base.to_lowercase());
826        if !is_previous_stage
827            && !base.eq_ignore_ascii_case("scratch")
828            && (base.ends_with(":latest") || (!base.contains(':') && !base.contains('@')))
829        {
830            findings.push(Finding {
831                column: 0,
832                end_line: 0,
833                end_column: 0,
834                rule: "DF001".into(),
835                severity: Severity::Warning,
836                line: instruction.line,
837                message: format!("'{}' uses an unpinned image tag", base),
838                roast: "Pinning to 'latest' is like ordering 'whatever' at a restaurant and then \
839                        complaining when your image breaks in prod. Use a real tag."
840                    .to_string(),
841            });
842        }
843        if let Some(alias) = from.alias {
844            stage_aliases.insert(alias.to_lowercase());
845        }
846    }
847
848    findings
849}
850
851fn rule_running_as_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
852    let mut findings = Vec::new();
853    let mut effective_user: Option<&Instruction> = None;
854    let mut report_root_user = |user: Option<&Instruction>| {
855        if let Some(u) = user.filter(|user| is_root_user(&user.arguments)) {
856            findings.push(Finding {
857                column: 0,
858                end_line: 0,
859                end_column: 0,
860                rule: "DF002".into(),
861                severity: Severity::Error,
862                line: u.line,
863                message: "Container is explicitly set to run as root".to_string(),
864                roast: "Congratulations, you're running as root. Your security team is crying, \
865                        your CISO is drafting a strongly-worded email, and a hacker somewhere \
866                        just smiled."
867                    .to_string(),
868            });
869        }
870    };
871    for instruction in instrs {
872        match instruction.instruction.as_str() {
873            "FROM" => {
874                report_root_user(effective_user);
875                effective_user = None;
876            }
877            "USER" => effective_user = Some(instruction),
878            _ => {}
879        }
880    }
881    report_root_user(effective_user);
882    findings
883}
884
885fn is_root_user(value: &str) -> bool {
886    matches!(value.trim().to_lowercase().as_str(), "root" | "0" | "0:0" | "root:root")
887}
888
889fn rule_no_multistage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
890    let from_count = instrs_of(instrs, "FROM").len();
891    if from_count > 1 {
892        return vec![];
893    }
894    let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
895        Some(f) => f,
896        None => return vec![],
897    };
898    let build_images = [
899        "golang", "node", "rust", "maven", "gradle", "openjdk", "python", "dotnet", "gcc",
900    ];
901    let img = first_from.arguments.to_lowercase();
902    if build_images.iter().any(|b| img.contains(b)) {
903        return vec![Finding {
904            column: 0,
905            end_line: 0,
906            end_column: 0,
907            rule: "DF011".into(),
908            severity: Severity::Warning,
909            line: first_from.line,
910            message: "Single-stage build with a heavy build image — consider multi-stage builds"
911                .to_string(),
912            roast: "Shipping your entire build toolchain to production? Your 2GB Go image is \
913                    basically a free gift to anyone who gets shell access. Multi-stage builds \
914                    exist. They're fantastic. Use them."
915                .to_string(),
916        }];
917    }
918    vec![]
919}
920
921fn rule_many_run_layers(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
922    let mut findings = Vec::new();
923    let mut consecutive = 0usize;
924    let mut start_line = 0usize;
925    for i in instrs {
926        if i.instruction == "RUN" {
927            if consecutive == 0 {
928                start_line = i.line;
929            }
930            consecutive += 1;
931        } else if i.instruction == "FROM" {
932            consecutive = 0;
933        } else if consecutive > 0 {
934            if consecutive >= 4 {
935                findings.push(Finding {
936                    column: 0,
937                    end_line: 0,
938                    end_column: 0,
939                    rule: "DF003".into(),
940                    severity: Severity::Warning,
941                    line: start_line,
942                    message: format!(
943                        "{} consecutive RUN instructions could be merged into one",
944                        consecutive
945                    ),
946                    roast: format!(
947                        "{} separate RUN layers? Your image has more layers than a mid-2000s emo \
948                         band. Combine them with && and save everyone's bandwidth.",
949                        consecutive
950                    ),
951                });
952            }
953            consecutive = 0;
954        }
955    }
956    if consecutive >= 4 {
957        findings.push(Finding {
958            column: 0,
959            end_line: 0,
960            end_column: 0,
961            rule: "DF003".into(),
962            severity: Severity::Warning,
963            line: start_line,
964            message: format!("{} consecutive RUN instructions could be merged into one", consecutive),
965            roast: format!(
966                "{} separate RUN layers? Your image is basically an onion — except nobody's \
967                 crying because it's beautiful; they're crying because it takes 10 minutes to pull.", consecutive
968            ),
969        });
970    }
971    findings
972}
973
974fn rule_add_instead_of_copy(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
975    instrs_of(instrs, "ADD")
976        .into_iter()
977        .filter(|i| {
978            // Skip --chown, --checksum and other flags to find the real source argument
979            let source = match i
980                .arguments
981                .split_whitespace()
982                .find(|t| !t.starts_with("--"))
983            {
984                Some(s) => s,
985                None => return false,
986            };
987            let is_url = source.contains("://");
988            let is_archive = source.ends_with(".tar.gz")
989                || source.ends_with(".tgz")
990                || source.ends_with(".tar.xz")
991                || source.ends_with(".tar.bz2")
992                || source.ends_with(".tar");
993            !is_url && !is_archive
994        })
995        .map(|i| Finding {
996            column: 0,
997            end_line: 0,
998            end_column: 0,
999            rule: "DF006".into(),
1000            severity: Severity::Warning,
1001            line: i.line,
1002            message: "ADD used for local file — prefer COPY".to_string(),
1003            roast: "Using ADD to copy local files is like taking a helicopter to cross the \
1004                    street. COPY exists, it's right there, it's boring and correct — which is \
1005                    everything you want in infrastructure."
1006                .to_string(),
1007        })
1008        .collect()
1009}
1010
1011fn rule_copy_all(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1012    instrs_of(instrs, "COPY")
1013        .into_iter()
1014        .filter(|i| {
1015            let a = i.arguments.trim();
1016            a.starts_with(". ") || a == "."
1017        })
1018        .map(|i| Finding {
1019            column: 0,
1020            end_line: 0,
1021            end_column: 0,
1022            rule: "DF007".into(),
1023            severity: Severity::Warning,
1024            line: i.line,
1025            message: "COPY . copies the entire build context — consider a .dockerignore file"
1026                .to_string(),
1027            roast: "COPY . — dumping your entire project including node_modules, .git history, \
1028                    and that .env file with the production database password into the image. \
1029                    Bold. Reckless. Very DevOps of you."
1030                .to_string(),
1031        })
1032        .collect()
1033}
1034
1035fn rule_cd_instead_of_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1036    let re = Regex::new(r"\bcd\s+[^\s;|&]+").unwrap();
1037    instrs_of(instrs, "RUN")
1038        .into_iter()
1039        .filter(|i| re.is_match(&i.arguments))
1040        .map(|i| Finding {
1041            column: 0,
1042            end_line: 0,
1043            end_column: 0,
1044            rule: "DF008".into(),
1045            severity: Severity::Info,
1046            line: i.line,
1047            message: "Using 'cd' in RUN — prefer WORKDIR instruction".to_string(),
1048            roast: "`cd` in a RUN instruction: not wrong, but every new RUN starts fresh anyway, \
1049                    so you're cosplaying as a shell script when you should be writing a Dockerfile. \
1050                    WORKDIR is your friend.".to_string(),
1051        })
1052        .collect()
1053}
1054
1055fn rule_relative_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1056    instrs_of(instrs, "WORKDIR")
1057        .into_iter()
1058        .filter(|i| {
1059            let path = i.arguments.trim().trim_matches(['\'', '"']);
1060            !path.starts_with('/') && !path.starts_with('$')
1061        })
1062        .map(|i| Finding {
1063            column: 0,
1064            end_line: 0,
1065            end_column: 0,
1066            rule: "DF009".into(),
1067            severity: Severity::Warning,
1068            line: i.line,
1069            message: format!(
1070                "WORKDIR '{}' is relative — use an absolute path",
1071                i.arguments.trim()
1072            ),
1073            roast: "A relative WORKDIR? You're setting your working directory relative to... \
1074                    what, exactly? Hope? Dreams? Use an absolute path like a grown-up."
1075                .to_string(),
1076        })
1077        .collect()
1078}
1079
1080fn rule_sudo_usage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1081    let re = Regex::new(r"\bsudo\b").unwrap();
1082    instrs_of(instrs, "RUN")
1083        .into_iter()
1084        .filter(|i| re.is_match(&i.arguments))
1085        .map(|i| Finding {
1086            column: 0,
1087            end_line: 0,
1088            end_column: 0,
1089            rule: "DF010".into(),
1090            severity: Severity::Warning,
1091            line: i.line,
1092            message: "sudo used inside a container — likely unnecessary".to_string(),
1093            roast: "sudo inside a Docker container? You're already root (probably). sudo is \
1094                    just a formality at this point, like putting a 'Wet Floor' sign in the ocean."
1095                .to_string(),
1096        })
1097        .collect()
1098}
1099
1100fn rule_no_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1101    if has_instr(instrs, "HEALTHCHECK") {
1102        return vec![];
1103    }
1104    if !has_instr(instrs, "EXPOSE") && !has_instr(instrs, "CMD") {
1105        return vec![];
1106    }
1107    vec![Finding {
1108        column: 0,
1109        end_line: 0,
1110        end_column: 0,
1111        rule: "DF012".into(),
1112        severity: Severity::Info,
1113        line: 0,
1114        message: "No HEALTHCHECK defined".to_string(),
1115        roast: "No HEALTHCHECK? Your container is basically on the honor system. 'It's fine, \
1116                I'm sure it's fine.' Meanwhile Kubernetes is just restarting it every 30 seconds \
1117                wondering what went wrong."
1118            .to_string(),
1119    }]
1120}
1121
1122fn rule_cmd_without_entrypoint(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1123    vec![]
1124}
1125
1126fn rule_shell_form_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1127    instrs_of(instrs, "ENTRYPOINT")
1128        .into_iter()
1129        .filter(|i| !i.arguments.trim().starts_with('['))
1130        .map(|i| Finding {
1131            column: 0,
1132            end_line: 0,
1133            end_column: 0,
1134            rule: "DF018".into(),
1135            severity: Severity::Warning,
1136            line: i.line,
1137            message: "ENTRYPOINT in shell form prevents signal propagation".to_string(),
1138            roast: "Shell-form ENTRYPOINT means your app runs as a child of /bin/sh. When \
1139                    Kubernetes sends SIGTERM, your app doesn't get it — /bin/sh does, and \
1140                    /bin/sh doesn't care. Use exec form: ENTRYPOINT [\"cmd\", \"arg\"]."
1141                .to_string(),
1142        })
1143        .collect()
1144}
1145
1146fn rule_deprecated_maintainer(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1147    instrs_of(instrs, "MAINTAINER")
1148        .into_iter()
1149        .map(|i| Finding {
1150            column: 0,
1151            end_line: 0,
1152            end_column: 0,
1153            rule: "DF019".into(),
1154            severity: Severity::Warning,
1155            line: i.line,
1156            message: "MAINTAINER is deprecated".to_string(),
1157            roast: "MAINTAINER has been deprecated since Docker 1.13. That was 2017. \
1158                    Your Dockerfile is old enough to be in middle school. \
1159                    Use LABEL maintainer=\"...\" like the rest of us."
1160                .to_string(),
1161        })
1162        .collect()
1163}
1164
1165fn rule_no_expose(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1166    if has_instr(instrs, "EXPOSE") {
1167        return vec![];
1168    }
1169    if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
1170        return vec![];
1171    }
1172    vec![Finding {
1173        column: 0,
1174        end_line: 0,
1175        end_column: 0,
1176        rule: "DF022".into(),
1177        severity: Severity::Info,
1178        line: 0,
1179        message: "No EXPOSE instruction — consider documenting which ports this service uses"
1180            .to_string(),
1181        roast: "No EXPOSE? Your container is a mystery box. Is it a web server? A database? \
1182                A very slow random number generator? EXPOSE is documentation — it tells the \
1183                next developer which port to knock on."
1184            .to_string(),
1185    }]
1186}
1187
1188fn rule_multiple_from_no_alias(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1189    let froms: Vec<_> = instrs_of(instrs, "FROM");
1190    let stage_count = froms.len();
1191    if stage_count <= 1 {
1192        return vec![];
1193    }
1194
1195    // A final stage cannot be referenced by a later COPY --from instruction, so
1196    // requiring it to have a stage name adds noise without improving safety.
1197    froms
1198        .into_iter()
1199        .take(stage_count - 1)
1200        .filter(|i| parse_from_arguments(&i.arguments).is_some_and(|from| from.alias.is_none()))
1201        .map(|i| Finding {
1202            column: 0,
1203            end_line: 0,
1204            end_column: 0,
1205            rule: "DF023".into(),
1206            severity: Severity::Warning,
1207            line: i.line,
1208            message: "Multi-stage FROM without AS alias — hard to reference later".to_string(),
1209            roast: "Multi-stage FROM without an alias. How will you COPY --from=... this? \
1210                    By index? \"--from=2\"? That's fragile. Give your stages names like \
1211                    a civilized person. FROM golang:1.21 AS builder."
1212                .to_string(),
1213        })
1214        .collect()
1215}
1216
1217fn rule_from_latest_alias(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1218    vec![]
1219}
1220
1221fn rule_shell_form_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1222    instrs_of(instrs, "CMD")
1223        .into_iter()
1224        .filter(|i| !i.arguments.trim().starts_with('['))
1225        .map(|i| Finding {
1226            column: 0,
1227            end_line: 0,
1228            end_column: 0,
1229            rule: "DF025".into(),
1230            severity: Severity::Warning,
1231            line: i.line,
1232            message: "CMD in shell form — prefer exec form [\"executable\", \"arg\"]".to_string(),
1233            roast: "Shell-form CMD wraps your process in /bin/sh -c, which means PID 1 is the \
1234                    shell, not your app. Signal handling breaks, graceful shutdown breaks, and \
1235                    your ops team breaks (emotionally). Use exec form."
1236                .to_string(),
1237        })
1238        .collect()
1239}
1240
1241fn rule_copy_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1242    instrs_of(instrs, "COPY")
1243        .into_iter()
1244        .filter(|i| {
1245            let a = i.arguments.trim();
1246            a.ends_with(" /") || a.contains(" / ") || a.ends_with("/.")
1247        })
1248        .map(|i| Finding {
1249            column: 0,
1250            end_line: 0,
1251            end_column: 0,
1252            rule: "DF026".into(),
1253            severity: Severity::Warning,
1254            line: i.line,
1255            message: "COPY to filesystem root — this may overwrite system files".to_string(),
1256            roast: "Copying files directly to /? Brave. Reckless. Chaotic. You're one typo away \
1257                    from overwriting /bin/sh and creating a container that doesn't even boot. \
1258                    Use a dedicated app directory."
1259                .to_string(),
1260        })
1261        .collect()
1262}
1263
1264fn rule_pip_no_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1265    let mut stage_cache_settings = std::collections::HashMap::new();
1266    let mut current_stage = None;
1267    let mut pip_cache_disabled = false;
1268
1269    instrs
1270        .iter()
1271        .filter(|instruction| match instruction.instruction.as_str() {
1272            "FROM" => {
1273                let base = instruction.words.first().map(|word| word.value.as_str());
1274                pip_cache_disabled = base
1275                    .and_then(|base| stage_cache_settings.get(base))
1276                    .copied()
1277                    .unwrap_or(false);
1278                current_stage = instruction
1279                    .words
1280                    .iter()
1281                    .position(|word| word.value.eq_ignore_ascii_case("as"))
1282                    .and_then(|position| instruction.words.get(position + 1))
1283                    .map(|word| word.value.clone());
1284                if let Some(stage) = &current_stage {
1285                    stage_cache_settings.insert(stage.clone(), pip_cache_disabled);
1286                }
1287                false
1288            }
1289            "ENV" => {
1290                for word in &instruction.words {
1291                    if let Some(value) = word.value.strip_prefix("PIP_NO_CACHE_DIR=") {
1292                        pip_cache_disabled = pip_boolean(value);
1293                        if let Some(stage) = &current_stage {
1294                            stage_cache_settings.insert(stage.clone(), pip_cache_disabled);
1295                        }
1296                    }
1297                }
1298                false
1299            }
1300            "RUN" => {
1301                let arguments = &instruction.arguments;
1302                if is_uv_pip_install(arguments) {
1303                    !arguments.contains("--no-cache")
1304                } else {
1305                    (arguments.contains("pip install") || arguments.contains("pip3 install"))
1306                        && !arguments.contains("--no-cache-dir")
1307                        && !pip_cache_disabled
1308                }
1309            }
1310            _ => false,
1311        })
1312        .map(|i| Finding {
1313            column: 0,
1314            end_line: 0,
1315            end_column: 0,
1316            rule: "DF030".into(),
1317            severity: Severity::Info,
1318            line: i.line,
1319            message: if is_uv_pip_install(&i.arguments) {
1320                "uv pip install without --no-cache wastes space in the image layer".to_string()
1321            } else {
1322                "pip install without --no-cache-dir wastes space in the image layer".to_string()
1323            },
1324            roast: "pip install without --no-cache-dir? You're carrying around a pip cache in \
1325                    your production image like a tourist with a suitcase full of hotel shampoos. \
1326                    You don't need those. Add the installer-specific no-cache flag."
1327                .to_string(),
1328        })
1329        .collect()
1330}
1331
1332/// pip accepts the same truthy spellings as Python's boolean configuration
1333/// parser for environment-backed options.
1334fn pip_boolean(value: &str) -> bool {
1335    matches!(
1336        value.to_ascii_lowercase().as_str(),
1337        "1" | "true" | "yes" | "on"
1338    )
1339}
1340
1341fn is_uv_pip_install(command: &str) -> bool {
1342    Regex::new(r"(?:^|\s)uv\s+pip\s+install(?:\s|$)")
1343        .expect("valid uv pip install regex")
1344        .is_match(command)
1345}
1346
1347fn rule_npm_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1348    let npm_install = Regex::new(r"\bnpm\s+install\b").expect("valid npm install regex");
1349    instrs_of(instrs, "RUN")
1350        .into_iter()
1351        .filter(|i| {
1352            let a = &i.arguments;
1353            npm_install.is_match(a) && !a.contains("--production") && !a.contains("--omit=dev")
1354        })
1355        .map(|i| Finding {
1356            column: 0,
1357            end_line: 0,
1358            end_column: 0,
1359            rule: "DF031".into(),
1360            severity: Severity::Info,
1361            line: i.line,
1362            message: "npm install used — consider npm ci for reproducible builds".to_string(),
1363            roast: "`npm install` in a Dockerfile: non-deterministic, slower than `npm ci`, \
1364                    and potentially installs different versions than your lockfile specifies. \
1365                    `npm ci` exists specifically for CI/CD and containers. Use it."
1366                .to_string(),
1367        })
1368        .collect()
1369}
1370
1371fn rule_python_env_vars(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1372    let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
1373        Some(f) => f,
1374        None => return vec![],
1375    };
1376    if !first_from.arguments.to_lowercase().contains("python") {
1377        return vec![];
1378    }
1379    let env_args: String = instrs_of(instrs, "ENV")
1380        .iter()
1381        .map(|i| i.arguments.as_str())
1382        .collect::<Vec<_>>()
1383        .join(" ");
1384    let mut findings = Vec::new();
1385    if !env_args.contains("PYTHONDONTWRITEBYTECODE") {
1386        findings.push(Finding {
1387            column: 0,
1388            end_line: 0,
1389            end_column: 0,
1390            rule: "DF032".into(),
1391            severity: Severity::Info,
1392            line: 0,
1393            message: "PYTHONDONTWRITEBYTECODE not set — Python will write .pyc files to the image"
1394                .to_string(),
1395            roast: "Python is quietly writing .pyc bytecode files all over your image. \
1396                    Set PYTHONDONTWRITEBYTECODE=1 and stop Python from hoarding compiled cache \
1397                    files in your container like a digital hoarder."
1398                .to_string(),
1399        });
1400    }
1401    if !env_args.contains("PYTHONUNBUFFERED") {
1402        findings.push(Finding {
1403            column: 0,
1404            end_line: 0,
1405            end_column: 0,
1406            rule: "DF032".into(),
1407            severity: Severity::Info,
1408            line: 0,
1409            message: "PYTHONUNBUFFERED not set — Python output may not appear in logs".to_string(),
1410            roast: "PYTHONUNBUFFERED not set? Your Python app is buffering stdout, meaning \
1411                    logs disappear into the void and you won't see output until the buffer \
1412                    flushes — which is never, because your container crashed. Set PYTHONUNBUFFERED=1.".to_string(),
1413        });
1414    }
1415    findings
1416}
1417
1418fn rule_no_dockerignore(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1419    vec![]
1420}
1421
1422fn rule_chmod_777(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1423    let re = Regex::new(r"chmod\s+([-R\s]*)777").unwrap();
1424    instrs_of(instrs, "RUN")
1425        .into_iter()
1426        .filter(|i| re.is_match(&i.arguments))
1427        .map(|i| Finding {
1428            column: 0,
1429            end_line: 0,
1430            end_column: 0,
1431            rule: "DF034".into(),
1432            severity: Severity::Error,
1433            line: i.line,
1434            message: "chmod 777 grants world-writable permissions — overly permissive".to_string(),
1435            roast: "chmod 777? Giving everyone read, write, and execute access is the filesystem \
1436                    equivalent of leaving your front door open with a sign that says \
1437                    'free stuff inside'. Minimum permissions, please."
1438                .to_string(),
1439        })
1440        .collect()
1441}
1442
1443fn rule_curl_no_fail(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1444    instrs_of(instrs, "RUN")
1445        .into_iter()
1446        .filter(|i| {
1447            let a = &i.arguments;
1448            // only flag when curl is actually fetching something, not being installed as a package
1449            let has_url = a.contains("http://") || a.contains("https://") || a.contains("ftp://");
1450            has_url
1451                && a.contains("curl")
1452                && !a.contains("--fail")
1453                && !a.contains("-fsSL")
1454                && !a.contains("-fsS")
1455                && !a.contains("-fL")
1456                && !a.contains("-fs ")
1457                && !{
1458                    let mut found = false;
1459                    for part in a.split_whitespace() {
1460                        if part.starts_with('-') && !part.starts_with("--") && part.contains('f') {
1461                            found = true;
1462                            break;
1463                        }
1464                    }
1465                    found
1466                }
1467        })
1468        .map(|i| Finding {
1469            column: 0,
1470            end_line: 0,
1471            end_column: 0,
1472            rule: "DF035".into(),
1473            severity: Severity::Info,
1474            line: i.line,
1475            message: "curl without --fail — HTTP errors won't cause the RUN step to fail"
1476                .to_string(),
1477            roast: "curl without --fail means a 404 or 500 response silently succeeds. \
1478                    Your build will happily continue after downloading an error page and \
1479                    treating it as a binary. Add --fail and save yourself a 2am debugging session."
1480                .to_string(),
1481        })
1482        .collect()
1483}
1484
1485fn rule_no_cmd_or_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1486    if has_instr(instrs, "CMD") || has_instr(instrs, "ENTRYPOINT") {
1487        return vec![];
1488    }
1489    if instrs.len() < 3 {
1490        return vec![];
1491    }
1492    vec![Finding {
1493        column: 0,
1494        end_line: 0,
1495        end_column: 0,
1496        rule: "DF036".into(),
1497        severity: Severity::Warning,
1498        line: 0,
1499        message: "No CMD or ENTRYPOINT defined — the container has no default command".to_string(),
1500        roast: "No CMD or ENTRYPOINT? This container starts, does nothing, and immediately exits \
1501                like an intern on their first day who didn't read the onboarding docs. \
1502                Tell it what to run."
1503            .to_string(),
1504    }]
1505}
1506
1507fn rule_uncleaned_package_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1508    let apt_distclean = Regex::new(r"\bapt-get\s+distclean\b").expect("valid apt distclean regex");
1509    let mut findings = Vec::new();
1510    for i in instrs_of(instrs, "RUN") {
1511        let arg = &i.arguments;
1512        let has_apt = arg.contains("apt-get install") || arg.contains("apt install");
1513        let has_yum = arg.contains("yum install") || arg.contains("dnf install");
1514        let has_apk = arg.contains("apk add") && !arg.contains("--no-cache");
1515        let cleans_apt_lists =
1516            arg.contains("rm -rf /var/lib/apt/lists") || apt_distclean.is_match(arg);
1517        if has_apt && !cleans_apt_lists {
1518            findings.push(Finding {
1519                column: 0,
1520                end_line: 0,
1521                end_column: 0,
1522                rule: "DF004".into(),
1523                severity: Severity::Warning,
1524                line: i.line,
1525                message: "apt cache not cleaned after install — adds unnecessary layer size"
1526                    .to_string(),
1527                roast: "Not cleaning the apt cache is like finishing a meal and leaving all the \
1528                        wrappers in the container. Your image is now a trash can. A very expensive \
1529                        trash can stored in ECR."
1530                    .to_string(),
1531            });
1532        }
1533        if has_yum && !arg.contains("yum clean all") && !arg.contains("dnf clean all") {
1534            findings.push(Finding {
1535                column: 0,
1536                end_line: 0,
1537                end_column: 0,
1538                rule: "DF004".into(),
1539                severity: Severity::Warning,
1540                line: i.line,
1541                message: "yum/dnf cache not cleaned after install".to_string(),
1542                roast: "You installed packages with yum but didn't clean up. Every megabyte of \
1543                        cache you leave is a megabyte of shame floating in your registry."
1544                    .to_string(),
1545            });
1546        }
1547        if has_apk {
1548            findings.push(Finding {
1549                column: 0,
1550                end_line: 0,
1551                end_column: 0,
1552                rule: "DF029".into(),
1553                severity: Severity::Warning,
1554                line: i.line,
1555                message: "apk add without --no-cache flag".to_string(),
1556                roast: "Using `apk add` without `--no-cache`? You chose Alpine to save space and \
1557                        then immediately gained it all back. That's impressive, in a bad way."
1558                    .to_string(),
1559            });
1560        }
1561    }
1562    findings
1563}
1564
1565fn rule_unpinned_packages(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1566    let re_yum = Regex::new(r"yum install[^&|;]*").unwrap();
1567    let mut findings = Vec::new();
1568    for i in instrs_of(instrs, "RUN") {
1569        if apt_install_commands(&i.arguments)
1570            .iter()
1571            .any(|(tokens, install_index)| apt_has_unpinned_package(tokens, *install_index))
1572        {
1573            findings.push(Finding {
1574                column: 0,
1575                end_line: 0,
1576                end_column: 0,
1577                rule: "DF005".into(),
1578                severity: Severity::Info,
1579                line: i.line,
1580                message: "apt-get install without pinned package versions".to_string(),
1581                roast: "Unpinned packages: a bold way to ensure your build is different \
1582                        every single time. 'It worked on my machine' is a lifestyle choice, \
1583                        not a deployment strategy."
1584                    .to_string(),
1585            });
1586        }
1587        if re_yum.find(&i.arguments).is_some() {
1588            findings.push(Finding {
1589                column: 0,
1590                end_line: 0,
1591                end_column: 0,
1592                rule: "DF005".into(),
1593                severity: Severity::Info,
1594                line: i.line,
1595                message: "yum install without pinned package versions".to_string(),
1596                roast: "Your yum packages are pinned to 'whatever yum feels like today'. \
1597                        Reproducibility called — it's going to voicemail."
1598                    .to_string(),
1599            });
1600        }
1601    }
1602    findings
1603}
1604
1605fn apt_install_commands(arguments: &str) -> Vec<(Vec<&str>, usize)> {
1606    arguments
1607        .split(['&', '|', ';'])
1608        .filter_map(|segment| {
1609            let segment_tokens: Vec<_> = segment.split_whitespace().collect();
1610            let apt_index = segment_tokens
1611                .iter()
1612                .position(|token| matches!(*token, "apt" | "apt-get"))?;
1613            let tokens = segment_tokens[apt_index + 1..].to_vec();
1614            let install_index = tokens.iter().position(|token| *token == "install")?;
1615            Some((tokens, install_index))
1616        })
1617        .collect()
1618}
1619
1620fn apt_has_unpinned_package(tokens: &[&str], install_index: usize) -> bool {
1621    if tokens.contains(&"--only-upgrade") {
1622        return false;
1623    }
1624
1625    let options_with_values = [
1626        "-a",
1627        "--host-architecture",
1628        "-c",
1629        "--config-file",
1630        "-o",
1631        "--option",
1632        "-q",
1633        "--quiet",
1634        "-t",
1635        "--target-release",
1636    ];
1637    let mut skip_option_value = false;
1638    for token in tokens.iter().skip(install_index + 1) {
1639        if skip_option_value {
1640            skip_option_value = false;
1641            continue;
1642        }
1643        if options_with_values.contains(token) {
1644            skip_option_value = true;
1645            continue;
1646        }
1647        if token.starts_with('-') {
1648            continue;
1649        }
1650        if !token.contains('=') {
1651            return true;
1652        }
1653    }
1654    false
1655}
1656
1657fn apt_assumes_yes(tokens: &[&str]) -> bool {
1658    for (index, token) in tokens.iter().enumerate() {
1659        if matches!(*token, "--yes" | "--assume-yes") {
1660            return true;
1661        }
1662        if let Some(short_options) = token.strip_prefix('-').filter(|_| !token.starts_with("--")) {
1663            if short_options.contains('y')
1664                || short_options.chars().filter(|c| *c == 'q').count() >= 2
1665            {
1666                return true;
1667            }
1668            if short_options
1669                .strip_prefix("q=")
1670                .and_then(|level| level.parse::<u8>().ok())
1671                .is_some_and(|level| level >= 2)
1672            {
1673                return true;
1674            }
1675        }
1676        if token
1677            .strip_prefix("--quiet=")
1678            .and_then(|level| level.parse::<u8>().ok())
1679            .is_some_and(|level| level >= 2)
1680        {
1681            return true;
1682        }
1683        if matches!(*token, "-q" | "--quiet")
1684            && tokens
1685                .get(index + 1)
1686                .and_then(|level| level.parse::<u8>().ok())
1687                .is_some_and(|level| level >= 2)
1688        {
1689            return true;
1690        }
1691    }
1692    false
1693}
1694
1695fn rule_apt_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1696    instrs_of(instrs, "RUN")
1697        .into_iter()
1698        .filter(|i| {
1699            let a = &i.arguments;
1700            apt_install_commands(a)
1701                .iter()
1702                .any(|(tokens, _)| !apt_assumes_yes(tokens))
1703                && !a.contains("DEBIAN_FRONTEND=noninteractive")
1704        })
1705        .map(|i| Finding {
1706            column: 0,
1707            end_line: 0,
1708            end_column: 0,
1709            rule: "DF015".into(),
1710            severity: Severity::Error,
1711            line: i.line,
1712            message: "apt-get install without -y flag will hang waiting for user input".to_string(),
1713            roast: "apt-get install without -y? Your build is going to sit there, patiently \
1714                    waiting for a 'yes' that will never come, like a golden retriever waiting \
1715                    for an owner who's on a cruise ship."
1716                .to_string(),
1717        })
1718        .collect()
1719}
1720
1721fn rule_apt_recommends(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1722    instrs_of(instrs, "RUN")
1723        .into_iter()
1724        .filter(|i| {
1725            let a = &i.arguments;
1726            (a.contains("apt-get install") || a.contains("apt install"))
1727                && !a.contains("--no-install-recommends")
1728        })
1729        .map(|i| Finding {
1730            column: 0,
1731            end_line: 0,
1732            end_column: 0,
1733            rule: "DF016".into(),
1734            severity: Severity::Info,
1735            line: i.line,
1736            message: "apt-get install without --no-install-recommends installs extra packages"
1737                .to_string(),
1738            roast: "Installing without --no-install-recommends? apt is now installing packages \
1739                    you didn't ask for, like a waiter who brings you a full bread basket when \
1740                    you said you're gluten-free. `--no-install-recommends` is right there."
1741                .to_string(),
1742        })
1743        .collect()
1744}
1745
1746fn rule_yum_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1747    instrs_of(instrs, "RUN")
1748        .into_iter()
1749        .filter(|i| {
1750            let a = &i.arguments;
1751            (a.contains("yum install") || a.contains("dnf install"))
1752                && !a.contains("-y")
1753                && !a.contains("--assumeyes")
1754        })
1755        .map(|i| Finding {
1756            column: 0,
1757            end_line: 0,
1758            end_column: 0,
1759            rule: "DF027".into(),
1760            severity: Severity::Error,
1761            line: i.line,
1762            message: "yum/dnf install without -y flag will hang waiting for user input".to_string(),
1763            roast: "yum install without -y. Your build will hang indefinitely, \
1764                    waiting for input in a non-interactive environment. \
1765                    It's not coming. Add -y and move on."
1766                .to_string(),
1767        })
1768        .collect()
1769}
1770
1771fn rule_apt_get_update_alone(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1772    let mut findings = Vec::new();
1773    let mut prev_was_update = false;
1774    let mut update_line = 0;
1775    for i in instrs {
1776        if i.instruction == "RUN" {
1777            let a = &i.arguments;
1778            let has_update = a.contains("apt-get update") || a.contains("apt update");
1779            let has_install = a.contains("apt-get install") || a.contains("apt install");
1780            if has_update && !has_install {
1781                prev_was_update = true;
1782                update_line = i.line;
1783            } else if has_install && !has_update && prev_was_update {
1784                findings.push(Finding {
1785            column: 0,
1786            end_line: 0,
1787            end_column: 0,
1788                    rule: "DF028".into(),
1789                    severity: Severity::Warning,
1790                    line: update_line,
1791                    message: "apt-get update in a separate RUN from apt-get install causes cache poisoning".to_string(),
1792                    roast: "Splitting `apt-get update` and `apt-get install` into separate RUN \
1793                            layers is a classic mistake. Docker caches the update layer and \
1794                            your install may use a stale index. Combine them with && or enjoy \
1795                            mysterious 404 errors.".to_string(),
1796                });
1797                prev_was_update = false;
1798            } else {
1799                prev_was_update = false;
1800            }
1801        }
1802    }
1803    findings
1804}
1805
1806fn rule_apk_no_cache(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1807    // handled inside rule_uncleaned_package_cache to avoid duplicate findings
1808    vec![]
1809}
1810
1811fn rule_secrets_in_env(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1812    let secret_patterns = [
1813        "password",
1814        "passwd",
1815        "secret",
1816        "token",
1817        "api_key",
1818        "apikey",
1819        "private_key",
1820        "auth_token",
1821        "access_key",
1822        "secret_key",
1823        "db_pass",
1824        "database_password",
1825    ];
1826    let mut findings = Vec::new();
1827    for i in instrs_of(instrs, "ENV") {
1828        let lower = i.arguments.to_lowercase();
1829        for pat in &secret_patterns {
1830            if lower.contains(pat) {
1831                findings.push(Finding {
1832                    column: 0,
1833                    end_line: 0,
1834                    end_column: 0,
1835                    rule: "DF013".into(),
1836                    severity: Severity::Error,
1837                    line: i.line,
1838                    message: format!("Potential secret in ENV variable (matched: '{}')", pat),
1839                    roast: format!(
1840                        "You put a '{}' in an ENV instruction. Congratulations — it's now \
1841                         immortalized in your image layers, your registry, your CI logs, \
1842                         and probably a security audit finding. Use Docker secrets or a vault.",
1843                        pat
1844                    ),
1845                });
1846                break;
1847            }
1848        }
1849    }
1850    findings
1851}
1852
1853fn rule_hardcoded_secrets(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1854    let re = Regex::new(r"(?i)(password|secret|token|key|passwd)\s*=\s*\S+").unwrap();
1855    let mut findings = Vec::new();
1856    for i in instrs
1857        .iter()
1858        .filter(|i| i.instruction == "ARG" || i.instruction == "ENV")
1859    {
1860        if let Some(cap) = re.find(&i.arguments) {
1861            let parts: Vec<&str> = cap.as_str().splitn(2, '=').collect();
1862            if parts.len() == 2 {
1863                let val = parts[1].trim();
1864                if !val.is_empty() && !val.starts_with('$') && val != "\"\"" && val != "''" {
1865                    findings.push(Finding {
1866                        column: 0,
1867                        end_line: 0,
1868                        end_column: 0,
1869                        rule: "DF014".into(),
1870                        severity: Severity::Error,
1871                        line: i.line,
1872                        message: "Hardcoded secret value detected in ARG/ENV".to_string(),
1873                        roast: "A hardcoded secret! How delightfully naive. It's in your git \
1874                                history forever now. Have fun rotating that. Maybe consider \
1875                                build secrets or runtime injection next time?"
1876                            .to_string(),
1877                    });
1878                }
1879            }
1880        }
1881    }
1882    findings
1883}
1884
1885fn rule_curl_pipe_sh(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1886    let re = Regex::new(r"(curl|wget)[^|]*\|\s*(bash|sh|ash|zsh|fish)\b").unwrap();
1887    instrs_of(instrs, "RUN")
1888        .into_iter()
1889        .filter(|i| re.is_match(&i.arguments))
1890        .map(|i| Finding {
1891            column: 0,
1892            end_line: 0,
1893            end_column: 0,
1894            rule: "DF021".into(),
1895            severity: Severity::Error,
1896            line: i.line,
1897            message: "Piping remote script directly to shell (curl/wget | sh)".to_string(),
1898            roast: "curl | sh: the technical equivalent of 'hold my beer'. You're downloading \
1899                    code from the internet and executing it blind, inside your container, \
1900                    and shipping it to prod. Your threat model is vibes."
1901                .to_string(),
1902        })
1903        .collect()
1904}
1905
1906fn rule_apt_instead_of_apt_get(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1907    let re =
1908        Regex::new(r"\bapt\s+(install|remove|update|upgrade|list|search|show|purge)\b").unwrap();
1909    instrs_of(instrs, "RUN")
1910        .into_iter()
1911        .filter(|i| re.is_match(&i.arguments))
1912        .map(|i| Finding {
1913            column: 0,
1914            end_line: 0,
1915            end_column: 0,
1916            rule: "DF059".into(),
1917            severity: Severity::Warning,
1918            line: i.line,
1919            message:
1920                "apt used instead of apt-get — apt is an end-user tool, not suited for scripts"
1921                    .to_string(),
1922            roast: "`apt` is designed for humans: it has progress bars, color output, and a \
1923                    warning that says 'do not use in scripts'. You are in a script. \
1924                    Use apt-get or apt-cache instead."
1925                .to_string(),
1926        })
1927        .collect()
1928}
1929
1930fn rule_useless_commands(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1931    let useless = [
1932        "ssh ",
1933        "vim ",
1934        "nano ",
1935        "emacs ",
1936        "shutdown",
1937        "reboot",
1938        "service ",
1939        "systemctl ",
1940        "ifconfig ",
1941        "iwconfig",
1942        "free ",
1943        "top ",
1944        "htop ",
1945        "mount ",
1946        "umount ",
1947    ];
1948    let mut findings = Vec::new();
1949    for i in instrs_of(instrs, "RUN") {
1950        for cmd in &useless {
1951            if i.arguments.contains(cmd) {
1952                findings.push(Finding {
1953                    column: 0,
1954                    end_line: 0,
1955                    end_column: 0,
1956                    rule: "DF060".into(),
1957                    severity: Severity::Info,
1958                    line: i.line,
1959                    message: format!(
1960                        "Command '{}' makes little sense inside a container",
1961                        cmd.trim()
1962                    ),
1963                    roast: format!(
1964                        "`{}` in a Dockerfile: you're running a command that assumes a full \
1965                         interactive OS environment inside a container. It doesn't apply here. \
1966                         Containers are not VMs.",
1967                        cmd.trim()
1968                    ),
1969                });
1970                break;
1971            }
1972        }
1973    }
1974    findings
1975}
1976
1977fn rule_from_platform_flag(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1978    let froms = instrs_of(instrs, "FROM");
1979    let final_stage = froms.len().saturating_sub(1);
1980
1981    froms
1982        .into_iter()
1983        .enumerate()
1984        .filter(|(index, instruction)| {
1985            let has_platform_flag = instruction
1986                .flags
1987                .iter()
1988                .any(|flag| flag.name.eq_ignore_ascii_case("platform"));
1989            let is_native_builder = *index < final_stage
1990                && instruction.flags.iter().any(|flag| {
1991                    flag.name.eq_ignore_ascii_case("platform")
1992                        && matches!(flag.value.as_deref(), Some("$BUILDPLATFORM" | "${BUILDPLATFORM}"))
1993                });
1994            has_platform_flag && !is_native_builder
1995        })
1996        .map(|(_, i)| i)
1997        .map(|i| Finding {
1998            column: 0,
1999            end_line: 0,
2000            end_column: 0,
2001            rule: "DF061".into(),
2002            severity: Severity::Warning,
2003            line: i.line,
2004            message: "FROM uses --platform flag — consider whether cross-platform targeting is intentional".to_string(),
2005            roast: "--platform in FROM forces a specific architecture. If you're building for \
2006                    amd64 but deploying on arm64, your image will be slow or broken. \
2007                    Make sure this is intentional and documented.".to_string(),
2008        })
2009        .collect()
2010}
2011
2012fn rule_env_self_reference(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2013    let re = Regex::new(r"(\w+)=\s*[\x22\x27]?\$\{?(\w+)\}?").unwrap();
2014    let mut findings = Vec::new();
2015    let mut stage_args = std::collections::HashSet::new();
2016    for i in instrs {
2017        if i.instruction == "FROM" {
2018            stage_args.clear();
2019            continue;
2020        }
2021        if i.instruction == "ARG" {
2022            if let Some(name) = i.arguments.split('=').next().and_then(|arg| arg.split_whitespace().next()) {
2023                stage_args.insert(name.to_string());
2024            }
2025            continue;
2026        }
2027        if i.instruction != "ENV" {
2028            continue;
2029        }
2030        for cap in re.captures_iter(&i.arguments) {
2031            let defined = &cap[1];
2032            let referenced = &cap[2];
2033            if defined == referenced && !stage_args.contains(referenced) {
2034                findings.push(Finding {
2035                    column: 0,
2036                    end_line: 0,
2037                    end_column: 0,
2038                    rule: "DF062".into(),
2039                    severity: Severity::Error,
2040                    line: i.line,
2041                    message: format!(
2042                        "ENV variable '{}' references itself in the same statement",
2043                        defined
2044                    ),
2045                    roast: format!(
2046                        "ENV {}=${{{}}} — you're defining a variable using itself. \
2047                         It hasn't been set yet at this point in the same ENV instruction. \
2048                         The result will be an empty string. Split it into two ENV statements.",
2049                        defined, referenced
2050                    ),
2051                });
2052                break;
2053            }
2054        }
2055    }
2056    findings
2057}
2058
2059fn rule_copy_relative_no_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2060    let mut stage_workdirs: std::collections::HashMap<String, bool> =
2061        std::collections::HashMap::new();
2062    let mut current_alias: Option<String> = None;
2063    let mut workdir_set = false;
2064    let mut findings = Vec::new();
2065    for i in instrs {
2066        if i.instruction == "FROM" {
2067            if let Some(from) = parse_from_arguments(&i.arguments) {
2068                workdir_set = stage_workdirs
2069                    .get(&from.image.to_lowercase())
2070                    .copied()
2071                    .unwrap_or(false);
2072                current_alias = from.alias.map(str::to_lowercase);
2073                if let Some(alias) = &current_alias {
2074                    stage_workdirs.insert(alias.clone(), workdir_set);
2075                }
2076            } else {
2077                workdir_set = false;
2078                current_alias = None;
2079            }
2080        } else if i.instruction == "WORKDIR" {
2081            workdir_set = true;
2082            if let Some(alias) = &current_alias {
2083                stage_workdirs.insert(alias.clone(), true);
2084            }
2085        } else if i.instruction == "COPY" {
2086            let args: Vec<&str> = i
2087                .arguments
2088                .split_whitespace()
2089                .filter(|t| !t.starts_with("--"))
2090                .collect();
2091            if let Some(dest) = args.last() {
2092                if !dest.starts_with('/') && !dest.starts_with('$') && !workdir_set {
2093                    findings.push(Finding {
2094                        column: 0,
2095                        end_line: 0,
2096                        end_column: 0,
2097                        rule: "DF063".into(),
2098                        severity: Severity::Warning,
2099                        line: i.line,
2100                        message: format!(
2101                            "COPY to relative destination '{}' but no WORKDIR has been set",
2102                            dest
2103                        ),
2104                        roast: format!(
2105                            "COPY to '{}' with no WORKDIR set. Relative destinations depend on \
2106                             the working directory, which defaults to /. \
2107                             Set WORKDIR explicitly before using relative paths.",
2108                            dest
2109                        ),
2110                    });
2111                }
2112            }
2113        }
2114    }
2115    findings
2116}
2117
2118fn rule_useradd_no_l(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2119    let re = Regex::new(r"\buseradd\b").unwrap();
2120    instrs_of(instrs, "RUN")
2121        .into_iter()
2122        .filter(|i| {
2123            re.is_match(&i.arguments)
2124                && !i.arguments.contains(" -l")
2125                && !i.arguments.contains("--no-log-init")
2126        })
2127        .map(|i| Finding {
2128            column: 0,
2129            end_line: 0,
2130            end_column: 0,
2131            rule: "DF064".into(),
2132            severity: Severity::Warning,
2133            line: i.line,
2134            message:
2135                "useradd without -l flag — high UIDs create oversized /var/log/lastlog entries"
2136                    .to_string(),
2137            roast: "useradd without -l (--no-log-init): with a high UID, this creates a sparse \
2138                    file in /var/log/lastlog that can balloon your image size by gigabytes. \
2139                    Add -l or use --no-log-init."
2140                .to_string(),
2141        })
2142        .collect()
2143}
2144
2145fn rule_copy_archive_use_add(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2146    const ARCHIVE_EXTS: &[&str] = &[".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar"];
2147    instrs_of(instrs, "COPY")
2148        .into_iter()
2149        .filter(|i| {
2150            // Ignore multi-stage COPY --from=... (the source is a container path, not a local file)
2151            if i.arguments.contains("--from=") || i.arguments.contains("--from =") {
2152                return false;
2153            }
2154            let sources: Vec<&str> = i
2155                .arguments
2156                .split_whitespace()
2157                .filter(|t| !t.starts_with("--"))
2158                .collect();
2159            // Need at least one source and one destination
2160            if sources.len() < 2 {
2161                return false;
2162            }
2163            // Check if any source (all but last) is an archive
2164            sources[..sources.len() - 1]
2165                .iter()
2166                .any(|s| ARCHIVE_EXTS.iter().any(|ext| s.ends_with(ext)))
2167        })
2168        .map(|i| Finding {
2169            column: 0,
2170            end_line: 0,
2171            end_column: 0,
2172            rule: "DF067".into(),
2173            severity: Severity::Info,
2174            line: i.line,
2175            message: "COPY of archive file — consider ADD which auto-extracts local tarballs"
2176                .to_string(),
2177            roast: "COPY drops the compressed archive as-is; you'll need a separate \
2178                    RUN tar -xzf layer to unpack it. ADD auto-extracts local tarballs into \
2179                    the destination directory and saves you the extra layer. \
2180                    Yes, this is the one situation where ADD is actually the right choice."
2181                .to_string(),
2182        })
2183        .collect()
2184}
2185
2186fn rule_onbuild_forbidden(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2187    const FORBIDDEN: &[&str] = &["FROM", "ONBUILD", "MAINTAINER"];
2188    let mut findings = Vec::new();
2189    for i in instrs_of(instrs, "ONBUILD") {
2190        let triggered = i
2191            .arguments
2192            .split_whitespace()
2193            .next()
2194            .unwrap_or("")
2195            .to_uppercase();
2196        if FORBIDDEN.contains(&triggered.as_str()) {
2197            findings.push(Finding {
2198                column: 0,
2199                end_line: 0,
2200                end_column: 0,
2201                rule: "DF068".into(),
2202                severity: Severity::Error,
2203                line: i.line,
2204                message: format!(
2205                    "ONBUILD {} is forbidden — {} cannot be used as an ONBUILD trigger",
2206                    triggered, triggered
2207                ),
2208                roast: format!(
2209                    "ONBUILD {} is explicitly prohibited by Docker. \
2210                     FROM would create a recursive inheritance loop, \
2211                     ONBUILD ONBUILD is a depth-2 trap nobody asked for, \
2212                     and MAINTAINER is deprecated everywhere, including here. \
2213                     This fails at build time.",
2214                    triggered
2215                ),
2216            });
2217        }
2218    }
2219    findings
2220}
2221
2222fn rule_bash_syntax_no_shell(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2223    // If an explicit SHELL instruction is present, the developer knows what they're doing
2224    if has_instr(instrs, "SHELL") {
2225        return vec![];
2226    }
2227    // Patterns that are valid bash but not POSIX sh — meaningless or broken on /bin/sh
2228    const BASH_ONLY: &[(&str, &str)] = &[
2229        ("[[ ", "double-bracket conditional"),
2230        ("source ", "source builtin (use '.' in POSIX sh)"),
2231        ("declare ", "declare builtin"),
2232        ("mapfile ", "mapfile builtin"),
2233        ("readarray ", "readarray builtin"),
2234        ("${!", "indirect variable expansion"),
2235    ];
2236    let mut findings = Vec::new();
2237    for i in instrs_of(instrs, "RUN") {
2238        for (pattern, label) in BASH_ONLY {
2239            if i.arguments.contains(pattern) {
2240                findings.push(Finding {
2241                    column: 0,
2242                    end_line: 0,
2243                    end_column: 0,
2244                    rule: "DF066".into(),
2245                    severity: Severity::Warning,
2246                    line: i.line,
2247                    message: format!(
2248                        "RUN uses bash-specific syntax ({}) but no SHELL instruction is set",
2249                        label
2250                    ),
2251                    roast: format!(
2252                        "'{}' is bash syntax. The default shell is /bin/sh, which on Alpine, \
2253                         Debian-slim, and distroless is NOT bash. Add \
2254                         `SHELL [\"/bin/bash\", \"-c\"]` before this RUN or your build \
2255                         will fail in ways that are confusing to debug.",
2256                        pattern.trim()
2257                    ),
2258                });
2259                break;
2260            }
2261        }
2262    }
2263    findings
2264}
2265
2266fn rule_untrusted_registry(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2267    const TRUSTED: &[&str] = &[
2268        "docker.io",
2269        "registry-1.docker.io",
2270        "ghcr.io",
2271        "gcr.io",
2272        "quay.io",
2273        "mcr.microsoft.com",
2274        "registry.access.redhat.com",
2275        "public.ecr.aws",
2276        "registry.k8s.io",
2277        "k8s.gcr.io",
2278        "dhi.io",
2279    ];
2280    let mut findings = Vec::new();
2281    for i in instrs_of(instrs, "FROM") {
2282        // Skip --platform=... flags to find the actual image reference
2283        let image = match i
2284            .arguments
2285            .split_whitespace()
2286            .find(|t| !t.starts_with("--"))
2287        {
2288            Some(img) => img,
2289            None => continue,
2290        };
2291        if image.eq_ignore_ascii_case("scratch") {
2292            continue;
2293        }
2294        // The registry is the first path component when it contains a dot or colon,
2295        // or is the literal "localhost". Plain names like "ubuntu" or "ubuntu:22.04"
2296        // with no slash imply docker.io — the colon there is the tag separator, not a port.
2297        if !image.contains('/') {
2298            continue;
2299        }
2300        let first = image
2301            .split('@')
2302            .next()
2303            .unwrap_or(image)
2304            .split('/')
2305            .next()
2306            .unwrap_or("");
2307        if (first.contains('.') || first.contains(':') || first == "localhost")
2308            && !TRUSTED.iter().any(|t| first.eq_ignore_ascii_case(t))
2309        {
2310            findings.push(Finding {
2311                column: 0,
2312                end_line: 0,
2313                end_column: 0,
2314                rule: "DF065".into(),
2315                severity: Severity::Warning,
2316                line: i.line,
2317                message: format!("FROM pulls from unrecognised registry '{}'", first),
2318                roast: format!(
2319                    "Pulling base images from '{}' — a registry you don't hear about at \
2320                     KubeCon. Supply-chain attacks love Dockerfiles that blindly trust \
2321                     random registries. Verify this is intentional and pin to a digest.",
2322                    first
2323                ),
2324            });
2325        }
2326    }
2327    findings
2328}
2329
2330fn rule_pipefail_missing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2331    let mut shell_has_pipefail = false;
2332    let mut findings = Vec::new();
2333
2334    for instruction in instrs {
2335        match instruction.instruction.as_str() {
2336            "FROM" => shell_has_pipefail = false,
2337            "SHELL" => {
2338                shell_has_pipefail = match &instruction.form {
2339                    InstructionForm::Json(arguments) => {
2340                        arguments
2341                            .windows(2)
2342                            .any(|pair| pair[0] == "-o" && pair[1] == "pipefail")
2343                            || arguments.iter().any(|argument| argument == "-opipefail")
2344                    }
2345                    _ => false,
2346                };
2347            }
2348            "RUN" if run_has_unprotected_pipeline(&instruction.command, shell_has_pipefail) => {
2349                findings.push(Finding {
2350                    column: 0,
2351                    end_line: 0,
2352                    end_column: 0,
2353                    rule: "DF057".into(),
2354                    severity: Severity::Warning,
2355                    line: instruction.line,
2356                    message: "RUN with pipe but no pipefail — failed commands in the pipe are silently ignored"
2357                        .to_string(),
2358                    roast: "A pipe in RUN without `set -o pipefail`. If the left side of that pipe fails, \
2359                            bash shrugs and moves on. The exit code is whatever the last command returns. \
2360                            Add `set -o pipefail` at the start of the RUN."
2361                        .to_string(),
2362                });
2363            }
2364            _ => {}
2365        }
2366    }
2367
2368    findings
2369}
2370
2371/// Return whether a shell-form RUN has a pipeline while pipefail is disabled.
2372///
2373/// This is intentionally a small shell lexer rather than a substring match: it
2374/// distinguishes `|` from `||` and quoted or escaped literal pipes, and follows
2375/// `set` commands in their execution order. It is not a full shell parser, but
2376/// covers the syntax relevant to enabling and disabling pipefail.
2377fn run_has_unprotected_pipeline(script: &str, initial_pipefail_enabled: bool) -> bool {
2378    let mut pipefail_enabled = initial_pipefail_enabled;
2379    let mut words = Vec::new();
2380    let mut current_word = String::new();
2381    let mut quote = None;
2382    let mut chars = script.chars().peekable();
2383
2384    while let Some(character) = chars.next() {
2385        if let Some(active_quote) = quote {
2386            if character == active_quote {
2387                quote = None;
2388            } else if character == '\\' && active_quote == '"' {
2389                if let Some(escaped) = chars.next() {
2390                    current_word.push(escaped);
2391                }
2392            } else {
2393                current_word.push(character);
2394            }
2395            continue;
2396        }
2397
2398        match character {
2399            '\\' => {
2400                if let Some(escaped) = chars.next() {
2401                    current_word.push(escaped);
2402                }
2403            }
2404            '\'' | '"' => quote = Some(character),
2405            character if character.is_whitespace() => flush_shell_word(&mut current_word, &mut words),
2406            '#' if current_word.is_empty() => break,
2407            ';' => finish_shell_command(&mut current_word, &mut words, &mut pipefail_enabled),
2408            '&' => {
2409                if chars.peek() == Some(&'&') {
2410                    chars.next();
2411                }
2412                finish_shell_command(&mut current_word, &mut words, &mut pipefail_enabled);
2413            }
2414            '|' => {
2415                if chars.peek() == Some(&'|') {
2416                    chars.next();
2417                    finish_shell_command(&mut current_word, &mut words, &mut pipefail_enabled);
2418                } else {
2419                    // `set -o pipefail | command` runs `set` in a pipeline
2420                    // subshell, so it does not protect this pipeline.
2421                    if !pipefail_enabled {
2422                        return true;
2423                    }
2424                    current_word.clear();
2425                    words.clear();
2426                }
2427            }
2428            _ => current_word.push(character),
2429        }
2430    }
2431
2432    false
2433}
2434
2435fn flush_shell_word(current_word: &mut String, words: &mut Vec<String>) {
2436    if !current_word.is_empty() {
2437        words.push(std::mem::take(current_word));
2438    }
2439}
2440
2441fn finish_shell_command(
2442    current_word: &mut String,
2443    words: &mut Vec<String>,
2444    pipefail_enabled: &mut bool,
2445) {
2446    flush_shell_word(current_word, words);
2447    if words.first().is_some_and(|word| word == "set") {
2448        let mut arguments = words[1..].iter();
2449        while let Some(argument) = arguments.next() {
2450            if (argument == "-o" || argument == "+o")
2451                && arguments.next().is_some_and(|value| value == "pipefail")
2452            {
2453                *pipefail_enabled = argument == "-o";
2454                words.clear();
2455                return;
2456            }
2457            if argument.starts_with('-')
2458                && argument[1..].contains('o')
2459                && arguments.next().is_some_and(|value| value == "pipefail")
2460            {
2461                *pipefail_enabled = true;
2462                words.clear();
2463                return;
2464            }
2465        }
2466    }
2467    words.clear();
2468}
2469
2470fn rule_wget_and_curl(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2471    let uses_wget = instrs_of(instrs, "RUN")
2472        .iter()
2473        .any(|i| i.arguments.contains("wget "));
2474    let uses_curl = instrs_of(instrs, "RUN")
2475        .iter()
2476        .any(|i| i.arguments.contains("curl "));
2477    if uses_wget && uses_curl {
2478        return vec![Finding {
2479            column: 0,
2480            end_line: 0,
2481            end_column: 0,
2482            rule: "DF058".into(),
2483            severity: Severity::Warning,
2484            line: 0,
2485            message: "Both wget and curl are used — pick one and use it consistently".to_string(),
2486            roast: "You're using both wget and curl in the same Dockerfile. They do the same \
2487                    thing. Pick one. Commit to it. Your image doesn't need two download tools \
2488                    any more than it needs two fire extinguishers."
2489                .to_string(),
2490        }];
2491    }
2492    vec![]
2493}
2494
2495fn rule_yarn_cache_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2496    instrs_of(instrs, "RUN")
2497        .into_iter()
2498        .filter(|i| {
2499            let a = &i.arguments;
2500            (a.contains("yarn install") || a.contains("yarn add"))
2501                && !a.contains("yarn cache clean")
2502        })
2503        .map(|i| Finding {
2504            column: 0,
2505            end_line: 0,
2506            end_column: 0,
2507            rule: "DF055".into(),
2508            severity: Severity::Info,
2509            line: i.line,
2510            message: "yarn install without yarn cache clean — yarn cache is left in the image"
2511                .to_string(),
2512            roast: "yarn install without cleaning the cache. Yarn dutifully stores downloaded \
2513                    packages in a cache that you are now shipping to production. \
2514                    Add `&& yarn cache clean` after install."
2515                .to_string(),
2516        })
2517        .collect()
2518}
2519
2520fn rule_wget_no_progress(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2521    instrs_of(instrs, "RUN")
2522        .into_iter()
2523        .filter(|i| {
2524            let a = &i.arguments;
2525            a.contains("wget ")
2526                && !a.contains("--progress")
2527                && !a.contains("-q")
2528                && !a.contains("--quiet")
2529                && (a.contains("http://") || a.contains("https://") || a.contains("ftp://"))
2530        })
2531        .map(|i| Finding {
2532            column: 0,
2533            end_line: 0,
2534            end_column: 0,
2535            rule: "DF056".into(),
2536            severity: Severity::Info,
2537            line: i.line,
2538            message: "wget without --progress flag produces verbose progress output in build logs"
2539                .to_string(),
2540            roast: "wget without --progress=dot:giga will spam your build logs with a progress \
2541                    bar that looks great locally and fills 50MB of CI log storage. \
2542                    Use --progress=dot:giga or -q to stay quiet."
2543                .to_string(),
2544        })
2545        .collect()
2546}
2547
2548fn rule_pip_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2549    instrs_of(instrs, "RUN")
2550        .into_iter()
2551        .filter(|i| {
2552            let a = &i.arguments;
2553            (a.contains("pip install") || a.contains("pip3 install"))
2554                && !a.contains("-r ")
2555                && !a.contains("--requirement")
2556                && !a.contains("==")
2557                && !a.contains(">=")
2558                && !a.contains("<=")
2559                && !a.contains("~=")
2560                && !a.contains(".txt")
2561                && !is_local_pip_install(a)
2562        })
2563        .map(|i| Finding {
2564            column: 0,
2565            end_line: 0,
2566            end_column: 0,
2567            rule: "DF051".into(),
2568            severity: Severity::Warning,
2569            line: i.line,
2570            message:
2571                "pip install without version pinning — use package==version for reproducibility"
2572                    .to_string(),
2573            roast: "pip install with no version pins. Every build pulls 'latest' and \
2574                    one day something breaks and you spend three hours bisecting which \
2575                    transitive dependency changed. Use package==version."
2576                .to_string(),
2577        })
2578        .collect()
2579}
2580
2581fn is_local_pip_install(command: &str) -> bool {
2582    let Some(install) = command.find("pip install") else {
2583        return false;
2584    };
2585    command[install + "pip install".len()..]
2586        .split_whitespace()
2587        .filter(|argument| !argument.starts_with('-'))
2588        .any(|argument| {
2589            matches!(argument, "." | "./")
2590                || argument.starts_with("./")
2591                || argument.starts_with("../")
2592                || argument.starts_with("file:")
2593        })
2594}
2595
2596fn rule_apk_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2597    instrs_of(instrs, "RUN")
2598        .into_iter()
2599        .filter(|i| {
2600            let a = &i.arguments;
2601            if !a.contains("apk add") {
2602                return false;
2603            }
2604            // check if any non-flag arg after "add" has no = for version pinning
2605            let after_add = match a.find("apk add") {
2606                Some(pos) => &a[pos + 7..],
2607                None => return false,
2608            };
2609            after_add
2610                .split_whitespace()
2611                .filter(|t| !t.starts_with('-') && !t.is_empty())
2612                .any(|t| !t.contains('=') && !t.contains('>') && !t.contains('<'))
2613        })
2614        .map(|i| Finding {
2615            column: 0,
2616            end_line: 0,
2617            end_column: 0,
2618            rule: "DF052".into(),
2619            severity: Severity::Warning,
2620            line: i.line,
2621            message: "apk add without version pinning — use package=version for reproducibility"
2622                .to_string(),
2623            roast: "apk add with no version? You chose Alpine to be minimal and fast, then \
2624                    immediately added unpinned packages. Your builds are non-deterministic \
2625                    by design now. Use package=version."
2626                .to_string(),
2627        })
2628        .collect()
2629}
2630
2631fn rule_gem_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2632    instrs_of(instrs, "RUN")
2633        .into_iter()
2634        .filter(|i| {
2635            let a = &i.arguments;
2636            a.contains("gem install")
2637                && !a.contains(" -v ")
2638                && !a.contains("--version")
2639                && !a.contains(':')
2640        })
2641        .map(|i| Finding {
2642            column: 0,
2643            end_line: 0,
2644            end_column: 0,
2645            rule: "DF053".into(),
2646            severity: Severity::Warning,
2647            line: i.line,
2648            message: "gem install without version pinning — use gem install <gem>:<version>"
2649                .to_string(),
2650            roast: "gem install with no version. RubyGems will grab whatever's latest today. \
2651                    Next week it grabs something else. Your builds are a dice roll. \
2652                    Use gem install name:version."
2653                .to_string(),
2654        })
2655        .collect()
2656}
2657
2658fn rule_go_install_version(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2659    instrs_of(instrs, "RUN")
2660        .into_iter()
2661        .filter(|i| {
2662            i.arguments
2663                .split(['&', '|', ';'])
2664                .any(|segment| {
2665                    let mut words = segment.split_whitespace();
2666
2667                    // Environment assignments may precede the command, but the
2668                    // executable itself must be `go`; a substring match would
2669                    // mistake `cargo install` for `go install`.
2670                    let executable = loop {
2671                        match words.next() {
2672                            Some(word)
2673                                if word.contains('=')
2674                                    && !word.starts_with('=')
2675                                    && !word.contains('/') => continue,
2676                            word => break word,
2677                        }
2678                    };
2679
2680                    executable == Some("go")
2681                        && words.next() == Some("install")
2682                        && !segment.contains('@')
2683                })
2684        })
2685        .map(|i| Finding {
2686            column: 0,
2687            end_line: 0,
2688            end_column: 0,
2689            rule: "DF054".into(),
2690            severity: Severity::Warning,
2691            line: i.line,
2692            message: "go install without @version — use go install package@version".to_string(),
2693            roast: "go install without @version. The Go toolchain requires a version suffix \
2694                    in module-aware mode. Use `go install pkg@v1.2.3` or at minimum `@latest` \
2695                    if you enjoy living dangerously."
2696                .to_string(),
2697        })
2698        .collect()
2699}
2700
2701fn rule_copy_multi_arg_slash(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2702    instrs_of(instrs, "COPY")
2703        .into_iter()
2704        .filter(|i| {
2705            let args: Vec<&str> = i
2706                .arguments
2707                .split_whitespace()
2708                .filter(|t| !t.starts_with("--"))
2709                .collect();
2710            if args.len() > 2 {
2711                let dest = args.last().unwrap_or(&"");
2712                !dest.ends_with('/')
2713            } else {
2714                false
2715            }
2716        })
2717        .map(|i| Finding {
2718            column: 0,
2719            end_line: 0,
2720            end_column: 0,
2721            rule: "DF048".into(),
2722            severity: Severity::Error,
2723            line: i.line,
2724            message: "COPY with multiple sources requires the destination to end with /"
2725                .to_string(),
2726            roast: "COPY with multiple sources and a destination that doesn't end with /? \
2727                    Docker will complain. Or worse, silently do something weird. \
2728                    Add a trailing slash to the destination."
2729                .to_string(),
2730        })
2731        .collect()
2732}
2733
2734fn rule_copy_from_undefined_stage(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2735    // An unresolved non-numeric reference is a valid external image (or a
2736    // BuildKit named context), so it cannot be diagnosed as an undefined
2737    // stage. Image allowlists are enforced by DF073 instead.
2738    Vec::new()
2739}
2740
2741fn rule_copy_from_self(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2742    let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
2743    let mut current_alias: Option<String> = None;
2744    let mut findings = Vec::new();
2745    for i in instrs {
2746        if i.instruction == "FROM" {
2747            current_alias = parse_from_arguments(&i.arguments)
2748                .and_then(|from| from.alias)
2749                .map(str::to_lowercase);
2750        } else if i.instruction == "COPY" {
2751            if let Some(cap) = re_from.captures(&i.arguments) {
2752                let from_ref = cap[1].to_lowercase();
2753                if let Some(ref alias) = current_alias {
2754                    if &from_ref == alias {
2755                        findings.push(Finding {
2756            column: 0,
2757            end_line: 0,
2758            end_column: 0,
2759                            rule: "DF050".into(),
2760                            severity: Severity::Error,
2761                            line: i.line,
2762                            message: format!(
2763                                "COPY --from={} references the current build stage — circular dependency",
2764                                &cap[1]
2765                            ),
2766                            roast: format!(
2767                                "COPY --from={} inside the same stage named {}. \
2768                                 That's a circular reference. Docker cannot copy from itself. \
2769                                 This will fail at build time.",
2770                                &cap[1], &cap[1]
2771                            ),
2772                        });
2773                    }
2774                }
2775            }
2776        }
2777    }
2778    findings
2779}
2780
2781fn rule_dnf_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2782    instrs_of(instrs, "RUN")
2783        .into_iter()
2784        .filter(|i| {
2785            let a = &i.arguments;
2786            a.contains("dnf install") && !a.contains("dnf clean all") && !a.contains("dnf clean")
2787        })
2788        .map(|i| Finding {
2789            column: 0,
2790            end_line: 0,
2791            end_column: 0,
2792            rule: "DF046".into(),
2793            severity: Severity::Warning,
2794            line: i.line,
2795            message: "dnf clean all missing after dnf install — RPM cache bloats the image"
2796                .to_string(),
2797            roast: "dnf install without `dnf clean all` afterwards? You're shipping RPM cache \
2798                    metadata to production. That's not a feature. Add `&& dnf clean all`."
2799                .to_string(),
2800        })
2801        .collect()
2802}
2803
2804fn rule_yum_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2805    instrs_of(instrs, "RUN")
2806        .into_iter()
2807        .filter(|i| {
2808            let a = &i.arguments;
2809            a.contains("yum install") && !a.contains("yum clean all") && !a.contains("yum clean")
2810        })
2811        .map(|i| Finding {
2812            column: 0,
2813            end_line: 0,
2814            end_column: 0,
2815            rule: "DF047".into(),
2816            severity: Severity::Warning,
2817            line: i.line,
2818            message: "yum clean all missing after yum install — cache stays in the image"
2819                .to_string(),
2820            roast: "yum install without cleanup is just permanently housing the package cache in \
2821                    your image. Every MB of yum cache is a MB of shame in your registry."
2822                .to_string(),
2823        })
2824        .collect()
2825}
2826
2827fn rule_zypper_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2828    instrs_of(instrs, "RUN")
2829        .into_iter()
2830        .filter(|i| {
2831            let a = &i.arguments;
2832            (a.contains("zypper install") || a.contains("zypper in "))
2833                && !a.contains("-y")
2834                && !a.contains("--non-interactive")
2835                && !a.contains(" -n ")
2836                && !a.contains(" -n\n")
2837                && !a.starts_with("-n ")
2838        })
2839        .map(|i| Finding {
2840            column: 0,
2841            end_line: 0,
2842            end_column: 0,
2843            rule: "DF043".into(),
2844            severity: Severity::Warning,
2845            line: i.line,
2846            message: "zypper install without non-interactive flag (-y) will hang in a build"
2847                .to_string(),
2848            roast: "zypper install without -y in a container build? It'll wait for input that \
2849                    will never arrive, like a chatbot asking for emotional validation."
2850                .to_string(),
2851        })
2852        .collect()
2853}
2854
2855fn rule_zypper_dist_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2856    instrs_of(instrs, "RUN")
2857        .into_iter()
2858        .filter(|i| {
2859            i.arguments.contains("zypper dist-upgrade") || i.arguments.contains("zypper dup")
2860        })
2861        .map(|i| Finding {
2862            column: 0,
2863            end_line: 0,
2864            end_column: 0,
2865            rule: "DF044".into(),
2866            severity: Severity::Warning,
2867            line: i.line,
2868            message:
2869                "zypper dist-upgrade upgrades all packages unpredictably — avoid in Dockerfiles"
2870                    .to_string(),
2871            roast: "zypper dist-upgrade: the 'nuke everything and hope for the best' approach to \
2872                    package management. Your image will be different every single build. Congrats."
2873                .to_string(),
2874        })
2875        .collect()
2876}
2877
2878fn rule_zypper_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2879    instrs_of(instrs, "RUN")
2880        .into_iter()
2881        .filter(|i| {
2882            let a = &i.arguments;
2883            (a.contains("zypper install") || a.contains("zypper in "))
2884                && !a.contains("zypper clean")
2885                && !a.contains("zypper cc")
2886        })
2887        .map(|i| Finding {
2888            column: 0,
2889            end_line: 0,
2890            end_column: 0,
2891            rule: "DF045".into(),
2892            severity: Severity::Info,
2893            line: i.line,
2894            message: "zypper cache not cleaned after install — adds unnecessary image bloat"
2895                .to_string(),
2896            roast:
2897                "zypper install without `zypper clean --all` afterwards. You're hoarding package \
2898                    metadata in your image. Clean it up."
2899                    .to_string(),
2900        })
2901        .collect()
2902}
2903
2904fn rule_expose_port_range(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2905    let mut findings = Vec::new();
2906    for i in instrs_of(instrs, "EXPOSE") {
2907        for port_spec in i.arguments.split_whitespace() {
2908            let port_str = port_spec.split('/').next().unwrap_or(port_spec);
2909            if let Ok(port) = port_str.parse::<u32>() {
2910                if port > 65535 {
2911                    findings.push(Finding {
2912                        column: 0,
2913                        end_line: 0,
2914                        end_column: 0,
2915                        rule: "DF040".into(),
2916                        severity: Severity::Error,
2917                        line: i.line,
2918                        message: format!("EXPOSE port {} is out of valid range (0-65535)", port),
2919                        roast: format!(
2920                            "Port {}? That's not a port, that's a zip code. \
2921                             Valid UNIX ports are 0-65535. Pick a real one.",
2922                            port
2923                        ),
2924                    });
2925                }
2926            }
2927        }
2928    }
2929    findings
2930}
2931
2932fn rule_multiple_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2933    let checks: Vec<_> = instrs_of(instrs, "HEALTHCHECK");
2934    if checks.len() <= 1 {
2935        return vec![];
2936    }
2937    checks[1..]
2938        .iter()
2939        .map(|i| Finding {
2940            column: 0,
2941            end_line: 0,
2942            end_column: 0,
2943            rule: "DF041".into(),
2944            severity: Severity::Error,
2945            line: i.line,
2946            message: "Multiple HEALTHCHECK instructions — only the last one applies".to_string(),
2947            roast: "Multiple HEALTHCHECKs but only the last one counts. The earlier ones are \
2948                haunting your image for no reason. One health check, one truth."
2949                .to_string(),
2950        })
2951        .collect()
2952}
2953
2954fn rule_unique_stage_aliases(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2955    let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2956    let mut findings = Vec::new();
2957    for i in instrs_of(instrs, "FROM") {
2958        if let Some(original_alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias)
2959        {
2960            let alias = original_alias.to_lowercase();
2961            if let Some(&prev_line) = seen.get(&alias) {
2962                findings.push(Finding {
2963                    column: 0,
2964                    end_line: 0,
2965                    end_column: 0,
2966                    rule: "DF042".into(),
2967                    severity: Severity::Error,
2968                    line: i.line,
2969                    message: format!(
2970                        "FROM alias '{}' is already defined on line {}",
2971                        original_alias, prev_line
2972                    ),
2973                    roast: format!(
2974                        "Two stages named '{}'. Docker uses the last one; the first is dead code. \
2975                         Give your stages unique names.",
2976                        original_alias
2977                    ),
2978                });
2979            } else {
2980                seen.insert(alias, i.line);
2981            }
2982        }
2983    }
2984    findings
2985}
2986
2987fn rule_invalid_instruction_order(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
2988    if instrs.is_empty() {
2989        return vec![];
2990    }
2991    let first = &instrs[0];
2992    if first.instruction != "FROM" && first.instruction != "ARG" {
2993        return vec![Finding {
2994            column: 0,
2995            end_line: 0,
2996            end_column: 0,
2997            rule: "DF037".into(),
2998            severity: Severity::Error,
2999            line: first.line,
3000            message: format!(
3001                "'{}' before FROM — Dockerfile must begin with FROM, ARG, or a comment",
3002                first.instruction
3003            ),
3004            roast: "Your Dockerfile doesn't start with FROM. That's like starting a recipe with \
3005                    'season to taste' before listing any ingredients. Docker is confused. So am I."
3006                .to_string(),
3007        }];
3008    }
3009    vec![]
3010}
3011
3012fn rule_multiple_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3013    let mut findings = Vec::new();
3014    let mut cmds = Vec::new();
3015    let mut report_duplicates = |cmds: &mut Vec<&Instruction>| {
3016        if cmds.len() > 1 {
3017            findings.extend(cmds.iter().skip(1).map(|i| Finding {
3018                column: 0,
3019                end_line: 0,
3020                end_column: 0,
3021                rule: "DF038".into(),
3022                severity: Severity::Warning,
3023                line: i.line,
3024                message: "Multiple CMD instructions — only the last one takes effect".to_string(),
3025                roast:
3026                    "Multiple CMDs and only the last one counts. The others are ghosts haunting your \
3027                    Dockerfile, contributing nothing except confusion. Pick one."
3028                        .to_string(),
3029            }));
3030        }
3031        cmds.clear();
3032    };
3033    for instruction in instrs {
3034        if instruction.instruction == "FROM" {
3035            report_duplicates(&mut cmds);
3036        }
3037        if instruction.instruction == "CMD" {
3038            cmds.push(instruction);
3039        }
3040    }
3041    report_duplicates(&mut cmds);
3042    findings
3043}
3044
3045fn rule_multiple_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3046    let eps: Vec<_> = instrs_of(instrs, "ENTRYPOINT");
3047    if eps.len() <= 1 {
3048        return vec![];
3049    }
3050    eps[1..]
3051        .iter()
3052        .map(|i| Finding {
3053            column: 0,
3054            end_line: 0,
3055            end_column: 0,
3056            rule: "DF039".into(),
3057            severity: Severity::Error,
3058            line: i.line,
3059            message: "Multiple ENTRYPOINT instructions — only the last one takes effect"
3060                .to_string(),
3061            roast: "Two ENTRYPOINTs. Bold. Only the last one runs; the first is just expensive \
3062                furniture. Delete it."
3063                .to_string(),
3064        })
3065        .collect()
3066}
3067
3068fn rule_no_user_instruction(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3069    if has_instr(instrs, "USER") {
3070        return vec![];
3071    }
3072    if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") {
3073        return vec![];
3074    }
3075    vec![Finding {
3076        column: 0,
3077        end_line: 0,
3078        end_column: 0,
3079        rule: "DF020".into(),
3080        severity: Severity::Warning,
3081        line: 0,
3082        message: "No USER instruction found — container will run as root by default".to_string(),
3083        roast: "No USER set? Bold strategy. Running everything as root in prod is a great way \
3084                to ensure job security — for your incident response team."
3085            .to_string(),
3086    }]
3087}
3088
3089fn rule_apt_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3090    let re = Regex::new(r"\bapt(-get)?\s+(dist-upgrade|upgrade)\b").unwrap();
3091    instrs_of(instrs, "RUN")
3092        .into_iter()
3093        .filter(|i| re.is_match(&i.arguments))
3094        .map(|i| Finding {
3095            column: 0,
3096            end_line: 0,
3097            end_column: 0,
3098            rule: "DF069".into(),
3099            severity: Severity::Warning,
3100            line: i.line,
3101            message: "apt-get upgrade/dist-upgrade makes builds non-reproducible".to_string(),
3102            roast:
3103                "apt-get upgrade: 'let's upgrade everything and see what breaks in six months'. \
3104                    Your image will be different every time you build it. \
3105                    Pin the packages you actually need instead of upgrading everything blindly."
3106                    .to_string(),
3107        })
3108        .collect()
3109}
3110
3111fn rule_copy_before_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
3112    const PKG_CMDS: &[&str] = &[
3113        "npm install",
3114        "npm ci",
3115        "pip install",
3116        "pip3 install",
3117        "yarn install",
3118        "yarn add",
3119        "bundle install",
3120        "composer install",
3121        "pnpm install",
3122        "bun install",
3123    ];
3124    let mut findings = Vec::new();
3125    let mut broad_copy_line: Option<usize> = None;
3126
3127    for i in instrs {
3128        match i.instruction.as_str() {
3129            "FROM" => {
3130                broad_copy_line = None;
3131            }
3132            "COPY" => {
3133                let tokens: Vec<&str> = i
3134                    .arguments
3135                    .split_whitespace()
3136                    .filter(|t| !t.starts_with("--"))
3137                    .collect();
3138                if tokens.len() >= 2 && (tokens[0] == "." || tokens[0].ends_with("/.")) {
3139                    broad_copy_line = Some(i.line);
3140                }
3141            }
3142            "RUN" => {
3143                if let Some(copy_line) = broad_copy_line {
3144                    if PKG_CMDS.iter().any(|cmd| i.arguments.contains(cmd))
3145                        && !is_local_pip_install(&i.arguments)
3146                    {
3147                        findings.push(Finding {
3148            column: 0,
3149            end_line: 0,
3150            end_column: 0,
3151                            rule: "DF070".into(),
3152                            severity: Severity::Warning,
3153                            line: copy_line,
3154                            message: "COPY . before package install — invalidates Docker layer cache on every source change".to_string(),
3155                            roast: "COPY . . before npm/pip install means every code change rebuilds \
3156                                    dependencies from scratch. Copy just the manifest first \
3157                                    (e.g. COPY package.json ./), run the install, then COPY . . — \
3158                                    now the install layer is cached between source changes.".to_string(),
3159                        });
3160                        broad_copy_line = None;
3161                    }
3162                }
3163            }
3164            _ => {}
3165        }
3166    }
3167    findings
3168}