Skip to content

Commit 890660b

Browse files
committed
feat(rules): +5 rules — relative WORKDIR, chmod 777, missing HEALTHCHECK, apt -y, pip --no-cache-dir
Adds PR008–PR012 with 10 new tests (25/25 pass total). README rules table updated.
1 parent 9db5c6b commit 890660b

File tree

3 files changed

+151
-0
lines changed

3 files changed

+151
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ hygiene on every change.
6262
| PR005 | low/med | `ADD` used where `COPY` would do (and `ADD <url>` for fetches) |
6363
| PR006 | critical | Hard-coded password / API key / token in `ENV`/`ARG`/`RUN` |
6464
| PR007 | medium | `sudo` inside a `RUN` — the builder is already root |
65+
| PR008 | low | Relative `WORKDIR` — depends on previous WORKDIR |
66+
| PR009 | high | `chmod 777` / `chmod a+rwx` in a `RUN` |
67+
| PR010 | info | Image declares `EXPOSE`/`CMD`/`ENTRYPOINT` but no `HEALTHCHECK` |
68+
| PR011 | medium | `apt-get install` without `-y` will block the build |
69+
| PR012 | low | `pip install` without `--no-cache-dir` bloats the layer |
6570

6671
Every rule ships with a concrete fix in the `hint:` line.
6772

src/prithvi/core.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,81 @@ def _rule_privileged_sudo(instrs: List[Instruction]) -> Iterable[Finding]:
221221
)
222222

223223

224+
def _rule_relative_workdir(instrs: List[Instruction]) -> Iterable[Finding]:
225+
for ins in instrs:
226+
if ins.cmd != "WORKDIR":
227+
continue
228+
path = ins.args.strip().strip('"').strip("'")
229+
if path and not path.startswith("/") and not path.startswith("$"):
230+
yield Finding(
231+
rule_id="PR008",
232+
severity=Severity.LOW,
233+
line=ins.line,
234+
message=f"WORKDIR {path!r} is relative — depends on the previous WORKDIR",
235+
hint="Use an absolute path so layer order can't change behaviour.",
236+
)
237+
238+
239+
def _rule_chmod_777(instrs: List[Instruction]) -> Iterable[Finding]:
240+
pat = re.compile(r"\bchmod\s+(?:-[Rr]\s+)?(?:0?777|a\+rwx)\b")
241+
for ins in instrs:
242+
if ins.cmd != "RUN":
243+
continue
244+
if pat.search(ins.args):
245+
yield Finding(
246+
rule_id="PR009",
247+
severity=Severity.HIGH,
248+
line=ins.line,
249+
message="`chmod 777` (or `a+rwx`) gives world-write permission",
250+
hint="Pick a tighter mode (e.g. 750 for dirs, 640 for files) and chown to the runtime user.",
251+
)
252+
253+
254+
def _rule_missing_healthcheck(instrs: List[Instruction]) -> Iterable[Finding]:
255+
if not any(i.cmd in ("EXPOSE", "CMD", "ENTRYPOINT") for i in instrs):
256+
return
257+
if any(i.cmd == "HEALTHCHECK" for i in instrs):
258+
return
259+
last = instrs[-1] if instrs else None
260+
yield Finding(
261+
rule_id="PR010",
262+
severity=Severity.INFO,
263+
line=last.line if last else 1,
264+
message="Image declares EXPOSE/CMD/ENTRYPOINT but no HEALTHCHECK",
265+
hint="Add `HEALTHCHECK CMD curl -fsS http://localhost:PORT/health || exit 1` so orchestrators can detect zombies.",
266+
)
267+
268+
269+
def _rule_apt_get_install_no_y(instrs: List[Instruction]) -> Iterable[Finding]:
270+
for ins in instrs:
271+
if ins.cmd != "RUN":
272+
continue
273+
low = ins.args.lower()
274+
if "apt-get install" in low and not re.search(r"-y\b|--yes\b|--assume-yes\b", low):
275+
yield Finding(
276+
rule_id="PR011",
277+
severity=Severity.MEDIUM,
278+
line=ins.line,
279+
message="apt-get install without -y will block the build on the prompt",
280+
hint="Add `-y` (or `--yes`) so non-interactive builds don't hang.",
281+
)
282+
283+
284+
def _rule_pip_no_cache(instrs: List[Instruction]) -> Iterable[Finding]:
285+
for ins in instrs:
286+
if ins.cmd != "RUN":
287+
continue
288+
low = ins.args.lower()
289+
if re.search(r"\bpip(?:3)?\s+install\b", low) and "--no-cache-dir" not in low:
290+
yield Finding(
291+
rule_id="PR012",
292+
severity=Severity.LOW,
293+
line=ins.line,
294+
message="pip install without --no-cache-dir bloats the layer",
295+
hint="Add `--no-cache-dir` so the wheel cache doesn't ship inside the image.",
296+
)
297+
298+
224299
RULES: Tuple[RuleFn, ...] = (
225300
_rule_no_latest_tag,
226301
_rule_runs_as_root,
@@ -229,6 +304,11 @@ def _rule_privileged_sudo(instrs: List[Instruction]) -> Iterable[Finding]:
229304
_rule_add_instead_of_copy,
230305
_rule_hardcoded_secret,
231306
_rule_privileged_sudo,
307+
_rule_relative_workdir,
308+
_rule_chmod_777,
309+
_rule_missing_healthcheck,
310+
_rule_apt_get_install_no_y,
311+
_rule_pip_no_cache,
232312
)
233313

