|
54 | 54 | (re.compile(r"\bghp_[A-Za-z0-9]{36}\b"), "a GitHub personal access token"), |
55 | 55 | (re.compile(r"\bgho_[A-Za-z0-9]{36}\b"), "a GitHub OAuth token"), |
56 | 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"), |
| 57 | + (re.compile(r"(?i)aws_secret_access_key(?:\s*[=:]\s*|\s+)[\"']?[A-Za-z0-9/+]{40}\b"), "an AWS secret access key"), |
58 | 58 | (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "a Slack token"), |
59 | 59 | ) |
60 | 60 |
|
|
82 | 82 | "sets world-writable permissions (`chmod 777`) — scope permissions more tightly"), |
83 | 83 | (re.compile(r"(?i)(?:--insecure|--no-check-certificate)\b"), True, ("RUN",), |
84 | 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, |
| 85 | + (re.compile(r"(?i)(?:password|passwd|secret|token|api[_-]?key)(?:\s*[=:]\s*|\s+)[\"']?\S{6,}"), |
| 86 | + False, ("ENV", "ARG", "RUN"), |
86 | 87 | "may embed a credential/secret — verify that no real secret is committed"), |
87 | 88 | ) |
88 | 89 |
|
@@ -309,45 +310,72 @@ def scan_dockerfile_risks(dockerfile_path): |
309 | 310 | with open(dockerfile_path, errors="replace") as fh: |
310 | 311 | raw_lines = fh.read().splitlines() |
311 | 312 |
|
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 |
314 | 317 | # (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 |
327 | 326 | 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() |
334 | 350 | for start, instr, text in logical: |
335 | 351 | snippet = text.splitlines()[0].strip()[:160] |
| 352 | + has_secret = False |
336 | 353 | for rx, what in SECRET_RULES: |
337 | 354 | if rx.search(text): |
| 355 | + has_secret = True |
338 | 356 | secrets.append({"line": start, "msg": "line %d appears to contain %s" % (start, what)}) |
339 | 357 | for rx, show, scope, msg in REVIEW_RULES: |
340 | 358 | if scope is not None and instr not in scope: |
341 | 359 | continue |
342 | 360 | 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()) |
348 | 374 | checklist.append({"line": start, "snippet": snippet, |
349 | 375 | "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()) |
351 | 379 | return secrets, checklist |
352 | 380 |
|
353 | 381 |
|
|
0 commit comments