Skip to main content

dockerfile_roast/
rules.rs

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