234314

tests/test_scanner.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,69 @@ def test_findings_are_sorted_by_severity_then_line() -> None:
105105
# Critical/High must come before Medium/Low.
106106
severities = [f.severity for f in findings]
107107
assert severities == sorted(severities, key=lambda s: ["critical", "high", "medium", "low", "info"].index(s.value))
108+
109+
110+
# New rules: PR008–PR012
111+
# --------------------------------------------------------------------------
112+
113+
114+
def test_flags_relative_workdir() -> None:
115+
findings = _scan("FROM alpine:3.19\nWORKDIR app\nUSER app")
116+
assert any(f.rule_id == "PR008" for f in findings)
117+
118+
119+
def test_absolute_workdir_is_fine() -> None:
120+
findings = _scan("FROM alpine:3.19\nWORKDIR /app\nUSER app")
121+
assert not any(f.rule_id == "PR008" for f in findings)
122+
123+
124+
def test_flags_chmod_777() -> None:
125+
findings = _scan('FROM alpine:3.19\nRUN chmod -R 777 /opt\nUSER app')
126+
assert any(f.rule_id == "PR009" and f.severity == Severity.HIGH for f in findings)
127+
128+
129+
def test_flags_chmod_a_rwx() -> None:
130+
findings = _scan('FROM alpine:3.19\nRUN chmod a+rwx /opt\nUSER app')
131+
assert any(f.rule_id == "PR009" for f in findings)
132+
133+
134+
def test_missing_healthcheck_when_exposed() -> None:
135+
src = "FROM alpine:3.19\nEXPOSE 8080\nCMD [\"./serve\"]\nUSER app"
136+
findings = _scan(src)
137+
assert any(f.rule_id == "PR010" for f in findings)
138+
139+
140+
def test_no_healthcheck_warning_when_present() -> None:
141+
src = (
142+
"FROM alpine:3.19\n"
143+
"EXPOSE 8080\n"
144+
'HEALTHCHECK CMD wget -qO- http://localhost:8080/health || exit 1\n'
145+
"USER app\n"
146+
"CMD [\"./serve\"]"
147+
)
148+
findings = _scan(src)
149+
assert not any(f.rule_id == "PR010" for f in findings)
150+
151+
152+
def test_flags_apt_install_without_y() -> None:
153+
src = "FROM debian:12\nRUN apt-get update && apt-get install curl && rm -rf /var/lib/apt/lists/*\nUSER app"
154+
findings = _scan(src)
155+
assert any(f.rule_id == "PR011" for f in findings)
156+
157+
158+
def test_apt_install_with_y_is_fine() -> None:
159+
src = "FROM debian:12\nRUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*\nUSER app"
160+
findings = _scan(src)
161+
assert not any(f.rule_id == "PR011" for f in findings)
162+
163+
164+
def test_flags_pip_install_without_no_cache_dir() -> None:
165+
src = "FROM python:3.12\nRUN pip install requests\nUSER app"
166+
findings = _scan(src)
167+
assert any(f.rule_id == "PR012" for f in findings)
168+
169+
170+
def test_pip_install_with_no_cache_dir_is_fine() -> None:
171+
src = "FROM python:3.12\nRUN pip install --no-cache-dir requests\nUSER app"
172+
findings = _scan(src)
173+
assert not any(f.rule_id == "PR012" for f in findings)

0 commit comments

Comments
 (0)