diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index a1e7b2efa6..8cfa78c743 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -1096,20 +1096,27 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - - name: Extract base-ref lockfile (PR triggers only) + - name: Extract base-ref lockfile and install-script allowlist (PR triggers only) if: github.event_name == 'pull_request' run: | set -e BASE_SHA="${{ github.event.pull_request.base.sha }}" git show "$BASE_SHA:studio/frontend/package-lock.json" \ > /tmp/base-package-lock.json + # Pull the TRUSTED allowlist from the base ref so a PR cannot + # allowlist its own new postinstall dependency in the same diff + # the checker scans. Missing file is OK (empty allowlist). + git show "$BASE_SHA:studio/frontend/.install-script-allowlist" \ + > /tmp/base-install-script-allowlist 2>/dev/null \ + || : > /tmp/base-install-script-allowlist - name: Diff for newly-added install-script deps if: github.event_name == 'pull_request' run: | python3 scripts/check_new_install_scripts.py \ --base /tmp/base-package-lock.json \ - --head studio/frontend/package-lock.json + --head studio/frontend/package-lock.json \ + --base-allowlist /tmp/base-install-script-allowlist - name: Skip install-script diff (non-PR trigger) if: github.event_name != 'pull_request' diff --git a/scripts/check_new_install_scripts.py b/scripts/check_new_install_scripts.py index e5a6782866..3ce83b6827 100644 --- a/scripts/check_new_install_scripts.py +++ b/scripts/check_new_install_scripts.py @@ -236,29 +236,41 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]: def _load_allowlist(path: Path) -> set[str]: - """Read a file of newline-separated package names to skip. + """Read a file of newline-separated ``name@version`` entries to skip. - Purely opt-in: an entry on its own line whitelists every version - of that package against the new-install-script gate. Lines - starting with ``#`` are comments; blank lines are ignored. The - intent is to triage well-known, eyeballed dev-only deps (vitest's - esbuild, sharp's libvips, etc.) without weakening the gate for - the long tail. Missing or unreadable file means empty allowlist. + Each entry MUST be pinned to an exact version (``esbuild@0.21.5``, + ``@scope/pkg@1.2.3``). Bare names are rejected so allowlisting + ``esbuild`` cannot silently approve a later malicious + ``esbuild@99.0.0`` published by a compromised maintainer -- every + new version requires its own review. Lines starting with ``#`` are + comments; blank lines are ignored. Missing file = empty allowlist. """ if not path.exists(): return set() out: set[str] = set() try: - for raw in path.read_text(encoding = "utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - out.add(line) + text = path.read_text(encoding = "utf-8") except OSError: return set() + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + name, sep, version = line.rpartition("@") + if not sep or not name or not version: + raise ValueError( + f"{path}: allowlist entry {line!r} must be pinned to an " + "exact version (e.g. 'esbuild@0.21.5'). Bare names are " + "rejected so we cannot silently approve a later release.", + ) + out.add(line.lower()) return out +def _finding_allowlist_key(finding: Finding) -> str: + return f"{finding.name}@{finding.version}".lower() + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description = ( @@ -280,10 +292,20 @@ def main(argv: list[str] | None = None) -> int: "--allowlist", default = None, help = ( - "Path to a newline-separated allowlist of package names " + "Path to the HEAD newline-separated 'name@version' allowlist " "to skip. Defaults to '
/.install-script-allowlist'." ), ) + parser.add_argument( + "--base-allowlist", + default = None, + help = ( + "Path to the TRUSTED BASE allowlist. Defaults to " + "'