133 lines
4.6 KiB
Python
Executable File
133 lines
4.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Lint changed GitHub Actions workflows for the two highest-signal CI attacks.
|
|
|
|
Called by .github/workflows/security-gate.yml. Dependency-free (stdlib +
|
|
regex line scanning, no PyYAML) so it never needs a network install to run --
|
|
a security check should not depend on fetching anything.
|
|
|
|
Checks, per changed `.github/workflows/*.yml`:
|
|
|
|
1. pull_request_target + PR-head checkout (CRITICAL). The classic OSS
|
|
supply-chain RCE: a `pull_request_target` workflow runs from the base with
|
|
secrets, and if it also checks out / runs the PR head it executes
|
|
attacker code with secrets in scope. We flag any checkout that pulls a
|
|
PR-head ref (github.event.pull_request.head.*, github.head_ref,
|
|
refs/pull/...). A `# leak-scan-allow: pull_request_target` line (the
|
|
repo's existing convention for hand-audited exceptions) downgrades it to
|
|
a warning -- safe here because untrusted authors are independently blocked
|
|
from editing workflows by sensitive-paths.sh.
|
|
|
|
2. Unpinned action references (HIGH). `uses: owner/repo@v4` / `@main` lets the
|
|
action's owner change what runs under our token later. Require a 40-hex
|
|
commit SHA. Local (`./`) and `docker://...@sha256:` refs are exempt.
|
|
|
|
Env in: CHANGED_FILES (path to a file with one changed path per line).
|
|
Exit: non-zero if any CRITICAL/HIGH finding; 0 otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
USES_RE = re.compile(r"""^\s*-?\s*uses:\s*['"]?([^'"\s#]+)['"]?""")
|
|
# PR-head refs that must never be checked out under pull_request_target.
|
|
HEAD_REF_RE = re.compile(
|
|
r"github\.event\.pull_request\.head\.(sha|ref)"
|
|
r"|github\.head_ref"
|
|
r"|refs/pull/",
|
|
)
|
|
|
|
|
|
def is_pinned(ref: str) -> bool:
|
|
if ref.startswith(("./", "../")):
|
|
return True # local action, ships with the repo
|
|
if ref.startswith("docker://"):
|
|
return "@sha256:" in ref # digest-pinned image
|
|
_, _, version = ref.partition("@")
|
|
return bool(SHA_RE.match(version))
|
|
|
|
|
|
def lint_file(path: str) -> tuple[list[str], list[str]]:
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
except OSError as e:
|
|
warnings.append(f"::warning file={path}::could not read workflow ({e})")
|
|
return errors, warnings
|
|
|
|
lines = text.splitlines()
|
|
allow_prt = "leak-scan-allow: pull_request_target" in text
|
|
has_prt = re.search(r"^\s*pull_request_target\s*:", text, re.MULTILINE) is not None
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
|
|
# 1. PR-head checkout under pull_request_target.
|
|
if has_prt and HEAD_REF_RE.search(line):
|
|
msg = (
|
|
f"file={path},line={i}::pull_request_target workflow references a "
|
|
"PR-head ref -- this runs untrusted PR code with secrets. "
|
|
"Check out 'main' only, or read the PR via the API."
|
|
)
|
|
(warnings if allow_prt else errors).append(
|
|
("::warning " if allow_prt else "::error ") + msg
|
|
)
|
|
|
|
# 2. Unpinned action reference.
|
|
m = USES_RE.match(line)
|
|
if m:
|
|
ref = m.group(1)
|
|
if "@" in ref and not is_pinned(ref):
|
|
errors.append(
|
|
f"::error file={path},line={i}::action '{ref}' is not pinned to a "
|
|
"full commit SHA; a tag/branch ref can be moved to hostile code."
|
|
)
|
|
|
|
return errors, warnings
|
|
|
|
|
|
def main() -> int:
|
|
changed = os.environ.get("CHANGED_FILES")
|
|
if not changed or not os.path.isfile(changed):
|
|
print(f"::error::changed-files list {changed!r} missing")
|
|
return 1
|
|
|
|
with open(changed, encoding="utf-8") as fh:
|
|
paths = [p.strip() for p in fh if p.strip()]
|
|
|
|
targets = [
|
|
p
|
|
for p in paths
|
|
if p.startswith(".github/workflows/")
|
|
and p.endswith((".yml", ".yaml"))
|
|
and os.path.isfile(p)
|
|
]
|
|
if not targets:
|
|
print("No changed workflow files to lint.")
|
|
return 0
|
|
|
|
all_errors: list[str] = []
|
|
for path in targets:
|
|
errors, warnings = lint_file(path)
|
|
for w in warnings:
|
|
print(w)
|
|
for e in errors:
|
|
print(e)
|
|
all_errors.extend(errors)
|
|
|
|
if all_errors:
|
|
print(f"::error::Workflow misuse linter failed with {len(all_errors)} finding(s).")
|
|
return 1
|
|
print(f"Workflow misuse linter passed ({len(targets)} file(s) checked).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|