1use crate::parser::Instruction;
2use regex::Regex;
3
4#[derive(Debug, Clone, 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 pub rule: &'static str,
24 pub severity: Severity,
25 pub line: usize,
26 pub message: String,
27 pub roast: String,
28}
29
30type RuleFn = fn(&[Instruction], &str) -> Vec<Finding>;
31
32pub struct Rule {
33 pub id: &'static str,
34 pub severity: Severity,
35 pub description: &'static str,
36 pub func: RuleFn,
37}
38
39pub fn all_rules() -> Vec<Rule> {
40 vec![
41 Rule { id: "DF001", severity: Severity::Warning, description: "Use specific base image tags instead of 'latest'", func: rule_latest_tag },
42 Rule { id: "DF002", severity: Severity::Error, description: "Do not run as root", func: rule_running_as_root },
43 Rule { id: "DF011", severity: Severity::Warning, description: "Use multi-stage builds to reduce image size", func: rule_no_multistage },
44 Rule { id: "DF013", severity: Severity::Error, description: "Avoid storing secrets in ENV variables", func: rule_secrets_in_env },
45 Rule { id: "DF014", severity: Severity::Error, description: "Avoid hardcoding passwords or tokens in ARG/ENV", func: rule_hardcoded_secrets },
46 Rule { id: "DF020", severity: Severity::Warning, description: "Set explicit non-root USER", func: rule_no_user_instruction },
47 Rule { id: "DF003", severity: Severity::Warning, description: "Combine RUN commands to reduce layers", func: rule_many_run_layers },
48 Rule { id: "DF004", severity: Severity::Warning, description: "Clean apt/yum/apk cache in the same RUN layer", func: rule_uncleaned_package_cache },
49 Rule { id: "DF005", severity: Severity::Info, description: "Pin package versions for reproducibility", func: rule_unpinned_packages },
50 Rule { id: "DF006", severity: Severity::Warning, description: "Avoid ADD for local files; prefer COPY", func: rule_add_instead_of_copy },
51 Rule { id: "DF007", severity: Severity::Warning, description: "Do not copy the entire build context (COPY . .)", func: rule_copy_all },
52 Rule { id: "DF008", severity: Severity::Info, description: "Use WORKDIR instead of inline cd commands", func: rule_cd_instead_of_workdir },
53 Rule { id: "DF009", severity: Severity::Warning, description: "Use absolute paths in WORKDIR", func: rule_relative_workdir },
54 Rule { id: "DF010", severity: Severity::Warning, description: "Avoid using sudo inside containers", func: rule_sudo_usage },
55 Rule { id: "DF012", severity: Severity::Info, description: "Set HEALTHCHECK for long-running services", func: rule_no_healthcheck },
56 Rule { id: "DF017", severity: Severity::Warning, description: "Use ENTRYPOINT with CMD for flexible images", func: rule_cmd_without_entrypoint },
57 Rule { id: "DF018", severity: Severity::Warning, description: "Avoid using shell form for ENTRYPOINT", func: rule_shell_form_entrypoint },
58 Rule { id: "DF019", severity: Severity::Warning, description: "Do not use deprecated MAINTAINER; use LABEL instead", func: rule_deprecated_maintainer },
59 Rule { id: "DF022", severity: Severity::Info, description: "Specify EXPOSE for documented ports", func: rule_no_expose },
60 Rule { id: "DF023", severity: Severity::Warning, description: "Avoid multiple FROM without aliases (unintended multistage)", func: rule_multiple_from_no_alias },
61 Rule { id: "DF024", severity: Severity::Warning, description: "Avoid using :latest in FROM even with aliases", func: rule_from_latest_alias },
62 Rule { id: "DF025", severity: Severity::Warning, description: "Use JSON array syntax for CMD/ENTRYPOINT", func: rule_shell_form_cmd },
63 Rule { id: "DF026", severity: Severity::Warning, description: "Avoid recursive COPY from root", func: rule_copy_root },
64 Rule { id: "DF030", severity: Severity::Info, description: "Avoid using pip without --no-cache-dir", func: rule_pip_no_cache },
65 Rule { id: "DF031", severity: Severity::Info, description: "Avoid npm install without ci/--production for prod images", func: rule_npm_install },
66 Rule { id: "DF032", severity: Severity::Info, description: "Set PYTHONDONTWRITEBYTECODE and PYTHONUNBUFFERED for Python images", func: rule_python_env_vars },
67 Rule { id: "DF033", severity: Severity::Info, description: "Use .dockerignore to exclude unnecessary files", func: rule_no_dockerignore },
68 Rule { id: "DF034", severity: Severity::Error, description: "Avoid chmod 777 — overly permissive", func: rule_chmod_777 },
69 Rule { id: "DF035", severity: Severity::Info, description: "Avoid using curl without --fail flags", func: rule_curl_no_fail },
70 Rule { id: "DF036", severity: Severity::Warning, description: "Avoid Dockerfile with no CMD or ENTRYPOINT", func: rule_no_cmd_or_entrypoint },
71 Rule { id: "DF015", severity: Severity::Error, description: "Avoid using apt-get without -y flag", func: rule_apt_no_y },
72 Rule { id: "DF016", severity: Severity::Info, description: "Use --no-install-recommends with apt-get", func: rule_apt_recommends },
73 Rule { id: "DF021", severity: Severity::Error, description: "Avoid wget|sh pipe patterns (execute remote code)", func: rule_curl_pipe_sh },
74 Rule { id: "DF027", severity: Severity::Error, description: "Do not use yum without -y flag", func: rule_yum_no_y },
75 Rule { id: "DF028", severity: Severity::Warning, description: "Cache-bust apt-get update", func: rule_apt_get_update_alone },
76 Rule { id: "DF029", severity: Severity::Warning, description: "Avoid apk add without --no-cache", func: rule_apk_no_cache },
77 Rule { id: "DF037", severity: Severity::Error, description: "Dockerfile must begin with FROM, ARG, or a comment", func: rule_invalid_instruction_order },
78 Rule { id: "DF038", severity: Severity::Warning, description: "Multiple CMD instructions — only the last one takes effect", func: rule_multiple_cmd },
79 Rule { id: "DF039", severity: Severity::Error, description: "Multiple ENTRYPOINT instructions — only the last one takes effect", func: rule_multiple_entrypoint },
80 Rule { id: "DF040", severity: Severity::Error, description: "EXPOSE port must be in valid range 0-65535", func: rule_expose_port_range },
81 Rule { id: "DF041", severity: Severity::Error, description: "Multiple HEALTHCHECK instructions — only the last one applies", func: rule_multiple_healthcheck },
82 Rule { id: "DF042", severity: Severity::Error, description: "FROM stage aliases must be unique", func: rule_unique_stage_aliases },
83 Rule { id: "DF043", severity: Severity::Warning, description: "zypper install without non-interactive flag", func: rule_zypper_no_y },
84 Rule { id: "DF044", severity: Severity::Warning, description: "Avoid zypper dist-upgrade in Dockerfiles", func: rule_zypper_dist_upgrade },
85 Rule { id: "DF045", severity: Severity::Info, description: "Run zypper clean after zypper install", func: rule_zypper_clean },
86 Rule { id: "DF046", severity: Severity::Warning, description: "Run dnf clean all after dnf install", func: rule_dnf_clean },
87 Rule { id: "DF047", severity: Severity::Warning, description: "Run yum clean all after yum install", func: rule_yum_clean },
88 Rule { id: "DF048", severity: Severity::Error, description: "COPY with multiple sources requires destination to end with /", func: rule_copy_multi_arg_slash },
89 Rule { id: "DF049", severity: Severity::Warning, description: "COPY --from must reference a previously defined stage", func: rule_copy_from_undefined_stage },
90 Rule { id: "DF050", severity: Severity::Error, description: "COPY --from cannot reference the current stage", func: rule_copy_from_self },
91 Rule { id: "DF051", severity: Severity::Warning, description: "Pin versions in pip install", func: rule_pip_version_pinning },
92 Rule { id: "DF052", severity: Severity::Warning, description: "Pin versions in apk add", func: rule_apk_version_pinning },
93 Rule { id: "DF053", severity: Severity::Warning, description: "Pin versions in gem install", func: rule_gem_version_pinning },
94 Rule { id: "DF054", severity: Severity::Warning, description: "Pin versions in go install with @version", func: rule_go_install_version },
95 Rule { id: "DF055", severity: Severity::Info, description: "Run yarn cache clean after yarn install", func: rule_yarn_cache_clean },
96 Rule { id: "DF056", severity: Severity::Info, description: "Use wget --progress=dot:giga to avoid bloated build logs", func: rule_wget_no_progress },
97 Rule { id: "DF057", severity: Severity::Warning, description: "Set -o pipefail before RUN commands that use pipes", func: rule_pipefail_missing },
98 Rule { id: "DF058", severity: Severity::Warning, description: "Use either wget or curl consistently, not both", func: rule_wget_and_curl },
99 Rule { id: "DF059", severity: Severity::Warning, description: "Use apt-get or apt-cache instead of apt in scripts", func: rule_apt_instead_of_apt_get },
100 Rule { id: "DF060", severity: Severity::Info, description: "Avoid running pointless interactive commands inside containers", func: rule_useless_commands },
101 Rule { id: "DF061", severity: Severity::Warning, description: "Do not use --platform in FROM unless required", func: rule_from_platform_flag },
102 Rule { id: "DF062", severity: Severity::Error, description: "ENV variable must not reference itself in the same statement", func: rule_env_self_reference },
103 Rule { id: "DF063", severity: Severity::Warning, description: "COPY to relative destination requires WORKDIR to be set first", func: rule_copy_relative_no_workdir },
104 Rule { id: "DF064", severity: Severity::Warning, description: "useradd without -l flag may create excessively large images", func: rule_useradd_no_l },
105 Rule { id: "DF065", severity: Severity::Warning, description: "FROM uses an unrecognised image registry", func: rule_untrusted_registry },
106 Rule { id: "DF066", severity: Severity::Warning, description: "Bash-specific syntax used without a SHELL instruction", func: rule_bash_syntax_no_shell },
107 Rule { id: "DF067", severity: Severity::Info, description: "COPY of a local archive — ADD auto-extracts tarballs", func: rule_copy_archive_use_add },
108 Rule { id: "DF068", severity: Severity::Error, description: "FROM, ONBUILD, and MAINTAINER are forbidden as ONBUILD triggers", func: rule_onbuild_forbidden },
109 Rule { id: "DF069", severity: Severity::Warning, description: "Avoid apt-get upgrade / dist-upgrade — makes builds non-reproducible", func: rule_apt_upgrade },
110 Rule { id: "DF070", severity: Severity::Warning, description: "Avoid broad COPY before package install — invalidates Docker layer cache", func: rule_copy_before_install },
111 ]
112}
113
114fn instrs_of<'a>(instrs: &'a [Instruction], name: &str) -> Vec<&'a Instruction> {
115 instrs.iter().filter(|i| i.instruction == name).collect()
116}
117
118fn has_instr(instrs: &[Instruction], name: &str) -> bool {
119 instrs.iter().any(|i| i.instruction == name)
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123struct FromArguments<'a> {
124 image: &'a str,
125 alias: Option<&'a str>,
126}
127
128fn parse_from_arguments(arguments: &str) -> Option<FromArguments<'_>> {
133 let mut tokens = arguments.split_whitespace();
134 let image = tokens.find(|token| !token.starts_with("--"))?;
135 let alias = match (tokens.next(), tokens.next()) {
136 (Some(keyword), Some(alias)) if keyword.eq_ignore_ascii_case("as") => Some(alias),
137 _ => None,
138 };
139 Some(FromArguments { image, alias })
140}
141
142fn rule_latest_tag(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
143 let mut stage_aliases = std::collections::HashSet::new();
144 let mut findings = Vec::new();
145
146 for instruction in instrs_of(instrs, "FROM") {
147 let Some(from) = parse_from_arguments(&instruction.arguments) else {
148 continue;
149 };
150 let base = from.image;
151 let is_previous_stage = stage_aliases.contains(&base.to_lowercase());
152 if !is_previous_stage
153 && !base.eq_ignore_ascii_case("scratch")
154 && (base.ends_with(":latest") || (!base.contains(':') && !base.contains('@')))
155 {
156 findings.push(Finding {
157 rule: "DF001",
158 severity: Severity::Warning,
159 line: instruction.line,
160 message: format!("'{}' uses an unpinned image tag", base),
161 roast: "Pinning to 'latest' is like ordering 'whatever' at a restaurant and then \
162 complaining when your image breaks in prod. Use a real tag."
163 .to_string(),
164 });
165 }
166 if let Some(alias) = from.alias {
167 stage_aliases.insert(alias.to_lowercase());
168 }
169 }
170
171 findings
172}
173
174fn rule_running_as_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
175 let mut findings = Vec::new();
176 for u in instrs_of(instrs, "USER") {
177 let val = u.arguments.trim().to_lowercase();
178 if val == "root" || val == "0" || val == "0:0" || val == "root:root" {
179 findings.push(Finding {
180 rule: "DF002",
181 severity: Severity::Error,
182 line: u.line,
183 message: "Container is explicitly set to run as root".to_string(),
184 roast: "Congratulations, you're running as root. Your security team is crying, \
185 your CISO is drafting a strongly-worded email, and a hacker somewhere \
186 just smiled.".to_string(),
187 });
188 }
189 }
190 findings
191}
192
193fn rule_no_multistage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
194 let from_count = instrs_of(instrs, "FROM").len();
195 if from_count > 1 { return vec![]; }
196 let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
197 Some(f) => f,
198 None => return vec![],
199 };
200 let build_images = ["golang", "node", "rust", "maven", "gradle", "openjdk", "python", "dotnet", "gcc"];
201 let img = first_from.arguments.to_lowercase();
202 if build_images.iter().any(|b| img.contains(b)) {
203 return vec![Finding {
204 rule: "DF011",
205 severity: Severity::Warning,
206 line: first_from.line,
207 message: "Single-stage build with a heavy build image — consider multi-stage builds".to_string(),
208 roast: "Shipping your entire build toolchain to production? Your 2GB Go image is \
209 basically a free gift to anyone who gets shell access. Multi-stage builds \
210 exist. They're fantastic. Use them.".to_string(),
211 }];
212 }
213 vec![]
214}
215
216fn rule_many_run_layers(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
217 let mut findings = Vec::new();
218 let mut consecutive = 0usize;
219 let mut start_line = 0usize;
220 for i in instrs {
221 if i.instruction == "RUN" {
222 if consecutive == 0 { start_line = i.line; }
223 consecutive += 1;
224 } else if i.instruction == "FROM" {
225 consecutive = 0;
226 } else if consecutive > 0 {
227 if consecutive >= 4 {
228 findings.push(Finding {
229 rule: "DF003",
230 severity: Severity::Warning,
231 line: start_line,
232 message: format!("{} consecutive RUN instructions could be merged into one", consecutive),
233 roast: format!(
234 "{} separate RUN layers? Your image has more layers than a mid-2000s emo \
235 band. Combine them with && and save everyone's bandwidth.", consecutive
236 ),
237 });
238 }
239 consecutive = 0;
240 }
241 }
242 if consecutive >= 4 {
243 findings.push(Finding {
244 rule: "DF003",
245 severity: Severity::Warning,
246 line: start_line,
247 message: format!("{} consecutive RUN instructions could be merged into one", consecutive),
248 roast: format!(
249 "{} separate RUN layers? Your image is basically an onion — except nobody's \
250 crying because it's beautiful; they're crying because it takes 10 minutes to pull.", consecutive
251 ),
252 });
253 }
254 findings
255}
256
257fn rule_add_instead_of_copy(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
258 instrs_of(instrs, "ADD")
259 .into_iter()
260 .filter(|i| {
261 let source = match i.arguments.split_whitespace()
263 .find(|t| !t.starts_with("--"))
264 {
265 Some(s) => s,
266 None => return false,
267 };
268 let is_url = source.contains("://");
269 let is_archive = source.ends_with(".tar.gz")
270 || source.ends_with(".tgz")
271 || source.ends_with(".tar.xz")
272 || source.ends_with(".tar.bz2")
273 || source.ends_with(".tar");
274 !is_url && !is_archive
275 })
276 .map(|i| Finding {
277 rule: "DF006",
278 severity: Severity::Warning,
279 line: i.line,
280 message: "ADD used for local file — prefer COPY".to_string(),
281 roast: "Using ADD to copy local files is like taking a helicopter to cross the \
282 street. COPY exists, it's right there, it's boring and correct — which is \
283 everything you want in infrastructure.".to_string(),
284 })
285 .collect()
286}
287
288fn rule_copy_all(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
289 instrs_of(instrs, "COPY")
290 .into_iter()
291 .filter(|i| { let a = i.arguments.trim(); a.starts_with(". ") || a == "." })
292 .map(|i| Finding {
293 rule: "DF007",
294 severity: Severity::Warning,
295 line: i.line,
296 message: "COPY . copies the entire build context — consider a .dockerignore file".to_string(),
297 roast: "COPY . — dumping your entire project including node_modules, .git history, \
298 and that .env file with the production database password into the image. \
299 Bold. Reckless. Very DevOps of you.".to_string(),
300 })
301 .collect()
302}
303
304fn rule_cd_instead_of_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
305 let re = Regex::new(r"\bcd\s+[^\s;|&]+").unwrap();
306 instrs_of(instrs, "RUN")
307 .into_iter()
308 .filter(|i| re.is_match(&i.arguments))
309 .map(|i| Finding {
310 rule: "DF008",
311 severity: Severity::Info,
312 line: i.line,
313 message: "Using 'cd' in RUN — prefer WORKDIR instruction".to_string(),
314 roast: "`cd` in a RUN instruction: not wrong, but every new RUN starts fresh anyway, \
315 so you're cosplaying as a shell script when you should be writing a Dockerfile. \
316 WORKDIR is your friend.".to_string(),
317 })
318 .collect()
319}
320
321fn rule_relative_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
322 instrs_of(instrs, "WORKDIR")
323 .into_iter()
324 .filter(|i| !i.arguments.trim().starts_with('/') && !i.arguments.trim().starts_with('$'))
325 .map(|i| Finding {
326 rule: "DF009",
327 severity: Severity::Warning,
328 line: i.line,
329 message: format!("WORKDIR '{}' is relative — use an absolute path", i.arguments.trim()),
330 roast: "A relative WORKDIR? You're setting your working directory relative to... \
331 what, exactly? Hope? Dreams? Use an absolute path like a grown-up.".to_string(),
332 })
333 .collect()
334}
335
336fn rule_sudo_usage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
337 let re = Regex::new(r"\bsudo\b").unwrap();
338 instrs_of(instrs, "RUN")
339 .into_iter()
340 .filter(|i| re.is_match(&i.arguments))
341 .map(|i| Finding {
342 rule: "DF010",
343 severity: Severity::Warning,
344 line: i.line,
345 message: "sudo used inside a container — likely unnecessary".to_string(),
346 roast: "sudo inside a Docker container? You're already root (probably). sudo is \
347 just a formality at this point, like putting a 'Wet Floor' sign in the ocean.".to_string(),
348 })
349 .collect()
350}
351
352fn rule_no_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
353 if has_instr(instrs, "HEALTHCHECK") { return vec![]; }
354 if !has_instr(instrs, "EXPOSE") && !has_instr(instrs, "CMD") { return vec![]; }
355 vec![Finding {
356 rule: "DF012",
357 severity: Severity::Info,
358 line: 0,
359 message: "No HEALTHCHECK defined".to_string(),
360 roast: "No HEALTHCHECK? Your container is basically on the honor system. 'It's fine, \
361 I'm sure it's fine.' Meanwhile Kubernetes is just restarting it every 30 seconds \
362 wondering what went wrong.".to_string(),
363 }]
364}
365
366fn rule_cmd_without_entrypoint(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
367 vec![]
368}
369
370fn rule_shell_form_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
371 instrs_of(instrs, "ENTRYPOINT")
372 .into_iter()
373 .filter(|i| !i.arguments.trim().starts_with('['))
374 .map(|i| Finding {
375 rule: "DF018",
376 severity: Severity::Warning,
377 line: i.line,
378 message: "ENTRYPOINT in shell form prevents signal propagation".to_string(),
379 roast: "Shell-form ENTRYPOINT means your app runs as a child of /bin/sh. When \
380 Kubernetes sends SIGTERM, your app doesn't get it — /bin/sh does, and \
381 /bin/sh doesn't care. Use exec form: ENTRYPOINT [\"cmd\", \"arg\"].".to_string(),
382 })
383 .collect()
384}
385
386fn rule_deprecated_maintainer(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
387 instrs_of(instrs, "MAINTAINER")
388 .into_iter()
389 .map(|i| Finding {
390 rule: "DF019",
391 severity: Severity::Warning,
392 line: i.line,
393 message: "MAINTAINER is deprecated".to_string(),
394 roast: "MAINTAINER has been deprecated since Docker 1.13. That was 2017. \
395 Your Dockerfile is old enough to be in middle school. \
396 Use LABEL maintainer=\"...\" like the rest of us.".to_string(),
397 })
398 .collect()
399}
400
401fn rule_no_expose(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
402 if has_instr(instrs, "EXPOSE") { return vec![]; }
403 if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") { return vec![]; }
404 vec![Finding {
405 rule: "DF022",
406 severity: Severity::Info,
407 line: 0,
408 message: "No EXPOSE instruction — consider documenting which ports this service uses".to_string(),
409 roast: "No EXPOSE? Your container is a mystery box. Is it a web server? A database? \
410 A very slow random number generator? EXPOSE is documentation — it tells the \
411 next developer which port to knock on.".to_string(),
412 }]
413}
414
415fn rule_multiple_from_no_alias(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
416 let froms: Vec<_> = instrs_of(instrs, "FROM");
417 if froms.len() <= 1 { return vec![]; }
418 froms.into_iter()
419 .skip(1)
420 .filter(|i| parse_from_arguments(&i.arguments).is_some_and(|from| from.alias.is_none()))
421 .map(|i| Finding {
422 rule: "DF023",
423 severity: Severity::Warning,
424 line: i.line,
425 message: "Multi-stage FROM without AS alias — hard to reference later".to_string(),
426 roast: "Multi-stage FROM without an alias. How will you COPY --from=... this? \
427 By index? \"--from=2\"? That's fragile. Give your stages names like \
428 a civilized person. FROM golang:1.21 AS builder.".to_string(),
429 })
430 .collect()
431}
432
433fn rule_from_latest_alias(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
434 vec![]
435}
436
437fn rule_shell_form_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
438 instrs_of(instrs, "CMD")
439 .into_iter()
440 .filter(|i| !i.arguments.trim().starts_with('['))
441 .map(|i| Finding {
442 rule: "DF025",
443 severity: Severity::Warning,
444 line: i.line,
445 message: "CMD in shell form — prefer exec form [\"executable\", \"arg\"]".to_string(),
446 roast: "Shell-form CMD wraps your process in /bin/sh -c, which means PID 1 is the \
447 shell, not your app. Signal handling breaks, graceful shutdown breaks, and \
448 your ops team breaks (emotionally). Use exec form.".to_string(),
449 })
450 .collect()
451}
452
453fn rule_copy_root(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
454 instrs_of(instrs, "COPY")
455 .into_iter()
456 .filter(|i| {
457 let a = i.arguments.trim();
458 a.ends_with(" /") || a.contains(" / ") || a.ends_with("/.")
459 })
460 .map(|i| Finding {
461 rule: "DF026",
462 severity: Severity::Warning,
463 line: i.line,
464 message: "COPY to filesystem root — this may overwrite system files".to_string(),
465 roast: "Copying files directly to /? Brave. Reckless. Chaotic. You're one typo away \
466 from overwriting /bin/sh and creating a container that doesn't even boot. \
467 Use a dedicated app directory.".to_string(),
468 })
469 .collect()
470}
471
472fn rule_pip_no_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
473 instrs_of(instrs, "RUN")
474 .into_iter()
475 .filter(|i| {
476 let a = &i.arguments;
477 (a.contains("pip install") || a.contains("pip3 install")) && !a.contains("--no-cache-dir")
478 })
479 .map(|i| Finding {
480 rule: "DF030",
481 severity: Severity::Info,
482 line: i.line,
483 message: "pip install without --no-cache-dir wastes space in the image layer".to_string(),
484 roast: "pip install without --no-cache-dir? You're carrying around a pip cache in \
485 your production image like a tourist with a suitcase full of hotel shampoos. \
486 You don't need those. Add --no-cache-dir.".to_string(),
487 })
488 .collect()
489}
490
491fn rule_npm_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
492 let npm_install = Regex::new(r"\bnpm\s+install\b").expect("valid npm install regex");
493 instrs_of(instrs, "RUN")
494 .into_iter()
495 .filter(|i| {
496 let a = &i.arguments;
497 npm_install.is_match(a) && !a.contains("--production") && !a.contains("--omit=dev")
498 })
499 .map(|i| Finding {
500 rule: "DF031",
501 severity: Severity::Info,
502 line: i.line,
503 message: "npm install used — consider npm ci for reproducible builds".to_string(),
504 roast: "`npm install` in a Dockerfile: non-deterministic, slower than `npm ci`, \
505 and potentially installs different versions than your lockfile specifies. \
506 `npm ci` exists specifically for CI/CD and containers. Use it.".to_string(),
507 })
508 .collect()
509}
510
511fn rule_python_env_vars(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
512 let first_from = match instrs_of(instrs, "FROM").into_iter().next() {
513 Some(f) => f,
514 None => return vec![],
515 };
516 if !first_from.arguments.to_lowercase().contains("python") { return vec![]; }
517 let env_args: String = instrs_of(instrs, "ENV").iter().map(|i| i.arguments.as_str()).collect::<Vec<_>>().join(" ");
518 let mut findings = Vec::new();
519 if !env_args.contains("PYTHONDONTWRITEBYTECODE") {
520 findings.push(Finding {
521 rule: "DF032",
522 severity: Severity::Info,
523 line: 0,
524 message: "PYTHONDONTWRITEBYTECODE not set — Python will write .pyc files to the image".to_string(),
525 roast: "Python is quietly writing .pyc bytecode files all over your image. \
526 Set PYTHONDONTWRITEBYTECODE=1 and stop Python from hoarding compiled cache \
527 files in your container like a digital hoarder.".to_string(),
528 });
529 }
530 if !env_args.contains("PYTHONUNBUFFERED") {
531 findings.push(Finding {
532 rule: "DF032",
533 severity: Severity::Info,
534 line: 0,
535 message: "PYTHONUNBUFFERED not set — Python output may not appear in logs".to_string(),
536 roast: "PYTHONUNBUFFERED not set? Your Python app is buffering stdout, meaning \
537 logs disappear into the void and you won't see output until the buffer \
538 flushes — which is never, because your container crashed. Set PYTHONUNBUFFERED=1.".to_string(),
539 });
540 }
541 findings
542}
543
544fn rule_no_dockerignore(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
545 vec![]
546}
547
548fn rule_chmod_777(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
549 let re = Regex::new(r"chmod\s+([-R\s]*)777").unwrap();
550 instrs_of(instrs, "RUN")
551 .into_iter()
552 .filter(|i| re.is_match(&i.arguments))
553 .map(|i| Finding {
554 rule: "DF034",
555 severity: Severity::Error,
556 line: i.line,
557 message: "chmod 777 grants world-writable permissions — overly permissive".to_string(),
558 roast: "chmod 777? Giving everyone read, write, and execute access is the filesystem \
559 equivalent of leaving your front door open with a sign that says \
560 'free stuff inside'. Minimum permissions, please.".to_string(),
561 })
562 .collect()
563}
564
565fn rule_curl_no_fail(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
566 instrs_of(instrs, "RUN")
567 .into_iter()
568 .filter(|i| {
569 let a = &i.arguments;
570 let has_url = a.contains("http://") || a.contains("https://") || a.contains("ftp://");
572 has_url
573 && a.contains("curl")
574 && !a.contains("--fail")
575 && !a.contains("-fsSL")
576 && !a.contains("-fsS")
577 && !a.contains("-fL")
578 && !a.contains("-fs ")
579 && !{
580 let mut found = false;
581 for part in a.split_whitespace() {
582 if part.starts_with('-') && !part.starts_with("--") && part.contains('f') {
583 found = true;
584 break;
585 }
586 }
587 found
588 }
589 })
590 .map(|i| Finding {
591 rule: "DF035",
592 severity: Severity::Info,
593 line: i.line,
594 message: "curl without --fail — HTTP errors won't cause the RUN step to fail".to_string(),
595 roast: "curl without --fail means a 404 or 500 response silently succeeds. \
596 Your build will happily continue after downloading an error page and \
597 treating it as a binary. Add --fail and save yourself a 2am debugging session.".to_string(),
598 })
599 .collect()
600}
601
602fn rule_no_cmd_or_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
603 if has_instr(instrs, "CMD") || has_instr(instrs, "ENTRYPOINT") { return vec![]; }
604 if instrs.len() < 3 { return vec![]; }
605 vec![Finding {
606 rule: "DF036",
607 severity: Severity::Warning,
608 line: 0,
609 message: "No CMD or ENTRYPOINT defined — the container has no default command".to_string(),
610 roast: "No CMD or ENTRYPOINT? This container starts, does nothing, and immediately exits \
611 like an intern on their first day who didn't read the onboarding docs. \
612 Tell it what to run.".to_string(),
613 }]
614}
615
616fn rule_uncleaned_package_cache(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
617 let apt_distclean = Regex::new(r"\bapt-get\s+distclean\b").expect("valid apt distclean regex");
618 let mut findings = Vec::new();
619 for i in instrs_of(instrs, "RUN") {
620 let arg = &i.arguments;
621 let has_apt = arg.contains("apt-get install") || arg.contains("apt install");
622 let has_yum = arg.contains("yum install") || arg.contains("dnf install");
623 let has_apk = arg.contains("apk add") && !arg.contains("--no-cache");
624 let cleans_apt_lists =
625 arg.contains("rm -rf /var/lib/apt/lists") || apt_distclean.is_match(arg);
626 if has_apt && !cleans_apt_lists {
627 findings.push(Finding {
628 rule: "DF004",
629 severity: Severity::Warning,
630 line: i.line,
631 message: "apt cache not cleaned after install — adds unnecessary layer size".to_string(),
632 roast: "Not cleaning the apt cache is like finishing a meal and leaving all the \
633 wrappers in the container. Your image is now a trash can. A very expensive \
634 trash can stored in ECR.".to_string(),
635 });
636 }
637 if has_yum && !arg.contains("yum clean all") && !arg.contains("dnf clean all") {
638 findings.push(Finding {
639 rule: "DF004",
640 severity: Severity::Warning,
641 line: i.line,
642 message: "yum/dnf cache not cleaned after install".to_string(),
643 roast: "You installed packages with yum but didn't clean up. Every megabyte of \
644 cache you leave is a megabyte of shame floating in your registry.".to_string(),
645 });
646 }
647 if has_apk {
648 findings.push(Finding {
649 rule: "DF029",
650 severity: Severity::Warning,
651 line: i.line,
652 message: "apk add without --no-cache flag".to_string(),
653 roast: "Using `apk add` without `--no-cache`? You chose Alpine to save space and \
654 then immediately gained it all back. That's impressive, in a bad way.".to_string(),
655 });
656 }
657 }
658 findings
659}
660
661fn rule_unpinned_packages(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
662 let re_yum = Regex::new(r"yum install[^&|;]*").unwrap();
663 let mut findings = Vec::new();
664 for i in instrs_of(instrs, "RUN") {
665 if apt_install_commands(&i.arguments)
666 .iter()
667 .any(|(tokens, install_index)| apt_has_unpinned_package(tokens, *install_index))
668 {
669 findings.push(Finding {
670 rule: "DF005",
671 severity: Severity::Info,
672 line: i.line,
673 message: "apt-get install without pinned package versions".to_string(),
674 roast: "Unpinned packages: a bold way to ensure your build is different \
675 every single time. 'It worked on my machine' is a lifestyle choice, \
676 not a deployment strategy.".to_string(),
677 });
678 }
679 for _cap in re_yum.find_iter(&i.arguments) {
680 findings.push(Finding {
681 rule: "DF005",
682 severity: Severity::Info,
683 line: i.line,
684 message: "yum install without pinned package versions".to_string(),
685 roast: "Your yum packages are pinned to 'whatever yum feels like today'. \
686 Reproducibility called — it's going to voicemail.".to_string(),
687 });
688 break;
689 }
690 }
691 findings
692}
693
694fn apt_install_commands(arguments: &str) -> Vec<(Vec<&str>, usize)> {
695 arguments
696 .split(['&', '|', ';'])
697 .filter_map(|segment| {
698 let segment_tokens: Vec<_> = segment.split_whitespace().collect();
699 let apt_index = segment_tokens
700 .iter()
701 .position(|token| matches!(*token, "apt" | "apt-get"))?;
702 let tokens = segment_tokens[apt_index + 1..].to_vec();
703 let install_index = tokens.iter().position(|token| *token == "install")?;
704 Some((tokens, install_index))
705 })
706 .collect()
707}
708
709fn apt_has_unpinned_package(tokens: &[&str], install_index: usize) -> bool {
710 if tokens.contains(&"--only-upgrade") {
711 return false;
712 }
713
714 let options_with_values = [
715 "-a",
716 "--host-architecture",
717 "-c",
718 "--config-file",
719 "-o",
720 "--option",
721 "-q",
722 "--quiet",
723 "-t",
724 "--target-release",
725 ];
726 let mut skip_option_value = false;
727 for token in tokens.iter().skip(install_index + 1) {
728 if skip_option_value {
729 skip_option_value = false;
730 continue;
731 }
732 if options_with_values.contains(token) {
733 skip_option_value = true;
734 continue;
735 }
736 if token.starts_with('-') {
737 continue;
738 }
739 if !token.contains('=') {
740 return true;
741 }
742 }
743 false
744}
745
746fn apt_assumes_yes(tokens: &[&str]) -> bool {
747 for (index, token) in tokens.iter().enumerate() {
748 if matches!(*token, "--yes" | "--assume-yes") {
749 return true;
750 }
751 if let Some(short_options) = token.strip_prefix('-').filter(|_| !token.starts_with("--")) {
752 if short_options.contains('y') || short_options.chars().filter(|c| *c == 'q').count() >= 2 {
753 return true;
754 }
755 if short_options
756 .strip_prefix("q=")
757 .and_then(|level| level.parse::<u8>().ok())
758 .is_some_and(|level| level >= 2)
759 {
760 return true;
761 }
762 }
763 if token
764 .strip_prefix("--quiet=")
765 .and_then(|level| level.parse::<u8>().ok())
766 .is_some_and(|level| level >= 2)
767 {
768 return true;
769 }
770 if matches!(*token, "-q" | "--quiet")
771 && tokens
772 .get(index + 1)
773 .and_then(|level| level.parse::<u8>().ok())
774 .is_some_and(|level| level >= 2)
775 {
776 return true;
777 }
778 }
779 false
780}
781
782fn rule_apt_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
783 instrs_of(instrs, "RUN")
784 .into_iter()
785 .filter(|i| {
786 let a = &i.arguments;
787 apt_install_commands(a)
788 .iter()
789 .any(|(tokens, _)| !apt_assumes_yes(tokens))
790 && !a.contains("DEBIAN_FRONTEND=noninteractive")
791 })
792 .map(|i| Finding {
793 rule: "DF015",
794 severity: Severity::Error,
795 line: i.line,
796 message: "apt-get install without -y flag will hang waiting for user input".to_string(),
797 roast: "apt-get install without -y? Your build is going to sit there, patiently \
798 waiting for a 'yes' that will never come, like a golden retriever waiting \
799 for an owner who's on a cruise ship.".to_string(),
800 })
801 .collect()
802}
803
804fn rule_apt_recommends(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
805 instrs_of(instrs, "RUN")
806 .into_iter()
807 .filter(|i| {
808 let a = &i.arguments;
809 (a.contains("apt-get install") || a.contains("apt install"))
810 && !a.contains("--no-install-recommends")
811 })
812 .map(|i| Finding {
813 rule: "DF016",
814 severity: Severity::Info,
815 line: i.line,
816 message: "apt-get install without --no-install-recommends installs extra packages".to_string(),
817 roast: "Installing without --no-install-recommends? apt is now installing packages \
818 you didn't ask for, like a waiter who brings you a full bread basket when \
819 you said you're gluten-free. `--no-install-recommends` is right there.".to_string(),
820 })
821 .collect()
822}
823
824fn rule_yum_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
825 instrs_of(instrs, "RUN")
826 .into_iter()
827 .filter(|i| {
828 let a = &i.arguments;
829 (a.contains("yum install") || a.contains("dnf install"))
830 && !a.contains("-y") && !a.contains("--assumeyes")
831 })
832 .map(|i| Finding {
833 rule: "DF027",
834 severity: Severity::Error,
835 line: i.line,
836 message: "yum/dnf install without -y flag will hang waiting for user input".to_string(),
837 roast: "yum install without -y. Your build will hang indefinitely, \
838 waiting for input in a non-interactive environment. \
839 It's not coming. Add -y and move on.".to_string(),
840 })
841 .collect()
842}
843
844fn rule_apt_get_update_alone(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
845 let mut findings = Vec::new();
846 let mut prev_was_update = false;
847 let mut update_line = 0;
848 for i in instrs {
849 if i.instruction == "RUN" {
850 let a = &i.arguments;
851 let has_update = a.contains("apt-get update") || a.contains("apt update");
852 let has_install = a.contains("apt-get install") || a.contains("apt install");
853 if has_update && !has_install {
854 prev_was_update = true;
855 update_line = i.line;
856 } else if has_install && !has_update && prev_was_update {
857 findings.push(Finding {
858 rule: "DF028",
859 severity: Severity::Warning,
860 line: update_line,
861 message: "apt-get update in a separate RUN from apt-get install causes cache poisoning".to_string(),
862 roast: "Splitting `apt-get update` and `apt-get install` into separate RUN \
863 layers is a classic mistake. Docker caches the update layer and \
864 your install may use a stale index. Combine them with && or enjoy \
865 mysterious 404 errors.".to_string(),
866 });
867 prev_was_update = false;
868 } else {
869 prev_was_update = false;
870 }
871 }
872 }
873 findings
874}
875
876fn rule_apk_no_cache(_instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
877 vec![]
879}
880
881fn rule_secrets_in_env(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
882 let secret_patterns = ["password", "passwd", "secret", "token", "api_key", "apikey",
883 "private_key", "auth_token", "access_key", "secret_key",
884 "db_pass", "database_password"];
885 let mut findings = Vec::new();
886 for i in instrs_of(instrs, "ENV") {
887 let lower = i.arguments.to_lowercase();
888 for pat in &secret_patterns {
889 if lower.contains(pat) {
890 findings.push(Finding {
891 rule: "DF013",
892 severity: Severity::Error,
893 line: i.line,
894 message: format!("Potential secret in ENV variable (matched: '{}')", pat),
895 roast: format!(
896 "You put a '{}' in an ENV instruction. Congratulations — it's now \
897 immortalized in your image layers, your registry, your CI logs, \
898 and probably a security audit finding. Use Docker secrets or a vault.",
899 pat
900 ),
901 });
902 break;
903 }
904 }
905 }
906 findings
907}
908
909fn rule_hardcoded_secrets(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
910 let re = Regex::new(r"(?i)(password|secret|token|key|passwd)\s*=\s*\S+").unwrap();
911 let mut findings = Vec::new();
912 for i in instrs.iter().filter(|i| i.instruction == "ARG" || i.instruction == "ENV") {
913 if let Some(cap) = re.find(&i.arguments) {
914 let parts: Vec<&str> = cap.as_str().splitn(2, '=').collect();
915 if parts.len() == 2 {
916 let val = parts[1].trim();
917 if !val.is_empty() && !val.starts_with('$') && val != "\"\"" && val != "''" {
918 findings.push(Finding {
919 rule: "DF014",
920 severity: Severity::Error,
921 line: i.line,
922 message: "Hardcoded secret value detected in ARG/ENV".to_string(),
923 roast: "A hardcoded secret! How delightfully naive. It's in your git \
924 history forever now. Have fun rotating that. Maybe consider \
925 build secrets or runtime injection next time?".to_string(),
926 });
927 }
928 }
929 }
930 }
931 findings
932}
933
934fn rule_curl_pipe_sh(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
935 let re = Regex::new(r"(curl|wget)[^|]*\|\s*(bash|sh|ash|zsh|fish)").unwrap();
936 instrs_of(instrs, "RUN")
937 .into_iter()
938 .filter(|i| re.is_match(&i.arguments))
939 .map(|i| Finding {
940 rule: "DF021",
941 severity: Severity::Error,
942 line: i.line,
943 message: "Piping remote script directly to shell (curl/wget | sh)".to_string(),
944 roast: "curl | sh: the technical equivalent of 'hold my beer'. You're downloading \
945 code from the internet and executing it blind, inside your container, \
946 and shipping it to prod. Your threat model is vibes.".to_string(),
947 })
948 .collect()
949}
950
951fn rule_apt_instead_of_apt_get(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
952 let re = Regex::new(r"\bapt\s+(install|remove|update|upgrade|list|search|show|purge)\b").unwrap();
953 instrs_of(instrs, "RUN")
954 .into_iter()
955 .filter(|i| re.is_match(&i.arguments))
956 .map(|i| Finding {
957 rule: "DF059",
958 severity: Severity::Warning,
959 line: i.line,
960 message: "apt used instead of apt-get — apt is an end-user tool, not suited for scripts".to_string(),
961 roast: "`apt` is designed for humans: it has progress bars, color output, and a \
962 warning that says 'do not use in scripts'. You are in a script. \
963 Use apt-get or apt-cache instead.".to_string(),
964 })
965 .collect()
966}
967
968fn rule_useless_commands(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
969 let useless = ["ssh ", "vim ", "nano ", "emacs ", "shutdown", "reboot",
970 "service ", "systemctl ", "ifconfig ", "iwconfig",
971 "free ", "top ", "htop ", "mount ", "umount "];
972 let mut findings = Vec::new();
973 for i in instrs_of(instrs, "RUN") {
974 for cmd in &useless {
975 if i.arguments.contains(cmd) {
976 findings.push(Finding {
977 rule: "DF060",
978 severity: Severity::Info,
979 line: i.line,
980 message: format!(
981 "Command '{}' makes little sense inside a container",
982 cmd.trim()
983 ),
984 roast: format!(
985 "`{}` in a Dockerfile: you're running a command that assumes a full \
986 interactive OS environment inside a container. It doesn't apply here. \
987 Containers are not VMs.",
988 cmd.trim()
989 ),
990 });
991 break;
992 }
993 }
994 }
995 findings
996}
997
998fn rule_from_platform_flag(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
999 instrs_of(instrs, "FROM")
1000 .into_iter()
1001 .filter(|i| i.arguments.contains("--platform"))
1002 .map(|i| Finding {
1003 rule: "DF061",
1004 severity: Severity::Warning,
1005 line: i.line,
1006 message: "FROM uses --platform flag — consider whether cross-platform targeting is intentional".to_string(),
1007 roast: "--platform in FROM forces a specific architecture. If you're building for \
1008 amd64 but deploying on arm64, your image will be slow or broken. \
1009 Make sure this is intentional and documented.".to_string(),
1010 })
1011 .collect()
1012}
1013
1014fn rule_env_self_reference(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1015 let re = Regex::new(r"(\w+)=\s*[\x22\x27]?\$\{?(\w+)\}?").unwrap();
1016 let mut findings = Vec::new();
1017 for i in instrs_of(instrs, "ENV") {
1018 for cap in re.captures_iter(&i.arguments) {
1019 let defined = &cap[1];
1020 let referenced = &cap[2];
1021 if defined == referenced {
1022 findings.push(Finding {
1023 rule: "DF062",
1024 severity: Severity::Error,
1025 line: i.line,
1026 message: format!(
1027 "ENV variable '{}' references itself in the same statement",
1028 defined
1029 ),
1030 roast: format!(
1031 "ENV {}=${{{}}} — you're defining a variable using itself. \
1032 It hasn't been set yet at this point in the same ENV instruction. \
1033 The result will be an empty string. Split it into two ENV statements.",
1034 defined, referenced
1035 ),
1036 });
1037 break;
1038 }
1039 }
1040 }
1041 findings
1042}
1043
1044fn rule_copy_relative_no_workdir(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1045 let mut stage_workdirs: std::collections::HashMap<String, bool> =
1046 std::collections::HashMap::new();
1047 let mut current_alias: Option<String> = None;
1048 let mut workdir_set = false;
1049 let mut findings = Vec::new();
1050 for i in instrs {
1051 if i.instruction == "FROM" {
1052 if let Some(from) = parse_from_arguments(&i.arguments) {
1053 workdir_set = stage_workdirs
1054 .get(&from.image.to_lowercase())
1055 .copied()
1056 .unwrap_or(false);
1057 current_alias = from.alias.map(str::to_lowercase);
1058 if let Some(alias) = ¤t_alias {
1059 stage_workdirs.insert(alias.clone(), workdir_set);
1060 }
1061 } else {
1062 workdir_set = false;
1063 current_alias = None;
1064 }
1065 } else if i.instruction == "WORKDIR" {
1066 workdir_set = true;
1067 if let Some(alias) = ¤t_alias {
1068 stage_workdirs.insert(alias.clone(), true);
1069 }
1070 } else if i.instruction == "COPY" {
1071 let args: Vec<&str> = i.arguments.split_whitespace()
1072 .filter(|t| !t.starts_with("--"))
1073 .collect();
1074 if let Some(dest) = args.last() {
1075 if !dest.starts_with('/') && !dest.starts_with('$') && !workdir_set {
1076 findings.push(Finding {
1077 rule: "DF063",
1078 severity: Severity::Warning,
1079 line: i.line,
1080 message: format!(
1081 "COPY to relative destination '{}' but no WORKDIR has been set",
1082 dest
1083 ),
1084 roast: format!(
1085 "COPY to '{}' with no WORKDIR set. Relative destinations depend on \
1086 the working directory, which defaults to /. \
1087 Set WORKDIR explicitly before using relative paths.",
1088 dest
1089 ),
1090 });
1091 }
1092 }
1093 }
1094 }
1095 findings
1096}
1097
1098fn rule_useradd_no_l(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1099 let re = Regex::new(r"\buseradd\b").unwrap();
1100 instrs_of(instrs, "RUN")
1101 .into_iter()
1102 .filter(|i| re.is_match(&i.arguments) && !i.arguments.contains(" -l") && !i.arguments.contains("--no-log-init"))
1103 .map(|i| Finding {
1104 rule: "DF064",
1105 severity: Severity::Warning,
1106 line: i.line,
1107 message: "useradd without -l flag — high UIDs create oversized /var/log/lastlog entries".to_string(),
1108 roast: "useradd without -l (--no-log-init): with a high UID, this creates a sparse \
1109 file in /var/log/lastlog that can balloon your image size by gigabytes. \
1110 Add -l or use --no-log-init.".to_string(),
1111 })
1112 .collect()
1113}
1114
1115fn rule_copy_archive_use_add(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1116 const ARCHIVE_EXTS: &[&str] = &[
1117 ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar",
1118 ];
1119 instrs_of(instrs, "COPY")
1120 .into_iter()
1121 .filter(|i| {
1122 if i.arguments.contains("--from=") || i.arguments.contains("--from =") {
1124 return false;
1125 }
1126 let sources: Vec<&str> = i.arguments
1127 .split_whitespace()
1128 .filter(|t| !t.starts_with("--"))
1129 .collect();
1130 if sources.len() < 2 { return false; }
1132 sources[..sources.len() - 1]
1134 .iter()
1135 .any(|s| ARCHIVE_EXTS.iter().any(|ext| s.ends_with(ext)))
1136 })
1137 .map(|i| Finding {
1138 rule: "DF067",
1139 severity: Severity::Info,
1140 line: i.line,
1141 message: "COPY of archive file — consider ADD which auto-extracts local tarballs".to_string(),
1142 roast: "COPY drops the compressed archive as-is; you'll need a separate \
1143 RUN tar -xzf layer to unpack it. ADD auto-extracts local tarballs into \
1144 the destination directory and saves you the extra layer. \
1145 Yes, this is the one situation where ADD is actually the right choice.".to_string(),
1146 })
1147 .collect()
1148}
1149
1150fn rule_onbuild_forbidden(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1151 const FORBIDDEN: &[&str] = &["FROM", "ONBUILD", "MAINTAINER"];
1152 let mut findings = Vec::new();
1153 for i in instrs_of(instrs, "ONBUILD") {
1154 let triggered = i.arguments
1155 .split_whitespace()
1156 .next()
1157 .unwrap_or("")
1158 .to_uppercase();
1159 if FORBIDDEN.contains(&triggered.as_str()) {
1160 findings.push(Finding {
1161 rule: "DF068",
1162 severity: Severity::Error,
1163 line: i.line,
1164 message: format!(
1165 "ONBUILD {} is forbidden — {} cannot be used as an ONBUILD trigger",
1166 triggered, triggered
1167 ),
1168 roast: format!(
1169 "ONBUILD {} is explicitly prohibited by Docker. \
1170 FROM would create a recursive inheritance loop, \
1171 ONBUILD ONBUILD is a depth-2 trap nobody asked for, \
1172 and MAINTAINER is deprecated everywhere, including here. \
1173 This fails at build time.",
1174 triggered
1175 ),
1176 });
1177 }
1178 }
1179 findings
1180}
1181
1182fn rule_bash_syntax_no_shell(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1183 if has_instr(instrs, "SHELL") { return vec![]; }
1185 const BASH_ONLY: &[(&str, &str)] = &[
1187 ("[[ ", "double-bracket conditional"),
1188 ("source ", "source builtin (use '.' in POSIX sh)"),
1189 ("declare ", "declare builtin"),
1190 ("mapfile ", "mapfile builtin"),
1191 ("readarray ", "readarray builtin"),
1192 ("${!", "indirect variable expansion"),
1193 ];
1194 let mut findings = Vec::new();
1195 for i in instrs_of(instrs, "RUN") {
1196 for (pattern, label) in BASH_ONLY {
1197 if i.arguments.contains(pattern) {
1198 findings.push(Finding {
1199 rule: "DF066",
1200 severity: Severity::Warning,
1201 line: i.line,
1202 message: format!(
1203 "RUN uses bash-specific syntax ({}) but no SHELL instruction is set",
1204 label
1205 ),
1206 roast: format!(
1207 "'{}' is bash syntax. The default shell is /bin/sh, which on Alpine, \
1208 Debian-slim, and distroless is NOT bash. Add \
1209 `SHELL [\"/bin/bash\", \"-c\"]` before this RUN or your build \
1210 will fail in ways that are confusing to debug.",
1211 pattern.trim()
1212 ),
1213 });
1214 break;
1215 }
1216 }
1217 }
1218 findings
1219}
1220
1221fn rule_untrusted_registry(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1222 const TRUSTED: &[&str] = &[
1223 "docker.io", "registry-1.docker.io",
1224 "ghcr.io", "gcr.io", "quay.io",
1225 "mcr.microsoft.com", "registry.access.redhat.com",
1226 "public.ecr.aws", "registry.k8s.io", "k8s.gcr.io",
1227 ];
1228 let mut findings = Vec::new();
1229 for i in instrs_of(instrs, "FROM") {
1230 let image = match i.arguments.split_whitespace().find(|t| !t.starts_with("--")) {
1232 Some(img) => img,
1233 None => continue,
1234 };
1235 if image.eq_ignore_ascii_case("scratch") { continue; }
1236 if !image.contains('/') { continue; }
1240 let first = image.split('@').next().unwrap_or(image)
1241 .split('/').next().unwrap_or("");
1242 if first.contains('.') || first.contains(':') || first == "localhost" {
1243 if !TRUSTED.iter().any(|t| first.eq_ignore_ascii_case(t)) {
1244 findings.push(Finding {
1245 rule: "DF065",
1246 severity: Severity::Warning,
1247 line: i.line,
1248 message: format!("FROM pulls from unrecognised registry '{}'", first),
1249 roast: format!(
1250 "Pulling base images from '{}' — a registry you don't hear about at \
1251 KubeCon. Supply-chain attacks love Dockerfiles that blindly trust \
1252 random registries. Verify this is intentional and pin to a digest.",
1253 first
1254 ),
1255 });
1256 }
1257 }
1258 }
1259 findings
1260}
1261
1262fn rule_pipefail_missing(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1263 instrs_of(instrs, "RUN")
1264 .into_iter()
1265 .filter(|i| {
1266 let a = &i.arguments;
1267 a.contains(" | ")
1269 && !a.contains("pipefail")
1270 && !a.contains("set -o pipefail")
1271 && !a.contains("set -eo pipefail")
1272 && !a.contains("set -euo pipefail")
1273 && !a.trim_start().starts_with("set ")
1275 })
1276 .map(|i| Finding {
1277 rule: "DF057",
1278 severity: Severity::Warning,
1279 line: i.line,
1280 message: "RUN with pipe but no pipefail — failed commands in the pipe are silently ignored".to_string(),
1281 roast: "A pipe in RUN without `set -o pipefail`. If the left side of that pipe fails, \
1282 bash shrugs and moves on. The exit code is whatever the last command returns. \
1283 Add `set -o pipefail` at the start of the RUN.".to_string(),
1284 })
1285 .collect()
1286}
1287
1288fn rule_wget_and_curl(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1289 let uses_wget = instrs_of(instrs, "RUN").iter().any(|i| i.arguments.contains("wget "));
1290 let uses_curl = instrs_of(instrs, "RUN").iter().any(|i| i.arguments.contains("curl "));
1291 if uses_wget && uses_curl {
1292 return vec![Finding {
1293 rule: "DF058",
1294 severity: Severity::Warning,
1295 line: 0,
1296 message: "Both wget and curl are used — pick one and use it consistently".to_string(),
1297 roast: "You're using both wget and curl in the same Dockerfile. They do the same \
1298 thing. Pick one. Commit to it. Your image doesn't need two download tools \
1299 any more than it needs two fire extinguishers.".to_string(),
1300 }];
1301 }
1302 vec![]
1303}
1304
1305fn rule_yarn_cache_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1306 instrs_of(instrs, "RUN")
1307 .into_iter()
1308 .filter(|i| {
1309 let a = &i.arguments;
1310 (a.contains("yarn install") || a.contains("yarn add"))
1311 && !a.contains("yarn cache clean")
1312 })
1313 .map(|i| Finding {
1314 rule: "DF055",
1315 severity: Severity::Info,
1316 line: i.line,
1317 message: "yarn install without yarn cache clean — yarn cache is left in the image".to_string(),
1318 roast: "yarn install without cleaning the cache. Yarn dutifully stores downloaded \
1319 packages in a cache that you are now shipping to production. \
1320 Add `&& yarn cache clean` after install.".to_string(),
1321 })
1322 .collect()
1323}
1324
1325fn rule_wget_no_progress(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1326 instrs_of(instrs, "RUN")
1327 .into_iter()
1328 .filter(|i| {
1329 let a = &i.arguments;
1330 a.contains("wget ") && !a.contains("--progress") && !a.contains("-q")
1331 && !a.contains("--quiet")
1332 && (a.contains("http://") || a.contains("https://") || a.contains("ftp://"))
1333 })
1334 .map(|i| Finding {
1335 rule: "DF056",
1336 severity: Severity::Info,
1337 line: i.line,
1338 message: "wget without --progress flag produces verbose progress output in build logs".to_string(),
1339 roast: "wget without --progress=dot:giga will spam your build logs with a progress \
1340 bar that looks great locally and fills 50MB of CI log storage. \
1341 Use --progress=dot:giga or -q to stay quiet.".to_string(),
1342 })
1343 .collect()
1344}
1345
1346fn rule_pip_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1347 instrs_of(instrs, "RUN")
1348 .into_iter()
1349 .filter(|i| {
1350 let a = &i.arguments;
1351 (a.contains("pip install") || a.contains("pip3 install"))
1352 && !a.contains("-r ") && !a.contains("--requirement")
1353 && !a.contains("==") && !a.contains(">=") && !a.contains("<=")
1354 && !a.contains("~=") && !a.contains(".txt")
1355 })
1356 .map(|i| Finding {
1357 rule: "DF051",
1358 severity: Severity::Warning,
1359 line: i.line,
1360 message: "pip install without version pinning — use package==version for reproducibility".to_string(),
1361 roast: "pip install with no version pins. Every build pulls 'latest' and \
1362 one day something breaks and you spend three hours bisecting which \
1363 transitive dependency changed. Use package==version.".to_string(),
1364 })
1365 .collect()
1366}
1367
1368fn rule_apk_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1369 instrs_of(instrs, "RUN")
1370 .into_iter()
1371 .filter(|i| {
1372 let a = &i.arguments;
1373 if !a.contains("apk add") { return false; }
1374 let after_add = match a.find("apk add") {
1376 Some(pos) => &a[pos + 7..],
1377 None => return false,
1378 };
1379 after_add.split_whitespace()
1380 .filter(|t| !t.starts_with('-') && !t.is_empty())
1381 .any(|t| !t.contains('=') && !t.contains('>') && !t.contains('<'))
1382 })
1383 .map(|i| Finding {
1384 rule: "DF052",
1385 severity: Severity::Warning,
1386 line: i.line,
1387 message: "apk add without version pinning — use package=version for reproducibility".to_string(),
1388 roast: "apk add with no version? You chose Alpine to be minimal and fast, then \
1389 immediately added unpinned packages. Your builds are non-deterministic \
1390 by design now. Use package=version.".to_string(),
1391 })
1392 .collect()
1393}
1394
1395fn rule_gem_version_pinning(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1396 instrs_of(instrs, "RUN")
1397 .into_iter()
1398 .filter(|i| {
1399 let a = &i.arguments;
1400 a.contains("gem install")
1401 && !a.contains(" -v ") && !a.contains("--version")
1402 && !a.contains(':')
1403 })
1404 .map(|i| Finding {
1405 rule: "DF053",
1406 severity: Severity::Warning,
1407 line: i.line,
1408 message: "gem install without version pinning — use gem install <gem>:<version>".to_string(),
1409 roast: "gem install with no version. RubyGems will grab whatever's latest today. \
1410 Next week it grabs something else. Your builds are a dice roll. \
1411 Use gem install name:version.".to_string(),
1412 })
1413 .collect()
1414}
1415
1416fn rule_go_install_version(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1417 instrs_of(instrs, "RUN")
1418 .into_iter()
1419 .filter(|i| {
1420 let a = &i.arguments;
1421 a.contains("go install") && !a.contains("@latest") && !a.contains('@')
1422 })
1423 .map(|i| Finding {
1424 rule: "DF054",
1425 severity: Severity::Warning,
1426 line: i.line,
1427 message: "go install without @version — use go install package@version".to_string(),
1428 roast: "go install without @version. The Go toolchain requires a version suffix \
1429 in module-aware mode. Use `go install pkg@v1.2.3` or at minimum `@latest` \
1430 if you enjoy living dangerously.".to_string(),
1431 })
1432 .collect()
1433}
1434
1435fn rule_copy_multi_arg_slash(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1436 instrs_of(instrs, "COPY")
1437 .into_iter()
1438 .filter(|i| {
1439 let args: Vec<&str> = i.arguments.split_whitespace()
1440 .filter(|t| !t.starts_with("--"))
1441 .collect();
1442 if args.len() > 2 {
1443 let dest = args.last().unwrap_or(&"");
1444 !dest.ends_with('/')
1445 } else {
1446 false
1447 }
1448 })
1449 .map(|i| Finding {
1450 rule: "DF048",
1451 severity: Severity::Error,
1452 line: i.line,
1453 message: "COPY with multiple sources requires the destination to end with /".to_string(),
1454 roast: "COPY with multiple sources and a destination that doesn't end with /? \
1455 Docker will complain. Or worse, silently do something weird. \
1456 Add a trailing slash to the destination.".to_string(),
1457 })
1458 .collect()
1459}
1460
1461fn rule_copy_from_undefined_stage(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1462 let mut defined_aliases: Vec<String> = Vec::new();
1463 let mut findings = Vec::new();
1464 let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
1465 for i in instrs {
1466 if i.instruction == "FROM" {
1467 if let Some(alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias) {
1468 defined_aliases.push(alias.to_lowercase());
1469 }
1470 } else if i.instruction == "COPY" {
1471 if let Some(cap) = re_from.captures(&i.arguments) {
1472 let from_ref = cap[1].to_lowercase();
1473 if from_ref.parse::<usize>().is_ok() { continue; }
1475 if !defined_aliases.contains(&from_ref) {
1476 findings.push(Finding {
1477 rule: "DF049",
1478 severity: Severity::Warning,
1479 line: i.line,
1480 message: format!(
1481 "COPY --from={} references an undefined build stage",
1482 &cap[1]
1483 ),
1484 roast: format!(
1485 "COPY --from={} and there's no FROM ... AS {} anywhere above. \
1486 Copying from thin air. Docker will reject this.",
1487 &cap[1], &cap[1]
1488 ),
1489 });
1490 }
1491 }
1492 }
1493 }
1494 findings
1495}
1496
1497fn rule_copy_from_self(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1498 let re_from = Regex::new(r"(?i)--from=(\S+)").unwrap();
1499 let mut current_alias: Option<String> = None;
1500 let mut findings = Vec::new();
1501 for i in instrs {
1502 if i.instruction == "FROM" {
1503 current_alias = parse_from_arguments(&i.arguments)
1504 .and_then(|from| from.alias)
1505 .map(str::to_lowercase);
1506 } else if i.instruction == "COPY" {
1507 if let Some(cap) = re_from.captures(&i.arguments) {
1508 let from_ref = cap[1].to_lowercase();
1509 if let Some(ref alias) = current_alias {
1510 if &from_ref == alias {
1511 findings.push(Finding {
1512 rule: "DF050",
1513 severity: Severity::Error,
1514 line: i.line,
1515 message: format!(
1516 "COPY --from={} references the current build stage — circular dependency",
1517 &cap[1]
1518 ),
1519 roast: format!(
1520 "COPY --from={} inside the same stage named {}. \
1521 That's a circular reference. Docker cannot copy from itself. \
1522 This will fail at build time.",
1523 &cap[1], &cap[1]
1524 ),
1525 });
1526 }
1527 }
1528 }
1529 }
1530 }
1531 findings
1532}
1533
1534fn rule_dnf_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1535 instrs_of(instrs, "RUN")
1536 .into_iter()
1537 .filter(|i| {
1538 let a = &i.arguments;
1539 a.contains("dnf install") && !a.contains("dnf clean all") && !a.contains("dnf clean")
1540 })
1541 .map(|i| Finding {
1542 rule: "DF046",
1543 severity: Severity::Warning,
1544 line: i.line,
1545 message: "dnf clean all missing after dnf install — RPM cache bloats the image".to_string(),
1546 roast: "dnf install without `dnf clean all` afterwards? You're shipping RPM cache \
1547 metadata to production. That's not a feature. Add `&& dnf clean all`.".to_string(),
1548 })
1549 .collect()
1550}
1551
1552fn rule_yum_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1553 instrs_of(instrs, "RUN")
1554 .into_iter()
1555 .filter(|i| {
1556 let a = &i.arguments;
1557 a.contains("yum install") && !a.contains("yum clean all") && !a.contains("yum clean")
1558 })
1559 .map(|i| Finding {
1560 rule: "DF047",
1561 severity: Severity::Warning,
1562 line: i.line,
1563 message: "yum clean all missing after yum install — cache stays in the image".to_string(),
1564 roast: "yum install without cleanup is just permanently housing the package cache in \
1565 your image. Every MB of yum cache is a MB of shame in your registry.".to_string(),
1566 })
1567 .collect()
1568}
1569
1570fn rule_zypper_no_y(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1571 instrs_of(instrs, "RUN")
1572 .into_iter()
1573 .filter(|i| {
1574 let a = &i.arguments;
1575 (a.contains("zypper install") || a.contains("zypper in "))
1576 && !a.contains("-y") && !a.contains("--non-interactive") && !a.contains(" -n ")
1577 && !a.contains(" -n\n") && !a.starts_with("-n ")
1578 })
1579 .map(|i| Finding {
1580 rule: "DF043",
1581 severity: Severity::Warning,
1582 line: i.line,
1583 message: "zypper install without non-interactive flag (-y) will hang in a build".to_string(),
1584 roast: "zypper install without -y in a container build? It'll wait for input that \
1585 will never arrive, like a chatbot asking for emotional validation.".to_string(),
1586 })
1587 .collect()
1588}
1589
1590fn rule_zypper_dist_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1591 instrs_of(instrs, "RUN")
1592 .into_iter()
1593 .filter(|i| i.arguments.contains("zypper dist-upgrade") || i.arguments.contains("zypper dup"))
1594 .map(|i| Finding {
1595 rule: "DF044",
1596 severity: Severity::Warning,
1597 line: i.line,
1598 message: "zypper dist-upgrade upgrades all packages unpredictably — avoid in Dockerfiles".to_string(),
1599 roast: "zypper dist-upgrade: the 'nuke everything and hope for the best' approach to \
1600 package management. Your image will be different every single build. Congrats.".to_string(),
1601 })
1602 .collect()
1603}
1604
1605fn rule_zypper_clean(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1606 instrs_of(instrs, "RUN")
1607 .into_iter()
1608 .filter(|i| {
1609 let a = &i.arguments;
1610 (a.contains("zypper install") || a.contains("zypper in "))
1611 && !a.contains("zypper clean") && !a.contains("zypper cc")
1612 })
1613 .map(|i| Finding {
1614 rule: "DF045",
1615 severity: Severity::Info,
1616 line: i.line,
1617 message: "zypper cache not cleaned after install — adds unnecessary image bloat".to_string(),
1618 roast: "zypper install without `zypper clean --all` afterwards. You're hoarding package \
1619 metadata in your image. Clean it up.".to_string(),
1620 })
1621 .collect()
1622}
1623
1624fn rule_expose_port_range(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1625 let mut findings = Vec::new();
1626 for i in instrs_of(instrs, "EXPOSE") {
1627 for port_spec in i.arguments.split_whitespace() {
1628 let port_str = port_spec.split('/').next().unwrap_or(port_spec);
1629 if let Ok(port) = port_str.parse::<u32>() {
1630 if port > 65535 {
1631 findings.push(Finding {
1632 rule: "DF040",
1633 severity: Severity::Error,
1634 line: i.line,
1635 message: format!("EXPOSE port {} is out of valid range (0-65535)", port),
1636 roast: format!(
1637 "Port {}? That's not a port, that's a zip code. \
1638 Valid UNIX ports are 0-65535. Pick a real one.",
1639 port
1640 ),
1641 });
1642 }
1643 }
1644 }
1645 }
1646 findings
1647}
1648
1649fn rule_multiple_healthcheck(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1650 let checks: Vec<_> = instrs_of(instrs, "HEALTHCHECK");
1651 if checks.len() <= 1 { return vec![]; }
1652 checks[1..].iter().map(|i| Finding {
1653 rule: "DF041",
1654 severity: Severity::Error,
1655 line: i.line,
1656 message: "Multiple HEALTHCHECK instructions — only the last one applies".to_string(),
1657 roast: "Multiple HEALTHCHECKs but only the last one counts. The earlier ones are \
1658 haunting your image for no reason. One health check, one truth.".to_string(),
1659 }).collect()
1660}
1661
1662fn rule_unique_stage_aliases(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1663 let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1664 let mut findings = Vec::new();
1665 for i in instrs_of(instrs, "FROM") {
1666 if let Some(original_alias) = parse_from_arguments(&i.arguments).and_then(|from| from.alias) {
1667 let alias = original_alias.to_lowercase();
1668 if let Some(&prev_line) = seen.get(&alias) {
1669 findings.push(Finding {
1670 rule: "DF042",
1671 severity: Severity::Error,
1672 line: i.line,
1673 message: format!(
1674 "FROM alias '{}' is already defined on line {}",
1675 original_alias, prev_line
1676 ),
1677 roast: format!(
1678 "Two stages named '{}'. Docker uses the last one; the first is dead code. \
1679 Give your stages unique names.",
1680 original_alias
1681 ),
1682 });
1683 } else {
1684 seen.insert(alias, i.line);
1685 }
1686 }
1687 }
1688 findings
1689}
1690
1691fn rule_invalid_instruction_order(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1692 if instrs.is_empty() { return vec![]; }
1693 let first = &instrs[0];
1694 if first.instruction != "FROM" && first.instruction != "ARG" {
1695 return vec![Finding {
1696 rule: "DF037",
1697 severity: Severity::Error,
1698 line: first.line,
1699 message: format!(
1700 "'{}' before FROM — Dockerfile must begin with FROM, ARG, or a comment",
1701 first.instruction
1702 ),
1703 roast: "Your Dockerfile doesn't start with FROM. That's like starting a recipe with \
1704 'season to taste' before listing any ingredients. Docker is confused. So am I.".to_string(),
1705 }];
1706 }
1707 vec![]
1708}
1709
1710fn rule_multiple_cmd(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1711 let cmds: Vec<_> = instrs_of(instrs, "CMD");
1712 if cmds.len() <= 1 { return vec![]; }
1713 cmds[1..].iter().map(|i| Finding {
1714 rule: "DF038",
1715 severity: Severity::Warning,
1716 line: i.line,
1717 message: "Multiple CMD instructions — only the last one takes effect".to_string(),
1718 roast: "Multiple CMDs and only the last one counts. The others are ghosts haunting your \
1719 Dockerfile, contributing nothing except confusion. Pick one.".to_string(),
1720 }).collect()
1721}
1722
1723fn rule_multiple_entrypoint(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1724 let eps: Vec<_> = instrs_of(instrs, "ENTRYPOINT");
1725 if eps.len() <= 1 { return vec![]; }
1726 eps[1..].iter().map(|i| Finding {
1727 rule: "DF039",
1728 severity: Severity::Error,
1729 line: i.line,
1730 message: "Multiple ENTRYPOINT instructions — only the last one takes effect".to_string(),
1731 roast: "Two ENTRYPOINTs. Bold. Only the last one runs; the first is just expensive \
1732 furniture. Delete it.".to_string(),
1733 }).collect()
1734}
1735
1736fn rule_no_user_instruction(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1737 if has_instr(instrs, "USER") { return vec![]; }
1738 if !has_instr(instrs, "CMD") && !has_instr(instrs, "ENTRYPOINT") { return vec![]; }
1739 vec![Finding {
1740 rule: "DF020",
1741 severity: Severity::Warning,
1742 line: 0,
1743 message: "No USER instruction found — container will run as root by default".to_string(),
1744 roast: "No USER set? Bold strategy. Running everything as root in prod is a great way \
1745 to ensure job security — for your incident response team.".to_string(),
1746 }]
1747}
1748
1749fn rule_apt_upgrade(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1750 let re = Regex::new(r"\bapt(-get)?\s+(dist-upgrade|upgrade)\b").unwrap();
1751 instrs_of(instrs, "RUN")
1752 .into_iter()
1753 .filter(|i| re.is_match(&i.arguments))
1754 .map(|i| Finding {
1755 rule: "DF069",
1756 severity: Severity::Warning,
1757 line: i.line,
1758 message: "apt-get upgrade/dist-upgrade makes builds non-reproducible".to_string(),
1759 roast: "apt-get upgrade: 'let's upgrade everything and see what breaks in six months'. \
1760 Your image will be different every time you build it. \
1761 Pin the packages you actually need instead of upgrading everything blindly.".to_string(),
1762 })
1763 .collect()
1764}
1765
1766fn rule_copy_before_install(instrs: &[Instruction], _raw: &str) -> Vec<Finding> {
1767 const PKG_CMDS: &[&str] = &[
1768 "npm install", "npm ci", "pip install", "pip3 install",
1769 "yarn install", "yarn add", "bundle install", "composer install",
1770 "pnpm install", "bun install",
1771 ];
1772 let mut findings = Vec::new();
1773 let mut broad_copy_line: Option<usize> = None;
1774
1775 for i in instrs {
1776 match i.instruction.as_str() {
1777 "FROM" => {
1778 broad_copy_line = None;
1779 }
1780 "COPY" => {
1781 let tokens: Vec<&str> = i.arguments
1782 .split_whitespace()
1783 .filter(|t| !t.starts_with("--"))
1784 .collect();
1785 if tokens.len() >= 2 && (tokens[0] == "." || tokens[0].ends_with("/.")) {
1786 broad_copy_line = Some(i.line);
1787 }
1788 }
1789 "RUN" => {
1790 if let Some(copy_line) = broad_copy_line {
1791 if PKG_CMDS.iter().any(|cmd| i.arguments.contains(cmd)) {
1792 findings.push(Finding {
1793 rule: "DF070",
1794 severity: Severity::Warning,
1795 line: copy_line,
1796 message: "COPY . before package install — invalidates Docker layer cache on every source change".to_string(),
1797 roast: "COPY . . before npm/pip install means every code change rebuilds \
1798 dependencies from scratch. Copy just the manifest first \
1799 (e.g. COPY package.json ./), run the install, then COPY . . — \
1800 now the install layer is cached between source changes.".to_string(),
1801 });
1802 broad_copy_line = None;
1803 }
1804 }
1805 }
1806 _ => {}
1807 }
1808 }
1809 findings
1810}