3434# subset of this, so rejecting anything else is free.
3535SAFE_TOKEN_RE = re .compile (r"^[A-Za-z0-9._-]+$" )
3636
37+ # --- Dockerfile risk surface -------------------------------------------------
38+ # A submitted Dockerfile is arbitrary contributor input. These patterns lift the
39+ # lines a human reviewer must look at up into the PR comment, so nothing risky is
40+ # ever merged without an explicit sign-off. Two tiers:
41+ # SECRET_RULES — high-confidence embedded credentials. These BLOCK the PR (the
42+ # secret is already in git history and must be rotated). Kept
43+ # deliberately narrow (structured tokens) so a legitimate PR is
44+ # never blocked by a false positive.
45+ # REVIEW_RULES — "please verify this" heuristics. These NEVER block (plenty of
46+ # legitimate containers do `curl | sh`); they populate an advisory
47+ # reviewer checklist. The middle field is show_snippet: whether it
48+ # is safe to echo the matched line back into the public PR comment
49+ # (False for the credential heuristic, so we never leak a value).
50+ SECRET_RULES = (
51+ (re .compile (r"\bAKIA[0-9A-Z]{16}\b" ), "an AWS access key ID" ),
52+ (re .compile (r"\bASIA[0-9A-Z]{16}\b" ), "an AWS temporary access key ID" ),
53+ (re .compile (r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----" ), "a private key" ),
54+ (re .compile (r"\bghp_[A-Za-z0-9]{36}\b" ), "a GitHub personal access token" ),
55+ (re .compile (r"\bgho_[A-Za-z0-9]{36}\b" ), "a GitHub OAuth token" ),
56+ (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" ),
58+ (re .compile (r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b" ), "a Slack token" ),
59+ )
60+
61+ # Dockerfile instructions that actually fetch or execute content; the download
62+ # heuristics only fire inside these, so an http:// homepage in a LABEL is never
63+ # mistaken for an insecure download. Each REVIEW rule is
64+ # (regex, show_snippet, scope, message); scope=None means "any instruction".
65+ FETCH_INSTR = ("RUN" , "ADD" , "COPY" )
66+
67+ REVIEW_RULES = (
68+ (re .compile (r"(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b" ), True , ("RUN" ,),
69+ "runs a remote script straight through a shell (`curl … | sh`) — confirm you trust the "
70+ "source; prefer a pinned download + checksum" ),
71+ (re .compile (r"(?i)\bADD\s+https?://" ), True , ("ADD" ,),
72+ "uses `ADD <url>` (no checksum, bypasses the layer cache) — prefer `curl`/`wget` with a "
73+ "verified checksum, or `COPY`" ),
74+ (re .compile (r"http://[^\s\"']+" ), True , FETCH_INSTR ,
75+ "downloads over insecure `http://` — use `https://`" ),
76+ (re .compile (r"(?i)https?://(?:pastebin\.com|[a-z0-9.-]*gist\.github|bit\.ly|tinyurl\.com|t\.co)/" ),
77+ True , FETCH_INSTR ,
78+ "fetches from a paste site or URL shortener — confirm what is actually being downloaded" ),
79+ (re .compile (r"https?://\d{1,3}(?:\.\d{1,3}){3}" ), True , FETCH_INSTR ,
80+ "downloads from a bare IP address — confirm this endpoint is trusted" ),
81+ (re .compile (r"(?i)chmod\s+(?:-R\s+)?0?777\b" ), True , ("RUN" ,),
82+ "sets world-writable permissions (`chmod 777`) — scope permissions more tightly" ),
83+ (re .compile (r"(?i)(?:--insecure|--no-check-certificate)\b" ), True , ("RUN" ,),
84+ "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 ,
86+ "may embed a credential/secret — verify that no real secret is committed" ),
87+ )
88+
89+ # Approved base-image prefixes; any other FROM is surfaced for a look (advisory only).
90+ APPROVED_BASE_RE = re .compile (r"(?i)^(?:docker\.io/)?(?:quay\.io/)?biocontainers/" )
91+
3792
3893def _safe (value ):
3994 return bool (value ) and SAFE_TOKEN_RE .match (value ) is not None
@@ -239,16 +294,87 @@ def check_labels(container, version, labels, dockerfile):
239294 return ok , (software or container ), tag , errors , warnings
240295
241296
297+ def scan_dockerfile_risks (dockerfile_path ):
298+ """Scan a submitted Dockerfile for content a reviewer must see.
299+
300+ Returns (secrets, checklist):
301+ secrets — [{line, msg}] high-confidence embedded credentials (blocking)
302+ checklist — [{line, snippet, msg}] advisory 'please verify' items (never blocking;
303+ snippet is '' when it is not safe to echo the line into a public comment).
304+ Comment/blank lines are ignored; only the first FROM is policy-checked.
305+ """
306+ secrets , checklist = [], []
307+ if not (dockerfile_path and os .path .exists (dockerfile_path )):
308+ return secrets , checklist
309+ with open (dockerfile_path , errors = "replace" ) as fh :
310+ raw_lines = fh .read ().splitlines ()
311+
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
314+ # (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 ("\\ " ):
327+ 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
334+ for start , instr , text in logical :
335+ snippet = text .splitlines ()[0 ].strip ()[:160 ]
336+ for rx , what in SECRET_RULES :
337+ if rx .search (text ):
338+ secrets .append ({"line" : start , "msg" : "line %d appears to contain %s" % (start , what )})
339+ for rx , show , scope , msg in REVIEW_RULES :
340+ if scope is not None and instr not in scope :
341+ continue
342+ 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 )):
348+ checklist .append ({"line" : start , "snippet" : snippet ,
349+ "msg" : "base image `%s` is not an official `biocontainers/*` image "
350+ "— confirm it is an approved base" % m .group (1 )[:80 ]})
351+ return secrets , checklist
352+
353+
354+ def _fmt_checklist (item ):
355+ """Render one advisory checklist item as a single sanitizable string for the report."""
356+ if item .get ("snippet" ):
357+ return "%s _(line %d: `%s`)_" % (item ["msg" ], item ["line" ], item ["snippet" ])
358+ return "%s _(line %d)_" % (item ["msg" ], item ["line" ])
359+
360+
242361def cmd_detect (args ):
243362 with open (args .changed_files ) as fh :
244363 changed = fh .read ().splitlines ()
245364 container , version , errors = detect_container (changed , args .workdir )
365+ checklist = []
366+ if container and version and not errors :
367+ dockerfile = os .path .join (args .workdir , container , version , "Dockerfile" )
368+ secrets , checklist = scan_dockerfile_risks (dockerfile )
369+ for s in secrets :
370+ errors .append (s ["msg" ] + " — remove it and rotate the credential before resubmitting." )
246371 report = {
247372 "container" : container ,
248373 "version" : version ,
249374 "ok" : not errors ,
250375 "errors" : errors ,
251376 "warnings" : [],
377+ "review_checklist" : [_fmt_checklist (c ) for c in checklist ],
252378 "pr_number" : os .environ .get ("PR_NUMBER" ) or None ,
253379 "head_sha" : os .environ .get ("HEAD_SHA" ) or None ,
254380 "software" : container ,
@@ -272,6 +398,11 @@ def cmd_check(args):
272398 labels = json .loads (raw ) if raw and raw != "null" else {}
273399 ok , software , tag , errors , warnings = check_labels (
274400 args .container , args .version , labels , args .dockerfile )
401+ secrets , checklist = scan_dockerfile_risks (args .dockerfile )
402+ for s in secrets :
403+ errors .append (s ["msg" ] + " — remove it and rotate the credential before resubmitting." )
404+ if secrets :
405+ ok = False
275406 report = {
276407 "container" : args .container ,
277408 "version" : args .version ,
@@ -280,6 +411,7 @@ def cmd_check(args):
280411 "ok" : ok ,
281412 "errors" : errors ,
282413 "warnings" : warnings ,
414+ "review_checklist" : [_fmt_checklist (c ) for c in checklist ],
283415 "pr_number" : os .environ .get ("PR_NUMBER" ) or None ,
284416 "head_sha" : os .environ .get ("HEAD_SHA" ) or None ,
285417 }
0 commit comments