From 39112583ffea8705ad1ea8b1dc9a5c299dd77f31 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Thu, 8 Oct 2026 18:29:50 +0000 Subject: [PATCH 1/2] build(site): keep pnpm-lock.yaml one YAML document and clear GHSA-rj75 pnpm 12.10.1 wrote its own pinned version into site/pnpm-lock.yaml as a separate first YAML document. GitHub's dependency graph reads only that document, so the SBOM listed 15 pnpm packages instead of the docs site's ~490 and Dependabot closed alerts #2, #3 and #4 as fixed with braces 3.0.3, http-cache-semantics 4.2.0 and postcss-selector-parser 6.1.4 still locked. - pmOnFail: ignore in site/pnpm-workspace.yaml stops pnpm writing the env document; mise already pins pnpm 12.10.1 equal to packageManager. The lockfile loses the 158-line env document and nothing else. - Override postcss-nested to 7.0.2 (postcss-selector-parser ^7, locked 7.1.6) to clear GHSA-rj75-hqrm-r3gf. The built site/dist is byte for byte identical to main's. - lint:pnpm-lock (scripts/verify_single_yaml_document.py) fails the check gate and a lefthook pre-push job when the lockfile holds more than one document, none, or no packages, with tests for each planted defect. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- CONTRIBUTING.md | 1 + lefthook.yml | 5 + mise.toml | 13 +- .../atif-cli/tests/test_pnpm_lock_guard.py | 122 ++++++++++++ scripts/verify_single_yaml_document.py | 112 +++++++++++ site/pnpm-lock.yaml | 179 ++---------------- site/pnpm-workspace.yaml | 16 ++ 8 files changed, 281 insertions(+), 169 deletions(-) create mode 100644 packages/atif-cli/tests/test_pnpm_lock_guard.py create mode 100755 scripts/verify_single_yaml_document.py diff --git a/AGENTS.md b/AGENTS.md index 69b2e1a..ed2d4e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -412,7 +412,7 @@ the derived example must execute — or a documented `EXCLUSIONS` entry. ## Definition of done `mise run check` fully green (lint + fmt + typecheck + lint:imports + lint:workflows + -lint:hooks + test). `mise run security` is the report-only tier beside it: findings do not fail +lint:hooks + lint:pnpm-lock + test). `mise run security` is the report-only tier beside it: findings do not fail it, a scanner that produced no usable SARIF does. First-time setup: `mise trust && mise install && mise run install`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 587b304..e828a05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,7 @@ that. | `fmt` | `uv run ruff format --check .` | formatting; `mise run fmt:write` applies it | | `typecheck` | `uv run ty check` | `[tool.ty.rules] all = "error"`, and `[tool.ty.terminal] error-on-warning = true` means a warning is also a failure | | `lint:imports` | `uv run lint-imports` | the import contracts below | +| `lint:pnpm-lock` | `python3 scripts/verify_single_yaml_document.py site/pnpm-lock.yaml` | `site/pnpm-lock.yaml` holding more than one YAML document, which GitHub's dependency graph cannot read | | `test` | `uv run pytest --no-header -q` | `[tool.pytest.ini_options] testpaths = ["packages/*/tests"]` | Each gate declares `sources`, so mise skips one whose inputs have not moved by diff --git a/lefthook.yml b/lefthook.yml index 613e6c7..0976066 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -162,6 +162,11 @@ pre-push: # because a config edit is not file-scoped work. run: mise run lint:hooks + - name: lint-pnpm-lock + # site/pnpm-lock.yaml must stay one YAML document or GitHub's dependency graph loses the + # docs site's packages. Milliseconds, and the failure only shows on GitHub otherwise. + run: mise run lint:pnpm-lock + - name: test run: mise run test skip: diff --git a/mise.toml b/mise.toml index 1dae563..690f660 100644 --- a/mise.toml +++ b/mise.toml @@ -190,6 +190,16 @@ description = "Validate lefthook.yml against lefthook's own schema" sources = ["lefthook.yml"] run = "uv run lefthook validate" +[tasks."lint:pnpm-lock"] +description = "Fail unless site/pnpm-lock.yaml is one YAML document that lists packages" +# pnpm 12 writes its own pinned version into the lockfile as a separate FIRST YAML document, and +# GitHub's dependency graph reads only that one: the docs site's dependencies vanished from the +# SBOM and Dependabot closed three alerts as fixed with nothing fixed (2026-10-08, PR #37). +# `pmOnFail: ignore` in site/pnpm-workspace.yaml keeps the file one document; this gate is what +# notices the day a pnpm bump or a stray `pnpm install` brings the second document back. No +# `sources`: it is a few milliseconds, and a skipped gate reads like a passing one. +run = "python3 scripts/verify_single_yaml_document.py site/pnpm-lock.yaml lint:pnpm-lock" + [tasks.test] description = "Run the full pytest suite across all members" sources = ["packages/*/src/**/*.py", "packages/*/tests/**/*.py", "pyproject.toml", "packages/*/pyproject.toml"] @@ -203,7 +213,7 @@ description = "The suite under branch coverage; writes coverage.xml and enforces run = "uv run pytest --no-header -q --cov --cov-report=term-missing --cov-report=xml" [tasks.check] -description = "All gates: lint + fmt + typecheck (ty + pyright) + imports + workflows + hooks + vex drift + test" +description = "All gates: lint + fmt + typecheck (ty + pyright) + imports + workflows + hooks + pnpm lock + vex drift + test" depends = [ "lint", "fmt", @@ -212,6 +222,7 @@ depends = [ "lint:imports", "lint:workflows", "lint:hooks", + "lint:pnpm-lock", # The VEX drift gate is here rather than in `security` on purpose: `security` is report-only # and this must FAIL. A generated suppression surface nothing verifies is a suppression # surface that outlives its reason. diff --git a/packages/atif-cli/tests/test_pnpm_lock_guard.py b/packages/atif-cli/tests/test_pnpm_lock_guard.py new file mode 100644 index 0000000..2d890ed --- /dev/null +++ b/packages/atif-cli/tests/test_pnpm_lock_guard.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""`scripts/verify_single_yaml_document.py` rejects what GitHub's dependency graph cannot read. + +pnpm 12 can write its own pinned version into `site/pnpm-lock.yaml` as a separate first YAML +document, and GitHub reads only that one. The guard is a `check` leg, so it needs the same +anti-vacuity proof every gate here carries: a planted second document turns it red, an empty +or package-less file turns it red, and the real lockfile is green. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +#: Repository root: `packages/atif-cli/tests/` -> three parents up. +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "verify_single_yaml_document.py" +LOCKFILE = ROOT / "site" / "pnpm-lock.yaml" + +_ENV_DOCUMENT = """--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.10.1 + version: 12.10.1 + +packages: + + pnpm@12.10.1: + hasBin: true + +""" + +_PROJECT_DOCUMENT = """--- +lockfileVersion: '9.0' + +importers: + + .: + dependencies: + astro: + specifier: 7.3.5 + version: 7.3.5 + +packages: + + astro@7.3.5: + resolution: {integrity: sha512-x} + +snapshots: + + astro@7.3.5: {} +""" + + +def _run(path: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - fixed interpreter and repository script + [sys.executable, str(SCRIPT), str(path)], + capture_output=True, + text=True, + check=False, + ) + + +def test_real_lockfile_is_one_document() -> None: + assert LOCKFILE.is_file(), f"{LOCKFILE} is missing" + result = _run(LOCKFILE) + assert result.returncode == 0, result.stderr + + +def test_second_document_is_rejected(tmp_path: Path) -> None: + lock = tmp_path / "pnpm-lock.yaml" + lock.write_text(_ENV_DOCUMENT + _PROJECT_DOCUMENT, encoding="utf-8") + result = _run(lock) + assert result.returncode == 1 + assert "2 YAML documents" in result.stderr + + +def test_single_document_with_leading_marker_passes(tmp_path: Path) -> None: + lock = tmp_path / "pnpm-lock.yaml" + lock.write_text(_PROJECT_DOCUMENT, encoding="utf-8") + assert _run(lock).returncode == 0 + + +@pytest.mark.parametrize( + ("body", "reason"), + [ + ("", "no content"), + ("# only a comment\n", "no content"), + ("lockfileVersion: '9.0'\npackages:\n", "no top-level importers:"), + ("lockfileVersion: '9.0'\nimporters:\n .: {}\n", "no top-level packages:"), + ("lockfileVersion: '9.0'\nimporters:\n .: {}\npackages:\n\n", "lists no package"), + ], +) +def test_vacuous_lockfile_is_rejected(tmp_path: Path, body: str, reason: str) -> None: + lock = tmp_path / "pnpm-lock.yaml" + lock.write_text(body, encoding="utf-8") + result = _run(lock) + assert result.returncode == 1 + assert reason in result.stderr + + +def test_missing_file_is_rejected(tmp_path: Path) -> None: + result = _run(tmp_path / "absent.yaml") + assert result.returncode == 1 + assert "unreadable" in result.stderr + + +def test_wrong_arguments_exit_two() -> None: + result = subprocess.run( # noqa: S603 - fixed interpreter and repository script + [sys.executable, str(SCRIPT)], capture_output=True, text=True, check=False + ) + assert result.returncode == 2 diff --git a/scripts/verify_single_yaml_document.py b/scripts/verify_single_yaml_document.py new file mode 100755 index 0000000..37153c5 --- /dev/null +++ b/scripts/verify_single_yaml_document.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Fail unless a pnpm lockfile is exactly one YAML document with packages in it. + + scripts/verify_single_yaml_document.py [label] + +pnpm 12 can write its own pinned version into `pnpm-lock.yaml` as a SEPARATE first YAML +document (`packageManagerDependencies`), ahead of the project's. GitHub's dependency graph +reads only the first document, so the docs site's real dependencies vanish from the SBOM and +Dependabot closes their alerts as fixed with nothing fixed. `pmOnFail: ignore` in +`site/pnpm-workspace.yaml` keeps the file one document; this is the check that notices the day +it stops doing so. + +Three properties, each one a way the check could pass on nothing: + +- the file is readable and holds content a missing or empty lockfile has no documents +- exactly one document carries content a second `---` section is the defect +- that document has `importers:` and at a lockfile with no packages is a file the graph + least one `packages:` entry would read as empty, which is the failure itself + +stdlib only, no YAML parser: a pnpm lockfile is block-style with every nested line indented, +so a column-0 `---` or `...` is always a document marker and never scalar content. + +Exit 0 when the lockfile is usable, 1 with the failing property on stderr when it is not, 2 on +a usage error. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING, NoReturn + +if TYPE_CHECKING: + from collections.abc import Sequence + +_USAGE = "usage: scripts/verify_single_yaml_document.py [label]" + + +def _fail(prefix: str, path: str, reason: str) -> NoReturn: + """Reject the lockfile. The reason names the property, which is the whole value here.""" + sys.stderr.write(f"{prefix}: {path} is not a single usable YAML document ({reason})\n") + sys.exit(1) + + +def _is_marker(line: str) -> bool: + """True for a column-0 document start `---` or end `...` marker.""" + return line.rstrip() in {"---", "..."} or line.startswith(("--- ", "... ")) + + +def _has_content(lines: list[str]) -> bool: + """True when a segment holds a line that is neither blank nor a comment.""" + return any(line.strip() and not line.lstrip().startswith("#") for line in lines) + + +def verify(path: str, label: str = "") -> None: + """Fail the process unless `path` is one YAML document that lists packages.""" + prefix = label or "verify-single-yaml-document" + + try: + raw = Path(path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + _fail(prefix, path, f"unreadable: {getattr(error, 'strerror', None) or error}") + + segments: list[list[str]] = [[]] + for line in raw.splitlines(): + if _is_marker(line): + segments.append([]) + else: + segments[-1].append(line) + documents = [segment for segment in segments if _has_content(segment)] + + if not documents: + _fail(prefix, path, "no content") + if len(documents) > 1: + _fail( + prefix, + path, + f"{len(documents)} YAML documents; GitHub's dependency graph reads only the first. " + "Set `pmOnFail: ignore` in site/pnpm-workspace.yaml and drop the extra document", + ) + + lines = documents[0] + if "importers:" not in lines: + _fail(prefix, path, "no top-level importers:") + try: + start = lines.index("packages:") + except ValueError: + _fail(prefix, path, "no top-level packages:") + entries = 0 + for line in lines[start + 1 :]: + if line and not line.startswith(" "): + break + if line.startswith(" ") and not line.startswith(" ") and line.strip(): + entries += 1 + if entries == 0: + _fail(prefix, path, "packages: lists no package") + + sys.stdout.write(f"{prefix}: {path} is one YAML document with {entries} package(s)\n") + + +def main(argv: Sequence[str]) -> int: + """Entry point. Returns the process exit code; 2 means the ARGUMENTS were wrong.""" + if not argv or len(argv) > 2: # noqa: PLR2004 — the two argv slots named in _USAGE + sys.stderr.write(f"{_USAGE}\n") + return 2 + verify(argv[0], argv[1] if len(argv) > 1 else "") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 2c51488..8b9c0d6 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -1,167 +1,12 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - pnpm: - specifier: 12.10.1 - version: 12.10.1 - -packages: - - '@pnpm/exe.android-arm64@12.10.1': - resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==} - cpu: [arm64] - os: [android] - - '@pnpm/exe.android-x64@12.10.1': - resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==} - cpu: [x64] - os: [android] - - '@pnpm/exe.darwin-arm64@12.10.1': - resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==} - cpu: [arm64] - os: [darwin] - - '@pnpm/exe.darwin-x64@12.10.1': - resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==} - cpu: [x64] - os: [darwin] - - '@pnpm/exe.freebsd-x64@12.10.1': - resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==} - cpu: [x64] - os: [freebsd] - - '@pnpm/exe.linux-arm64-musl@12.10.1': - resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-arm64@12.10.1': - resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-ppc64@12.10.1': - resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-riscv64@12.10.1': - resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-s390x@12.10.1': - resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-x64-musl@12.10.1': - resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-x64@12.10.1': - resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.win32-arm64@12.10.1': - resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==} - cpu: [arm64] - os: [win32] - - '@pnpm/exe.win32-x64@12.10.1': - resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==} - cpu: [x64] - os: [win32] - - pnpm@12.10.1: - resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==} - engines: {node: '>=18.*'} - hasBin: true - -snapshots: - - '@pnpm/exe.android-arm64@12.10.1': - optional: true - - '@pnpm/exe.android-x64@12.10.1': - optional: true - - '@pnpm/exe.darwin-arm64@12.10.1': - optional: true - - '@pnpm/exe.darwin-x64@12.10.1': - optional: true - - '@pnpm/exe.freebsd-x64@12.10.1': - optional: true - - '@pnpm/exe.linux-arm64-musl@12.10.1': - optional: true - - '@pnpm/exe.linux-arm64@12.10.1': - optional: true - - '@pnpm/exe.linux-ppc64@12.10.1': - optional: true - - '@pnpm/exe.linux-riscv64@12.10.1': - optional: true - - '@pnpm/exe.linux-s390x@12.10.1': - optional: true - - '@pnpm/exe.linux-x64-musl@12.10.1': - optional: true - - '@pnpm/exe.linux-x64@12.10.1': - optional: true - - '@pnpm/exe.win32-arm64@12.10.1': - optional: true - - '@pnpm/exe.win32-x64@12.10.1': - optional: true - - pnpm@12.10.1: - optionalDependencies: - '@pnpm/exe.android-arm64': 12.10.1 - '@pnpm/exe.android-x64': 12.10.1 - '@pnpm/exe.darwin-arm64': 12.10.1 - '@pnpm/exe.darwin-x64': 12.10.1 - '@pnpm/exe.freebsd-x64': 12.10.1 - '@pnpm/exe.linux-arm64': 12.10.1 - '@pnpm/exe.linux-arm64-musl': 12.10.1 - '@pnpm/exe.linux-ppc64': 12.10.1 - '@pnpm/exe.linux-riscv64': 12.10.1 - '@pnpm/exe.linux-s390x': 12.10.1 - '@pnpm/exe.linux-x64': 12.10.1 - '@pnpm/exe.linux-x64-musl': 12.10.1 - '@pnpm/exe.win32-arm64': 12.10.1 - '@pnpm/exe.win32-x64': 12.10.1 - ---- lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + postcss-nested: 7.0.2 + importers: .: @@ -2027,14 +1872,14 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} peerDependencies: postcss: ^8.2.14 - postcss-selector-parser@6.1.4: - resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss@8.5.28: @@ -3124,7 +2969,7 @@ snapshots: hast-util-to-text: 4.0.2 hastscript: 9.0.1 postcss: 8.5.28 - postcss-nested: 6.2.0(postcss@8.5.28) + postcss-nested: 7.0.2(postcss@8.5.28) unist-util-visit: 5.1.0 unist-util-visit-parents: 6.0.2 @@ -4907,12 +4752,12 @@ snapshots: picomatch@4.0.7: {} - postcss-nested@6.2.0(postcss@8.5.28): + postcss-nested@7.0.2(postcss@8.5.28): dependencies: postcss: 8.5.28 - postcss-selector-parser: 6.1.4 + postcss-selector-parser: 7.1.6 - postcss-selector-parser@6.1.4: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 diff --git a/site/pnpm-workspace.yaml b/site/pnpm-workspace.yaml index cae8b1d..580dfe2 100644 --- a/site/pnpm-workspace.yaml +++ b/site/pnpm-workspace.yaml @@ -13,3 +13,19 @@ allowBuilds: # back to an older one, so a pinned version younger than the cooldown has no installable candidate # and the install fails outright. Such a pin goes under `minimumReleaseAgeExclude` until it ages # out; every pin in `package.json` is past the cooldown today, so the list is gone. + +# pnpm 12 reads `packageManager` in package.json and, with the default `pmOnFail: download`, writes +# the pinned pnpm into the lockfile as a separate first YAML document (`packageManagerDependencies`). +# GitHub's dependency graph parses only the first document, so the docs site's real dependencies +# vanished from the SBOM and Dependabot closed alerts as fixed. `ignore` keeps the lockfile one +# document. mise already pins pnpm 12.10.1 equal to `packageManager`, so pnpm's own version +# switching adds nothing; `lint:pnpm-lock` fails the build if the second document comes back. +pmOnFail: ignore + +# GHSA-rj75-hqrm-r3gf (postcss-selector-parser < 7.1.6). @expressive-code/core 0.44.2, the newest +# release, depends on postcss-nested ^6, which pins postcss-selector-parser ^6.1.1 and so resolves +# 6.1.4. postcss-nested 7 takes postcss-selector-parser ^7.0.0 (7.1.6 today). expressive-code only +# runs postcss-nested over the CSS it generates at build time. Drop this override once +# @expressive-code/core ships a postcss-nested 7 range. +overrides: + postcss-nested: 7.0.2 From 393635816d81828b25a5e9b6839ef7df8cc5bfc9 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Thu, 8 Oct 2026 19:03:41 +0000 Subject: [PATCH 2/2] ci(security): render dependency review allow-ghsas from the OpenVEX ledger PR #40 makes GitHub's dependency graph read site/ again, so dependency-review-action 5.0.0 sees every docs site package as added and fails on braces 3.0.3 (GHSA-vfj7-8cjw-p6xm, high, no patched release). The OpenVEX ledger already argues braces 3.0.3 and http-cache-semantics 4.2.0 not_affected, but the action has no VEX input: its one suppression surface is allow-ghsas, which src/filter.ts at a1d282b matches on the GHSA id alone. - scripts/vex_to_osv_config.py --workflow renders the allow-ghsas line of .github/workflows/dependency-review.yml from the GHSA ids of the ledger's not_affected and fixed statements (mise run security:vex), and with --check fails when the two sets differ in either direction. - --pnpm-lock holds every pkg:npm PURL in the ledger to the versions site/pnpm-lock.yaml locks, both ways (a moved version, a gone package, or a second locked copy the statement does not name), which puts back the version scoping a version-blind allow list drops. - security:vex:check passes both flags inside mise run check, and its sources include the pnpm lock and the workflow. - packages/atif-cli/tests/test_vex_drift_gate.py plants each defect and asserts the real tree is green. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dependency-review.yml | 9 + CONTRIBUTING.md | 1 + mise.toml | 30 ++- .../atif-cli/tests/test_vex_drift_gate.py | 179 ++++++++++++++ scripts/vex_to_osv_config.py | 218 +++++++++++++++++- security/atif-sql.openvex.json | 2 +- 6 files changed, 419 insertions(+), 20 deletions(-) create mode 100644 packages/atif-cli/tests/test_vex_drift_gate.py diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 13adcde..18a0313 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -50,6 +50,15 @@ jobs: # `on-failure`, not `always`: a comment on every green pull request trains reviewers # to skim past the one that is not green. comment-summary-in-pr: on-failure + # RENDERED from security/atif-sql.openvex.json by `mise run security:vex`; do not edit + # by hand. The OpenVEX ledger is the one record of why a finding is suppressed, but + # this action has no `--vex` input: its only suppression surface is this list, which + # src/filter.ts at a1d282b matches on the GHSA id alone, blind to package and version. + # So the value is the GHSA ids of the ledger's `not_affected` and `fixed` statements, + # and `mise run security:vex:check` (inside `mise run check`) fails when this set and + # the ledger's differ in either direction, or when a ledger npm version is not the one + # site/pnpm-lock.yaml locks, which puts the version scoping back. + allow-ghsas: GHSA-ch52-4w7c-c8xp, GHSA-vfj7-8cjw-p6xm # Strong copyleft cannot enter an Apache-2.0 distribution. LGPL is deliberately # absent (weak copyleft, linkable) and so is MPL-2.0, which # `mise run security:licenses` already allows by name for certifi, pathspec, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e828a05..c91d5d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ that. | `typecheck` | `uv run ty check` | `[tool.ty.rules] all = "error"`, and `[tool.ty.terminal] error-on-warning = true` means a warning is also a failure | | `lint:imports` | `uv run lint-imports` | the import contracts below | | `lint:pnpm-lock` | `python3 scripts/verify_single_yaml_document.py site/pnpm-lock.yaml` | `site/pnpm-lock.yaml` holding more than one YAML document, which GitHub's dependency graph cannot read | +| `security:vex:check` | `uv run python scripts/vex_to_osv_config.py ... --check` | `osv-scanner.toml` or the `allow-ghsas:` line of `.github/workflows/dependency-review.yml` drifting from `security/atif-sql.openvex.json`, or a ledger PURL version that `uv.lock` or `site/pnpm-lock.yaml` no longer locks; `mise run security:vex` re-renders both | | `test` | `uv run pytest --no-header -q` | `[tool.pytest.ini_options] testpaths = ["packages/*/tests"]` | Each gate declares `sources`, so mise skips one whose inputs have not moved by diff --git a/mise.toml b/mise.toml index 690f660..f9a8a14 100644 --- a/mise.toml +++ b/mise.toml @@ -325,26 +325,41 @@ depends = [ # --------------------------------------------------------------------------- [tasks."security:vex"] -description = "Regenerate osv-scanner.toml from the OpenVEX ledger" +description = "Regenerate osv-scanner.toml and dependency-review's allow-ghsas from the OpenVEX ledger" # NOT in `security`'s depends, deliberately. `osv-scanner.toml` is COMMITTED and drift-gated, so # regenerating it as part of the scan would let a scan run against a suppression surface no gate # has ever seen — the scan would define its own exemptions and then pass. Editing -# `security/atif-sql.openvex.json` means running this task and committing BOTH files. -run = "uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml" +# `security/atif-sql.openvex.json` means running this task and committing what it rewrote: +# osv-scanner.toml and the `allow-ghsas:` line of .github/workflows/dependency-review.yml. +run = """ +uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml \ + --pnpm-lock site/pnpm-lock.yaml --workflow .github/workflows/dependency-review.yml +""" [tasks."security:vex:check"] -description = "Drift gate: osv-scanner.toml must match the ledger, and the renderer must work" +description = "Drift gate: osv-scanner.toml and allow-ghsas must match the ledger, and the renderer must work" # In `check`'s depends, because a generated file nothing verifies is a file that drifts. Two # assertions, and the second exists because the live ledger ships EMPTY: # # --check re-renders the live ledger in memory and diffs it against the committed # osv-scanner.toml, printing a unified diff and failing on any difference. This also # re-runs every ledger validation, so a stale PURL version fails here even when the -# rendered TOML happens not to change. +# rendered TOML happens not to change. With `--pnpm-lock` it holds every +# `pkg:npm` PURL to the version site/pnpm-lock.yaml locks, and with `--workflow` it +# fails when dependency-review.yml's `allow-ghsas` set differs from the ledger's +# GHSA set in either direction: that action has no `--vex`, and its allow list is +# version-blind, so the lock assertion is what scopes it. # examples/ renders the worked example to a temp path and asserts both advisory ids reach the # output. With no live statements, `--check` alone would exercise only the # empty-ledger branch and the suppression-emitting path could rot unnoticed. -sources = ["security/**/*.json", "osv-scanner.toml", "scripts/vex_to_osv_config.py", "uv.lock"] +sources = [ + "security/**/*.json", + "osv-scanner.toml", + "scripts/vex_to_osv_config.py", + "uv.lock", + "site/pnpm-lock.yaml", + ".github/workflows/dependency-review.yml", +] # # `uv run python`, not bare `python3`: the renderer uses 3.12+ syntax (a `type` statement), and # on a host whose /usr/bin precedes the mise tool dir, `python3` is the system 3.9 and the task @@ -356,7 +371,8 @@ sources = ["security/**/*.json", "osv-scanner.toml", "scripts/vex_to_osv_config. # report. run = """ set -eu -uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml --check +uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml \ + --pnpm-lock site/pnpm-lock.yaml --workflow .github/workflows/dependency-review.yml --check scratch=$(mktemp -d) trap 'rm -rf "$scratch"' EXIT uv run python scripts/vex_to_osv_config.py \ diff --git a/packages/atif-cli/tests/test_vex_drift_gate.py b/packages/atif-cli/tests/test_vex_drift_gate.py new file mode 100644 index 0000000..a3bf31b --- /dev/null +++ b/packages/atif-cli/tests/test_vex_drift_gate.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""`security:vex:check` holds dependency review's `allow-ghsas` to the OpenVEX ledger. + +actions/dependency-review-action 5.0.0 has no VEX input, so its `allow-ghsas` list is rendered +from `security/atif-sql.openvex.json` by `scripts/vex_to_osv_config.py --workflow`. That list +matches on the GHSA id alone, so the script also holds every `pkg:npm` PURL in the ledger to +the version `site/pnpm-lock.yaml` locks. The gate is a `check` leg and needs the anti-vacuity +proof every gate here carries: each planted defect turns it red, and the real tree is green. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +#: Repository root: `packages/atif-cli/tests/` -> three parents up. +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "vex_to_osv_config.py" +#: Relative to ROOT, where `_run` starts the script: the renderer writes the ledger's path into +#: the osv-scanner.toml header, so the committed file only matches a render that names it the +#: way `mise run security:vex` does. +LEDGER = Path("security/atif-sql.openvex.json") +OSV_CONFIG = Path("osv-scanner.toml") +UV_LOCK = Path("uv.lock") +PNPM_LOCK = Path("site/pnpm-lock.yaml") +WORKFLOW = Path(".github/workflows/dependency-review.yml") + +_ALLOW_LINE = re.compile(r"^(\s*allow-ghsas:[ \t]*)(.*)$", re.MULTILINE) + + +def _run( + *, + ledger: Path = LEDGER, + output: Path = OSV_CONFIG, + pnpm_lock: Path = PNPM_LOCK, + workflow: Path = WORKFLOW, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + argv = [ + sys.executable, + str(SCRIPT), + str(ledger), + str(output), + "--lock", + str(UV_LOCK), + "--pnpm-lock", + str(pnpm_lock), + "--workflow", + str(workflow), + ] + if check: + argv.append("--check") + return subprocess.run( # noqa: S603 - fixed interpreter and repository script + argv, capture_output=True, text=True, check=False, cwd=ROOT + ) + + +def _ledger_ghsas() -> list[str]: + document = json.loads((ROOT / LEDGER).read_text(encoding="utf-8")) + return sorted( + statement["vulnerability"]["name"] + for statement in document["statements"] + if statement["status"] in {"not_affected", "fixed"} + ) + + +def _workflow_with(tmp_path: Path, value: str) -> Path: + text = (ROOT / WORKFLOW).read_text(encoding="utf-8") + path = tmp_path / "dependency-review.yml" + path.write_text(_ALLOW_LINE.sub(lambda m: m.group(1) + value, text, count=1), "utf-8") + return path + + +def _lock_with(tmp_path: Path, old: str, new: str) -> Path: + text = (ROOT / PNPM_LOCK).read_text(encoding="utf-8") + assert old in text, f"{old!r} is not in {PNPM_LOCK}" + path = tmp_path / "pnpm-lock.yaml" + path.write_text(text.replace(old, new), encoding="utf-8") + return path + + +def test_real_tree_is_green() -> None: + result = _run() + assert result.returncode == 0, result.stderr + assert f"allow-ghsas matches {LEDGER}" in result.stdout + + +def test_ledger_suppresses_at_least_one_npm_ghsa() -> None: + # Anti-vacuity: with an empty ledger every comparison below would pass on nothing. + ghsas = _ledger_ghsas() + assert ghsas, "the live ledger carries no suppressing GHSA statement" + assert all(ghsa.startswith("GHSA-") for ghsa in ghsas) + + +def test_ledger_ghsa_missing_from_workflow_is_red(tmp_path: Path) -> None: + dropped, *kept = _ledger_ghsas() + workflow = _workflow_with(tmp_path, ", ".join(kept) or "''") + result = _run(workflow=workflow) + assert result.returncode == 1 + assert f"allow-ghsas is missing {dropped}" in result.stderr + + +def test_workflow_ghsa_without_ledger_statement_is_red(tmp_path: Path) -> None: + extra = "GHSA-aaaa-bbbb-cccc" + workflow = _workflow_with(tmp_path, ", ".join([*_ledger_ghsas(), extra])) + result = _run(workflow=workflow) + assert result.returncode == 1 + assert f"allow-ghsas lists {extra} with no not_affected or fixed" in result.stderr + + +def test_non_suppressing_statement_leaves_the_allow_list(tmp_path: Path) -> None: + document = json.loads((ROOT / LEDGER).read_text(encoding="utf-8")) + demoted = document["statements"][0] + demoted["status"] = "under_investigation" + ledger = tmp_path / "ledger.json" + ledger.write_text(json.dumps(document), encoding="utf-8") + result = _run(ledger=ledger) + assert result.returncode == 1 + assert f"allow-ghsas lists {demoted['vulnerability']['name']}" in result.stderr + + +def test_npm_version_moved_in_lock_is_red(tmp_path: Path) -> None: + lock = _lock_with(tmp_path, "\n braces@3.0.3:", "\n braces@3.0.4:") + result = _run(pnpm_lock=lock) + assert result.returncode == 1 + assert "names pkg:npm/braces@3.0.3 but" in result.stderr + assert "locks braces 3.0.4" in result.stderr + + +def test_second_locked_copy_the_statement_does_not_name_is_red(tmp_path: Path) -> None: + lock = _lock_with( + tmp_path, "\n braces@3.0.3:", "\n braces@2.3.2:\n resolution: {}\n\n braces@3.0.3:" + ) + result = _run(pnpm_lock=lock) + assert result.returncode == 1 + assert "also locks braces 2.3.2" in result.stderr + + +def test_npm_package_gone_from_lock_is_red(tmp_path: Path) -> None: + lock = _lock_with(tmp_path, "\n braces@3.0.3:", "\n brace-gone@3.0.3:") + result = _run(pnpm_lock=lock) + assert result.returncode == 1 + assert "locks no package 'braces'" in result.stderr + + +def test_scoped_npm_purl_is_decoded(tmp_path: Path) -> None: + document = json.loads((ROOT / LEDGER).read_text(encoding="utf-8")) + purl = "pkg:npm/%40types/braces@3.0.5" + document["statements"][0]["products"] = [{"@id": purl, "identifiers": {"purl": purl}}] + ledger = tmp_path / "ledger.json" + ledger.write_text(json.dumps(document), encoding="utf-8") + workflow = tmp_path / "dependency-review.yml" + shutil.copyfile(ROOT / WORKFLOW, workflow) + result = _run(ledger=ledger, output=tmp_path / "osv.toml", workflow=workflow, check=False) + assert result.returncode == 0, result.stderr + + +def test_workflow_without_allow_line_is_red(tmp_path: Path) -> None: + text = _ALLOW_LINE.sub("", (ROOT / WORKFLOW).read_text(encoding="utf-8"), count=1) + workflow = tmp_path / "dependency-review.yml" + workflow.write_text(text, encoding="utf-8") + result = _run(workflow=workflow) + assert result.returncode == 1 + assert "exactly one `allow-ghsas:` input" in result.stderr + + +def test_render_rewrites_the_allow_line_from_the_ledger(tmp_path: Path) -> None: + workflow = _workflow_with(tmp_path, "GHSA-stale-0000-0000") + result = _run(output=tmp_path / "osv.toml", workflow=workflow, check=False) + assert result.returncode == 0, result.stderr + assert workflow.read_text(encoding="utf-8") == (ROOT / WORKFLOW).read_text(encoding="utf-8") + assert (tmp_path / "osv.toml").read_text(encoding="utf-8") == (ROOT / OSV_CONFIG).read_text( + encoding="utf-8" + ) diff --git a/scripts/vex_to_osv_config.py b/scripts/vex_to_osv_config.py index cbb71f2..9331f9c 100755 --- a/scripts/vex_to_osv_config.py +++ b/scripts/vex_to_osv_config.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -"""Render `osv-scanner.toml` from the OpenVEX ledger, and fail when the two disagree. +"""Render osv-scanner.toml and dependency review's allow-ghsas from the OpenVEX ledger. + +Fail when either disagrees with the ledger, or the ledger with the lockfiles. scripts/vex_to_osv_config.py [--lock uv.lock] - scripts/vex_to_osv_config.py [--lock uv.lock] --check + [--pnpm-lock site/pnpm-lock.yaml] [--workflow .github/workflows/dependency-review.yml] + scripts/vex_to_osv_config.py [...the same...] --check `security/atif-sql.openvex.json` is the ONE record of why a vulnerability finding is suppressed. Every scanner's suppression surface is derived from it, because three scanners @@ -40,6 +43,22 @@ bump moves the package, `mise run check` goes red and a human has to re-establish the reachability argument before the suppression can follow the dependency forward. +WHY DEPENDENCY REVIEW NEEDS IT TOO +actions/dependency-review-action 5.0.0 (a1d282b) has no VEX input either. Its one suppression +surface is `allow-ghsas`, a comma-separated list that `filterAllowedAdvisories` in src/filter.ts +matches with `ghsas.includes(vuln.advisory_ghsa_id)`: the GHSA id ALONE, blind to package, +ecosystem and version. So `--workflow` renders that input's value from the GHSA ids of the +ledger's suppressing statements (render mode rewrites the one `allow-ghsas:` line in place; +`--check` fails when the workflow's set differs from the ledger's in either direction), and the +version scoping is again rebuilt as an assertion, this time against the docs site's lock: + + with `--pnpm-lock`, every `pkg:npm/@` in the ledger must be a version + site/pnpm-lock.yaml locks, and every version of the lock holds must be one the + statement names, or this script fails. + +The second half matters because `allow-ghsas` would also hide the advisory on a second, newer +copy of the package that the reachability argument never covered. + WHAT THIS ALSO VALIDATES, AND WHY IT HAS TO grype does not validate the document. Probed 2026-08-28: grype 0.111.1 exits 0 on a ledger carrying `"status": "bogus_status"`, so a typo'd status silently suppresses nothing. It DOES @@ -67,10 +86,12 @@ import argparse import difflib import json +import re import sys import tomllib from pathlib import Path from typing import TYPE_CHECKING, NoReturn +from urllib.parse import unquote if TYPE_CHECKING: from collections.abc import Iterator, Sequence @@ -127,6 +148,10 @@ # real, which is the one thing this pipeline must never do. _SUPPRESSING_STATUSES = frozenset({"not_affected", "fixed"}) +# The one `allow-ghsas:` input line in the dependency-review workflow. Group 1 is everything up to +# and including the colon and its following space, kept verbatim on a rewrite. +_ALLOW_GHSAS = re.compile(r"^(\s*allow-ghsas:[ \t]*)(.*)$", re.MULTILINE) + _HEADER = """\ # GENERATED by scripts/vex_to_osv_config.py from {source} — do not edit. # @@ -197,6 +222,34 @@ def _locked_versions(lock_path: Path) -> dict[str, str]: return resolved +def _pnpm_locked_versions(lock_path: Path) -> dict[str, set[str]]: + """Map every package name in a pnpm v9 lockfile's `packages:` section to its versions. + + No YAML parser, matching scripts/verify_single_yaml_document.py: the section is block style, + one ` @:` key per package at a two-space indent, quoted when it starts with + `@`. A name can lock more than one version, so each maps to a set. + """ + try: + lines = lock_path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as error: + _fail(f"cannot read {lock_path}: {getattr(error, 'strerror', None) or error}") + resolved: dict[str, set[str]] = {} + inside = False + for line in lines: + if line and not line[0].isspace(): + inside = line.rstrip() == "packages:" + continue + if not inside or not line.startswith(" ") or line.startswith(" "): + continue + key = line.strip().removesuffix(":").strip("'\"") + name, _, version = key.rpartition("@") + if name and version: + resolved.setdefault(name, set()).add(version.split("(", 1)[0]) + if not resolved: + _fail(f"{lock_path} lists no packages under a top-level packages: section") + return resolved + + def _split_purl(purl: str) -> tuple[str, str, str]: """Split `pkg:/@` into its three parts. @@ -215,6 +268,8 @@ def _split_purl(purl: str) -> tuple[str, str, str]: f"whose reachability was argued, or the suppression outlives the argument" ) name, version = remainder.rsplit("@", 1) + # An npm scope is percent-encoded in a PURL (`pkg:npm/%40types/node@24.0.0`). + name = unquote(name) if not name or not version: _fail(f"PURL {purl!r} has an empty name or version") return ecosystem, name, version @@ -291,8 +346,53 @@ def _validate_document(document: JsonValue, source: str) -> list[JsonValue]: return collected -def _validate_statement(statement: JsonValue, offset: int, locked: dict[str, str]) -> None: - """Check one statement, including its PURL versions against uv.lock.""" +def _check_npm_products( + purls: list[str], offset: int, npm_locked: dict[str, set[str]], lock_name: str +) -> None: + """Hold a statement's npm PURLs to the versions the pnpm lockfile resolves. + + Both directions, because `allow-ghsas` is version-blind: a named version the lock no longer + holds is a stale argument, and a locked version the statement does not name is a copy of the + package the argument never covered but the workflow would suppress anyway. + """ + named: dict[str, set[str]] = {} + for purl in purls: + ecosystem, name, product_version = _split_purl(purl) + if ecosystem == "npm": + named.setdefault(name, set()).add(product_version) + for name, versions in sorted(named.items()): + resolved = npm_locked.get(name) + if resolved is None: + _fail( + f"statements[{offset}] names pkg:npm/{name} but {lock_name} locks no package " + f"{name!r} — the dependency is gone, so delete the statement" + ) + moved = sorted(versions - resolved) + if moved: + _fail( + f"statements[{offset}] names pkg:npm/{name}@{', '.join(moved)} but {lock_name} " + f"locks {name} {', '.join(sorted(resolved))}. The suppression was reasoned about " + f"a version the docs site no longer locks: re-establish the reachability " + f"argument, update the PURL and the impact_statement, bump the document " + f"version, then regenerate" + ) + uncovered = sorted(resolved - versions) + if uncovered: + _fail( + f"statements[{offset}] names pkg:npm/{name}@{', '.join(sorted(versions))} but " + f"{lock_name} also locks {name} {', '.join(uncovered)}. dependency-review's " + f"allow-ghsas is version-blind, so it would suppress the advisory on that copy " + f"too: argue it in the statement as another product, or remove the copy" + ) + + +def _validate_statement( + statement: JsonValue, + offset: int, + locked: dict[str, str], + npm_locked: tuple[dict[str, set[str]], str] | None = None, +) -> None: + """Check one statement, its PyPI versions against uv.lock and its npm ones against pnpm.""" if not isinstance(statement, dict): _fail(f"statements[{offset}] is not a JSON object") unknown = sorted(set(statement) - _STATEMENT_KEYS) @@ -325,12 +425,15 @@ def _validate_statement(statement: JsonValue, offset: int, locked: dict[str, str f"the TOML literal string this renders into" ) - for purl in _statement_purls(statement, offset): + purls = _statement_purls(statement, offset) + if npm_locked is not None: + _check_npm_products(purls, offset, *npm_locked) + for purl in purls: ecosystem, name, product_version = _split_purl(purl) if ecosystem != "pypi": - # uv.lock resolves PyPI only. A statement about another ecosystem (the docs site's - # npm tree, say) still has to name a version, but there is no lockfile here to - # check it against, so it passes unasserted rather than being silently trusted. + # uv.lock resolves PyPI only; npm is asserted above against `--pnpm-lock` when it is + # given. Any other ecosystem still has to name a version, but there is no lockfile + # here to check it against, so it passes unasserted rather than silently trusted. continue resolved = locked.get(name) if resolved is None: @@ -375,8 +478,60 @@ def _render_entries(statements: Sequence[JsonValue], source: str) -> Iterator[st ) -def render(ledger_path: Path, lock_path: Path) -> str: - """Validate the ledger and return the `osv-scanner.toml` text it renders to.""" +def allowed_ghsas(statements: Sequence[JsonValue]) -> list[str]: + """The GHSA ids of every suppressing statement, its name and aliases, sorted and unique.""" + ids: set[str] = set() + for offset, statement in enumerate(statements): + if _get(statement, "status") in _SUPPRESSING_STATUSES: + ids.update(i for i in _vulnerability_ids(statement, offset) if i.startswith("GHSA-")) + return sorted(ids) + + +def _workflow_line(text: str, workflow: str) -> re.Match[str]: + """Find the workflow's one `allow-ghsas:` line; zero or several is a defect.""" + matches = list(_ALLOW_GHSAS.finditer(text)) + if len(matches) != 1: + _fail( + f"{workflow} must carry exactly one `allow-ghsas:` input rendered from the ledger, " + f"found {len(matches)}" + ) + return matches[0] + + +def _parse_ghsas(value: str) -> set[str]: + """Read an `allow-ghsas` value the way the action does: split on commas, trim each.""" + value = value.split(" #", 1)[0].strip().strip("'\"") + return {item.strip() for item in value.split(",") if item.strip()} + + +def render_workflow(text: str, ghsas: Sequence[str], workflow: str) -> str: + """Return the workflow text with its `allow-ghsas:` value set to `ghsas`.""" + line = _workflow_line(text, workflow) + value = ", ".join(ghsas) if ghsas else "''" + return text[: line.start()] + line.group(1) + value + text[line.end() :] + + +def check_workflow(text: str, ghsas: Sequence[str], workflow: str, ledger: str) -> None: + """Fail when the workflow's `allow-ghsas` set differs from the ledger's, either way.""" + listed = _parse_ghsas(_workflow_line(text, workflow).group(2)) + missing = sorted(set(ghsas) - listed) + if missing: + _fail( + f"{workflow} allow-ghsas is missing {', '.join(missing)}, which {ledger} suppresses — " + f"dependency review will fail on a finding the ledger already argued: run " + f"`mise run security:vex` and commit both files" + ) + extra = sorted(listed - set(ghsas)) + if extra: + _fail( + f"{workflow} allow-ghsas lists {', '.join(extra)} with no not_affected or fixed " + f"statement in {ledger} — a suppression with no recorded argument: add the " + f"statement or run `mise run security:vex` to drop it" + ) + + +def load(ledger_path: Path, lock_path: Path, pnpm_lock_path: Path | None = None) -> list[JsonValue]: + """Read and validate the ledger against its lockfiles, return its statements.""" source = ledger_path.as_posix() try: raw = ledger_path.read_text(encoding="utf-8") @@ -389,9 +544,18 @@ def render(ledger_path: Path, lock_path: Path) -> str: statements = _validate_document(document, source) locked = _locked_versions(lock_path) + npm_locked = ( + (_pnpm_locked_versions(pnpm_lock_path), pnpm_lock_path.as_posix()) + if pnpm_lock_path is not None + else None + ) for offset, statement in enumerate(statements): - _validate_statement(statement, offset, locked) + _validate_statement(statement, offset, locked, npm_locked) + return statements + +def render(statements: Sequence[JsonValue], source: str) -> str: + """Return the `osv-scanner.toml` text a validated ledger renders to.""" entries = list(_render_entries(statements, source)) body = "".join(entries) if entries else _EMPTY_BODY return _HEADER.format(source=source, body=body) @@ -408,6 +572,14 @@ def main(argv: Sequence[str]) -> int: parser.add_argument( "--lock", default="uv.lock", help="lockfile the PURL versions are checked against" ) + parser.add_argument( + "--pnpm-lock", + help="pnpm lockfile the ledger's pkg:npm PURL versions are checked against", + ) + parser.add_argument( + "--workflow", + help="dependency-review workflow whose allow-ghsas input is rendered from the ledger", + ) parser.add_argument( "--check", action="store_true", @@ -417,13 +589,35 @@ def main(argv: Sequence[str]) -> int: ledger_path = Path(args.ledger) output_path = Path(args.output) - rendered = render(ledger_path, Path(args.lock)) + pnpm_lock = Path(args.pnpm_lock) if args.pnpm_lock else None + statements = load(ledger_path, Path(args.lock), pnpm_lock) + rendered = render(statements, ledger_path.as_posix()) + ghsas = allowed_ghsas(statements) + workflow = Path(args.workflow) if args.workflow else None + workflow_text = "" + if workflow is not None: + try: + workflow_text = workflow.read_text(encoding="utf-8") + except OSError as error: + _fail(f"cannot read {workflow}: {error.strerror or type(error).__name__}") if not args.check: output_path.write_text(rendered, encoding="utf-8") sys.stdout.write(f"vex-to-osv-config: wrote {output_path} from {ledger_path}\n") + if workflow is not None: + workflow.write_text( + render_workflow(workflow_text, ghsas, workflow.as_posix()), encoding="utf-8" + ) + sys.stdout.write(f"vex-to-osv-config: wrote allow-ghsas in {workflow}\n") return 0 + if workflow is not None: + check_workflow(workflow_text, ghsas, workflow.as_posix(), ledger_path.as_posix()) + sys.stdout.write( + f"vex-to-osv-config: {workflow} allow-ghsas matches {ledger_path} " + f"({len(ghsas)} GHSA ids)\n" + ) + try: committed = output_path.read_text(encoding="utf-8") except OSError: diff --git a/security/atif-sql.openvex.json b/security/atif-sql.openvex.json index 2d05fa2..dc3d372 100644 --- a/security/atif-sql.openvex.json +++ b/security/atif-sql.openvex.json @@ -5,7 +5,7 @@ "role": "document creator", "timestamp": "2026-08-28T17:06:14Z", "version": 2, - "tooling": "hand-authored; rendered to osv-scanner.toml by scripts/vex_to_osv_config.py", + "tooling": "hand-authored; rendered to osv-scanner.toml and the allow-ghsas input of .github/workflows/dependency-review.yml by scripts/vex_to_osv_config.py", "statements": [ { "@id": "https://github.com/laithalsaadoon/atif-sql/security/atif-sql.openvex.json#braces-3.0.3",