From 175db67471e79c69148a55e6b5979a80039cc4e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 19:31:18 +0900 Subject: [PATCH 01/23] feat(release): block tagging on GPL-family or unknown artifact licences The release workflow already downloads the mandatory CycloneDX SBOM for the exact commit, but nothing read it for licence terms, so a GPL-family or undeclared component could reach a published release. Gate the SBOM inside `verify`, which `publish` depends on, so a failure stops the run before any tag exists. The SBOM is the scope on purpose: it lists transitive, optional and build components that a pyproject-only reading would miss, and being unexecuted is not an exemption. A dual-licensed component passes only when its SPDX expression really offers a permissive alternative with OR, since AND imposes both; SPDX operators are matched case-sensitively so a name like GPL-2.0-or-later stays one operand. An absent, empty or placeholder licence fails closed rather than passing silently, and an empty component set is not accepted as evidence of cleanliness. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/release.yml | 10 ++ docs/RELEASING.md | 13 ++- scripts/ci/release_license_gate.py | 155 +++++++++++++++++++++++++++++ tests/test_release_license_gate.py | 105 +++++++++++++++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 scripts/ci/release_license_gate.py create mode 100644 tests/test_release_license_gate.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a2d8de17..cc4380d0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -286,6 +286,16 @@ jobs: exit 1 fi + - name: Refuse to release a GPL-family or unknown-licence artifact + run: | + set -euo pipefail + # The SBOM downloaded above is the artifact's own component list, so + # it covers transitive, optional and build components that a + # pyproject-only reading would miss. A failure here stops the run + # before `publish` exists, so no tag is created and nothing is + # published; it is never waived to get a release out. + python -m scripts.ci.release_license_gate --sbom sbom-download/cyclonedx-sbom.json + - name: Upload rendered notes and SBOM for the publish job uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # actions/upload-artifact@v5 with: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2c3f6bcb3..7b9075b6b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -50,7 +50,16 @@ LifeOS and other consumers must verify their specific owner contracts too. 5. A successful `security.yml` run for that exact commit exposes a non-empty `cyclonedx-sbom/cyclonedx-sbom.json` artifact. Lookup, download, upload, empty-file or content-verification failure is fatal, not best effort. -6. A repository administrator has enabled GitHub release immutability before +6. That same SBOM passes the fail-closed licence gate + (`scripts/ci/release_license_gate.py`): no component may carry a GPL-family + licence (GPL, LGPL or AGPL in any spelling) and none may have an absent, + placeholder or unknown licence. The SBOM is the scope, so transitive, + optional and build-only components count; being unexecuted is not an + exemption. A dual-licensed component passes only when its SPDX expression + really offers a permissive alternative with `OR` -- `AND` imposes both. The + gate runs inside `verify`, which `publish` depends on, so a failure stops + the run before any tag exists. It is never waived to get a release out. +7. A repository administrator has enabled GitHub release immutability before publication. The normal workflow token has no Administration permission; do not add an administrative secret or expand the publisher's authority merely to read or change this setting. The publisher validates the actual @@ -58,7 +67,7 @@ LifeOS and other consumers must verify their specific owner contracts too. reporting success. A setting that was disabled or changed during publication can leave a complete but mutable public release; that is a **failed** run and is ineligible for consumption, not an automatic deletion/retagging case. -7. The runner's GitHub CLI supports `gh release verify` and +8. The runner's GitHub CLI supports `gh release verify` and `gh release verify-asset`. Missing verification capability fails closed; do not replace it with a filename or hash-only success claim. diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py new file mode 100644 index 000000000..4b9a7cdfd --- /dev/null +++ b/scripts/ci/release_license_gate.py @@ -0,0 +1,155 @@ +"""Fail-closed licence gate for the canonical release, read from the CycloneDX SBOM. + +The release workflow already downloads the mandatory `cyclonedx-sbom.json` for +the exact commit being released. That SBOM enumerates every component that ends +up in the distributed artifact -- direct, transitive, build and optional alike +-- so it is the honest input for a licence decision: a check driven by +`pyproject.toml` alone would miss transitive components, and one driven by the +locally installed environment would miss whatever that environment happens not +to install. + +Two outcomes block a release, and neither is waivable here: + +* a copyleft licence in the GPL family (GPL, LGPL, AGPL, in any SPDX spelling); +* a component whose licence is absent, empty or literally unknown. + +A dual-licensed component passes only when its expression really does offer a +permissive alternative (`Apache-2.0 OR GPL-2.0-only` passes and records +`Apache-2.0` as the taken option; `Apache-2.0 AND GPL-2.0-only` does not, since +`AND` imposes both). Being unused at runtime, optional or test-only is never an +exemption: if it is in the artifact's SBOM, it is in scope. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from typing import Any + +# SPDX identifiers and the older free-text spellings that mean the same family. +_COPYLEFT_PATTERN = re.compile( + r"(?:^|[^A-Za-z])(?:A?GPL|LGPL|GPL)(?:[-_ ]|$)" + r"|GNU\s+(?:Affero\s+|Lesser\s+)?General\s+Public", + re.IGNORECASE, +) +_UNKNOWN_VALUES = frozenset({"", "unknown", "none", "null", "noassertion", "other", "proprietary"}) + + +def _license_terms(component: dict[str, Any]) -> list[str]: + """Collect every licence string CycloneDX offers for one component.""" + terms: list[str] = [] + for entry in component.get("licenses") or []: + if not isinstance(entry, dict): + continue + expression = entry.get("expression") + if isinstance(expression, str) and expression.strip(): + terms.append(expression.strip()) + license_object = entry.get("license") + if isinstance(license_object, dict): + for key in ("id", "name"): + value = license_object.get(key) + if isinstance(value, str) and value.strip(): + terms.append(value.strip()) + return terms + + +def _permissive_choice(expression: str) -> str | None: + """Return one non-copyleft operand of an SPDX ``OR`` expression, if any. + + ``AND`` imposes every operand at once, so an expression containing a + top-level ``AND`` never yields a choice here even when one operand is + permissive. Operators are matched case-sensitively, as SPDX defines them, + so a name such as ``GPL-2.0-or-later`` is one operand rather than two. + """ + # SPDX operators are upper-case standalone tokens. Matching them + # case-insensitively would split "GPL-2.0-or-later" on its own name and + # invent a permissive operand that the licence never offered. + cleaned = expression.replace("(", " ").replace(")", " ") + if re.search(r"\sAND\s", cleaned): + return None + operands = [part.strip() for part in re.split(r"\sOR\s", cleaned)] + if len(operands) < 2: + return None + for operand in operands: + if operand and not _COPYLEFT_PATTERN.search(operand): + return operand + return None + + +def classify_sbom_components(sbom: dict[str, Any]) -> dict[str, list[dict[str, str]]]: + """Split every SBOM component into permitted, copyleft and unknown groups.""" + permitted: list[dict[str, str]] = [] + copyleft: list[dict[str, str]] = [] + unknown: list[dict[str, str]] = [] + for component in sbom.get("components") or []: + if not isinstance(component, dict): + continue + name = str(component.get("name") or "") + version = str(component.get("version") or "") + terms = _license_terms(component) + usable = [term for term in terms if term.strip().lower() not in _UNKNOWN_VALUES] + row = {"name": name, "version": version, "license": "; ".join(usable)} + if not usable: + unknown.append(row) + continue + copyleft_terms = [term for term in usable if _COPYLEFT_PATTERN.search(term)] + if not copyleft_terms: + permitted.append(row) + continue + choice = next( + (taken for term in copyleft_terms for taken in (_permissive_choice(term),) if taken), + None, + ) + if choice is not None: + permitted.append({**row, "license": f"{row['license']} (permissive option taken: {choice})"}) + continue + copyleft.append(row) + return {"permitted": permitted, "copyleft": copyleft, "unknown": unknown} + + +def _render(groups: dict[str, list[dict[str, str]]]) -> str: + lines = [ + f"permitted={len(groups['permitted'])} " + f"copyleft={len(groups['copyleft'])} unknown={len(groups['unknown'])}" + ] + for label in ("copyleft", "unknown"): + for row in groups[label]: + lines.append(f"{label.upper()} {row['name']}=={row['version']} license={row['license'] or ''}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sbom", required=True, help="path to cyclonedx-sbom.json for the exact commit") + arguments = parser.parse_args(argv) + try: + with open(arguments.sbom, encoding="utf-8") as handle: + sbom = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + print(f"::error::Release licence gate could not read the CycloneDX SBOM at {arguments.sbom}: {error}", file=sys.stderr) + return 1 + if not isinstance(sbom, dict) or not sbom.get("components"): + print( + "::error::CycloneDX SBOM lists no components; an empty component set is not evidence that the " + "artifact is licence-clean. Refusing to release.", + file=sys.stderr, + ) + return 1 + groups = classify_sbom_components(sbom) + print(_render(groups)) + if groups["copyleft"] or groups["unknown"]: + print( + "::error::Release licence gate failed: " + f"{len(groups['copyleft'])} GPL-family and {len(groups['unknown'])} unknown-licence component(s) " + "are present in the artifact SBOM. Replace them with permissively licensed alternatives; being " + "optional or unexecuted is not an exemption, and this gate is never waived to publish.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry point + raise SystemExit(main()) diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py new file mode 100644 index 000000000..b2d8cec86 --- /dev/null +++ b/tests/test_release_license_gate.py @@ -0,0 +1,105 @@ +"""Contract for the fail-closed release licence gate. + +A release must not start tagging or publishing while the artifact's own +CycloneDX SBOM still carries a GPL-family or unknown-licence component. These +cases are the ones the coordinator's policy names explicitly: optional and +unexecuted components are in scope, a dual licence passes only on a real +permissive option, and an unknown licence is a stop rather than a pass. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.ci.release_license_gate import classify_sbom_components, main # noqa: E402 + + +def _sbom(*components: dict) -> dict: + return {"bomFormat": "CycloneDX", "specVersion": "1.5", "components": list(components)} + + +def _component(name: str, version: str, *, expression: str | None = None, license_id: str | None = None, + licenses: list | None = None) -> dict: + component = {"name": name, "version": version, "type": "library"} + if licenses is not None: + component["licenses"] = licenses + elif expression is not None: + component["licenses"] = [{"expression": expression}] + elif license_id is not None: + component["licenses"] = [{"license": {"id": license_id}}] + return component + + +def _write(tmp_path: Path, payload: dict) -> str: + path = tmp_path / "cyclonedx-sbom.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return str(path) + + +@pytest.mark.parametrize( + "license_id", + ["LGPL-3.0-only", "GPL-2.0-or-later", "AGPL-3.0", "GNU Lesser General Public License v3 (LGPLv3)"], +) +def test_copyleft_component_blocks_the_release(tmp_path, license_id) -> None: + sbom = _sbom(_component("permitted_library", "1.0", license_id="MIT"), + _component("copyleft_library", "3.3.4", license_id=license_id)) + + groups = classify_sbom_components(sbom) + + assert [row["name"] for row in groups["copyleft"]] == ["copyleft_library"] + assert main(["--sbom", _write(tmp_path, sbom)]) == 1 + + +def test_unknown_license_blocks_the_release(tmp_path) -> None: + """An absent or placeholder licence is a stop, never an implicit pass.""" + sbom = _sbom(_component("undeclared_library", "0.1"), + _component("placeholder_library", "0.2", license_id="NOASSERTION")) + + groups = classify_sbom_components(sbom) + + assert {row["name"] for row in groups["unknown"]} == {"undeclared_library", "placeholder_library"} + assert main(["--sbom", _write(tmp_path, sbom)]) == 1 + + +def test_permissive_only_sbom_passes(tmp_path) -> None: + sbom = _sbom(_component("mit_library", "1.0", license_id="MIT"), + _component("apache_library", "2.0", license_id="Apache-2.0"), + _component("mozilla_library", "3.0", license_id="MPL-2.0")) + + assert main(["--sbom", _write(tmp_path, sbom)]) == 0 + + +def test_dual_license_passes_only_on_a_real_permissive_option(tmp_path) -> None: + choosable = _sbom(_component("dual_choice_library", "1.0", expression="Apache-2.0 OR GPL-2.0-only")) + conjunctive = _sbom(_component("dual_conjunct_library", "1.0", expression="Apache-2.0 AND GPL-2.0-only")) + + assert main(["--sbom", _write(tmp_path, choosable)]) == 0 + assert classify_sbom_components(conjunctive)["copyleft"][0]["name"] == "dual_conjunct_library" + + +def test_empty_component_set_is_not_evidence_of_cleanliness(tmp_path) -> None: + assert main(["--sbom", _write(tmp_path, {"bomFormat": "CycloneDX", "components": []})]) == 1 + + +def test_missing_sbom_file_fails_closed(tmp_path) -> None: + assert main(["--sbom", str(tmp_path / "absent.json")]) == 1 + + +def test_release_workflow_runs_the_gate_before_publishing() -> None: + """The gate must sit in `verify`, which `publish` depends on, after the SBOM download.""" + workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "release.yml" + text = workflow.read_text(encoding="utf-8") + + verify_block = text[text.index("\n verify:") : text.index("\n publish:")] + assert "scripts.ci.release_license_gate" in verify_block + assert verify_block.index("Fetch the required CycloneDX SBOM") < verify_block.index( + "scripts.ci.release_license_gate" + ) + publish_block = text[text.index("\n publish:") :] + assert "needs: verify" in publish_block From 7d5aabafb6714e90c7535e88ee0eb5c24cce122a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 20:54:02 +0900 Subject: [PATCH 02/23] fix(release): close six fail-open paths in the licence gate and prove SBOM coverage Independent review reproduced six inputs that the first revision let through: a LicenseRef- pointer, 'GPL-3.0-only OR UNKNOWN', the free-text spelling 'GPLv3', a permissive expression sitting beside a separate AGPL entry, a null component, and a GPL component nested under a permitted parent. Each had the same shape -- an undecidable or composite case resolved in favour of passing. They are now resolved in favour of refusing: - LicenseRef-/DocumentRef- and placeholder values are undecidable, and undecidable is never read as permission. - SPDX composition is evaluated properly: separate licenses[] entries are conjunctive and each must pass; AND contaminates; OR yields a choice only when no operand is undecidable. - The copyleft matcher no longer requires a separator, so GPLv3 matches while MIT-0 still does not. - Components are walked recursively, and a non-object component or non-list components/licenses field is a schema refusal. The gate additionally proves coverage rather than assuming it: every distribution declared across pyproject's runtime, optional-extra and dependency-group scopes must appear in the SBOM, and a shipped Cargo or npm manifest must have matching purl components. security.yml currently collects only the installed api/db/queue environment, so this fails closed until SBOM collection is extended -- which is the point. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/release.yml | 16 +- docs/RELEASING.md | 22 ++- scripts/ci/release_license_gate.py | 271 ++++++++++++++++++++--------- tests/test_release_license_gate.py | 89 +++++++++- 4 files changed, 308 insertions(+), 90 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc4380d0d..349ee6735 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -289,12 +289,16 @@ jobs: - name: Refuse to release a GPL-family or unknown-licence artifact run: | set -euo pipefail - # The SBOM downloaded above is the artifact's own component list, so - # it covers transitive, optional and build components that a - # pyproject-only reading would miss. A failure here stops the run - # before `publish` exists, so no tag is created and nothing is - # published; it is never waived to get a release out. - python -m scripts.ci.release_license_gate --sbom sbom-download/cyclonedx-sbom.json + # The SBOM downloaded above is the artifact's own component list. + # It is also checked for coverage: every scope pyproject.toml + # declares, and every non-Python manifest that ships, must actually + # appear, because a partial SBOM is silence rather than evidence. + # A failure here stops the run before `publish` exists, so no tag is + # created and nothing is published; it is never waived. + python -m scripts.ci.release_license_gate \ + --sbom sbom-download/cyclonedx-sbom.json \ + --pyproject pyproject.toml \ + --repository-root . - name: Upload rendered notes and SBOM for the publish job uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # actions/upload-artifact@v5 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7b9075b6b..892a5425e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -56,8 +56,26 @@ LifeOS and other consumers must verify their specific owner contracts too. placeholder or unknown licence. The SBOM is the scope, so transitive, optional and build-only components count; being unexecuted is not an exemption. A dual-licensed component passes only when its SPDX expression - really offers a permissive alternative with `OR` -- `AND` imposes both. The - gate runs inside `verify`, which `publish` depends on, so a failure stops + really offers a permissive alternative with `OR`: `AND` imposes both, and an + undecidable operand (`GPL-3.0-only OR UNKNOWN`) offers no reviewable choice. + Separate `licenses[]` entries are conjunctive, so each must pass on its own, + and nested components are adjudicated like any other. A malformed SBOM, or + one with no components, is refused rather than read as clean. + + The same gate also proves *coverage*: every distribution declared in + `pyproject.toml` -- runtime, every optional extra and every dependency group + -- must appear in the SBOM, and a shipped non-Python manifest (`rust/Cargo.toml`, + `package.json`) must have matching `pkg:cargo/` or `pkg:npm/` components. A + partial SBOM is silence about the scopes it never collected, not evidence. + + Known gap, which keeps releases blocked until it is closed: `security.yml` + builds the SBOM with `cyclonedx-py environment` in an Ubuntu/Python 3.12 + environment installed from `requirements.lock` (`api`, `db`, `queue`), so the + `dev`, `fuzz` and `native-build` groups, the Rust workspace, the npm packages + and the container image layers are not collected. Extending that collection is + the prerequisite for any release, not a reason to relax this gate. + + The gate runs inside `verify`, which `publish` depends on, so a failure stops the run before any tag exists. It is never waived to get a release out. 7. A repository administrator has enabled GitHub release immutability before publication. The normal workflow token has no Administration permission; diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 4b9a7cdfd..dcf6cb7f6 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -1,23 +1,31 @@ """Fail-closed licence gate for the canonical release, read from the CycloneDX SBOM. The release workflow already downloads the mandatory `cyclonedx-sbom.json` for -the exact commit being released. That SBOM enumerates every component that ends -up in the distributed artifact -- direct, transitive, build and optional alike --- so it is the honest input for a licence decision: a check driven by -`pyproject.toml` alone would miss transitive components, and one driven by the -locally installed environment would miss whatever that environment happens not -to install. - -Two outcomes block a release, and neither is waivable here: - -* a copyleft licence in the GPL family (GPL, LGPL, AGPL, in any SPDX spelling); -* a component whose licence is absent, empty or literally unknown. - -A dual-licensed component passes only when its expression really does offer a -permissive alternative (`Apache-2.0 OR GPL-2.0-only` passes and records -`Apache-2.0` as the taken option; `Apache-2.0 AND GPL-2.0-only` does not, since -`AND` imposes both). Being unused at runtime, optional or test-only is never an -exemption: if it is in the artifact's SBOM, it is in scope. +the exact commit being released. That SBOM is the input here: it names the +components that actually ship, including transitive ones a `pyproject.toml` +reading would miss. + +Three outcomes block a release, none of them waivable: + +* a copyleft licence in the GPL family (GPL, LGPL, AGPL, in SPDX or free-text + spelling, including forms like ``GPLv3`` that carry no separator); +* a licence that cannot be adjudicated -- absent, empty, a placeholder such as + ``NOASSERTION``, or a ``LicenseRef-`` pointer to a text this gate has not + reviewed. Undecidable is refused, never read as permission; +* an SBOM that does not demonstrably cover the dependency scopes this + repository declares, because a partial SBOM says nothing about the scopes it + never collected. + +Composite licence semantics follow SPDX rather than convenience. Separate +``licenses[]`` entries are conjunctive, so every entry must pass on its own. A +single expression passes only when its operands really offer a permissive +choice: ``Apache-2.0 OR GPL-2.0-only`` passes and records the option taken, +``Apache-2.0 AND GPL-2.0-only`` does not because ``AND`` imposes both, and +``GPL-3.0-only OR UNKNOWN`` does not either, because an undecidable operand +cannot be the permissive one. SPDX operators are matched case-sensitively, so +``GPL-2.0-or-later`` stays a single operand. + +Being optional, unexecuted or test-only is never an exemption. """ from __future__ import annotations @@ -26,125 +34,226 @@ import json import re import sys +import tomllib +from pathlib import Path from typing import Any -# SPDX identifiers and the older free-text spellings that mean the same family. +# GPL/LGPL/AGPL in any spelling, including ``GPLv3``. No trailing separator is +# required: free-text spellings run the version straight onto the family name. _COPYLEFT_PATTERN = re.compile( - r"(?:^|[^A-Za-z])(?:A?GPL|LGPL|GPL)(?:[-_ ]|$)" + r"(?:^|[^A-Za-z0-9])(?:A?GPL|LGPL)" r"|GNU\s+(?:Affero\s+|Lesser\s+)?General\s+Public", re.IGNORECASE, ) -_UNKNOWN_VALUES = frozenset({"", "unknown", "none", "null", "noassertion", "other", "proprietary"}) +_UNDECIDABLE_VALUES = frozenset({"", "unknown", "none", "null", "noassertion", "other", "proprietary"}) +_UNDECIDABLE_PREFIXES = ("licenseref-", "documentref-") +_NAME_SEPARATORS = re.compile(r"[-_.]+") +_REQUIREMENT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*") + + +class SbomSchemaError(Exception): + """The SBOM could not be read as a component inventory.""" + + +def _is_undecidable(term: str) -> bool: + lowered = term.strip().lower() + return lowered in _UNDECIDABLE_VALUES or lowered.startswith(_UNDECIDABLE_PREFIXES) def _license_terms(component: dict[str, Any]) -> list[str]: - """Collect every licence string CycloneDX offers for one component.""" + """Return one string per ``licenses[]`` entry; entries are conjunctive.""" + licenses = component.get("licenses") + if licenses is None: + return [] + if not isinstance(licenses, list): + raise SbomSchemaError(f"component {component.get('name')!r} has a non-list licenses field") terms: list[str] = [] - for entry in component.get("licenses") or []: + for entry in licenses: if not isinstance(entry, dict): - continue + raise SbomSchemaError(f"component {component.get('name')!r} has a malformed licenses entry") expression = entry.get("expression") if isinstance(expression, str) and expression.strip(): terms.append(expression.strip()) + continue license_object = entry.get("license") if isinstance(license_object, dict): - for key in ("id", "name"): - value = license_object.get(key) - if isinstance(value, str) and value.strip(): - terms.append(value.strip()) + value = license_object.get("id") or license_object.get("name") + terms.append(value.strip() if isinstance(value, str) else "") + continue + terms.append("") return terms -def _permissive_choice(expression: str) -> str | None: - """Return one non-copyleft operand of an SPDX ``OR`` expression, if any. +def classify_license_term(term: str) -> tuple[str, str]: + """Classify one ``licenses[]`` entry as permitted, copyleft or undecidable. - ``AND`` imposes every operand at once, so an expression containing a - top-level ``AND`` never yields a choice here even when one operand is - permissive. Operators are matched case-sensitively, as SPDX defines them, - so a name such as ``GPL-2.0-or-later`` is one operand rather than two. + The second element carries the reason; for a permitted dual licence it is + the permissive operand actually relied upon. """ - # SPDX operators are upper-case standalone tokens. Matching them - # case-insensitively would split "GPL-2.0-or-later" on its own name and - # invent a permissive operand that the licence never offered. - cleaned = expression.replace("(", " ").replace(")", " ") + if _is_undecidable(term): + return "undecidable", term.strip() or "" + cleaned = term.replace("(", " ").replace(")", " ") if re.search(r"\sAND\s", cleaned): - return None - operands = [part.strip() for part in re.split(r"\sOR\s", cleaned)] - if len(operands) < 2: - return None - for operand in operands: - if operand and not _COPYLEFT_PATTERN.search(operand): - return operand - return None + operands = [part.strip() for part in re.split(r"\sAND\s", cleaned) if part.strip()] + if any(_is_undecidable(operand) for operand in operands): + return "undecidable", term + if any(_COPYLEFT_PATTERN.search(operand) for operand in operands): + return "copyleft", term + return "permitted", term + operands = [part.strip() for part in re.split(r"\sOR\s", cleaned) if part.strip()] + if len(operands) > 1: + if any(_is_undecidable(operand) for operand in operands): + return "undecidable", term + choice = next((operand for operand in operands if not _COPYLEFT_PATTERN.search(operand)), None) + return ("permitted", choice) if choice is not None else ("copyleft", term) + return ("copyleft", term) if _COPYLEFT_PATTERN.search(term) else ("permitted", term) + + +def _walk_components(container: Any, path: str) -> list[dict[str, Any]]: + """Flatten the component tree; a nested dependency ships just as much.""" + components = container.get("components") + if components is None: + return [] + if not isinstance(components, list): + raise SbomSchemaError(f"{path} has a non-list components field") + flattened: list[dict[str, Any]] = [] + for index, component in enumerate(components): + if not isinstance(component, dict): + raise SbomSchemaError(f"{path}.components[{index}] is not an object") + flattened.append(component) + flattened.extend(_walk_components(component, f"{path}.components[{index}]")) + return flattened def classify_sbom_components(sbom: dict[str, Any]) -> dict[str, list[dict[str, str]]]: - """Split every SBOM component into permitted, copyleft and unknown groups.""" + """Split every component, nested ones included, into the three groups.""" permitted: list[dict[str, str]] = [] copyleft: list[dict[str, str]] = [] - unknown: list[dict[str, str]] = [] - for component in sbom.get("components") or []: - if not isinstance(component, dict): - continue + undecidable: list[dict[str, str]] = [] + for component in _walk_components(sbom, "sbom"): name = str(component.get("name") or "") version = str(component.get("version") or "") terms = _license_terms(component) - usable = [term for term in terms if term.strip().lower() not in _UNKNOWN_VALUES] - row = {"name": name, "version": version, "license": "; ".join(usable)} - if not usable: - unknown.append(row) - continue - copyleft_terms = [term for term in usable if _COPYLEFT_PATTERN.search(term)] - if not copyleft_terms: - permitted.append(row) - continue - choice = next( - (taken for term in copyleft_terms for taken in (_permissive_choice(term),) if taken), - None, - ) - if choice is not None: - permitted.append({**row, "license": f"{row['license']} (permissive option taken: {choice})"}) + if not terms: + undecidable.append({"name": name, "version": version, "license": ""}) continue - copyleft.append(row) - return {"permitted": permitted, "copyleft": copyleft, "unknown": unknown} + verdicts = [classify_license_term(term) for term in terms] + row = {"name": name, "version": version, "license": "; ".join(terms)} + if any(verdict == "undecidable" for verdict, _ in verdicts): + undecidable.append(row) + elif any(verdict == "copyleft" for verdict, _ in verdicts): + copyleft.append(row) + else: + permitted.append({**row, "license": "; ".join(reason for _, reason in verdicts)}) + return {"permitted": permitted, "copyleft": copyleft, "undecidable": undecidable} + + +def _normalize(name: str) -> str: + return _NAME_SEPARATORS.sub("-", name.strip().lower()) + +def _declared_requirements(pyproject: dict[str, Any]) -> set[str]: + """Every distribution this project declares, across all scopes.""" + project = pyproject.get("project") or {} + sources: list[Any] = [project.get("dependencies") or []] + sources.extend((project.get("optional-dependencies") or {}).values()) + sources.extend((pyproject.get("dependency-groups") or {}).values()) + declared: set[str] = set() + for requirements in sources: + for requirement in requirements or []: + if not isinstance(requirement, str): + continue + match = _REQUIREMENT_NAME.match(requirement) + if match: + declared.add(_normalize(match.group(0))) + return declared -def _render(groups: dict[str, list[dict[str, str]]]) -> str: + +def scope_coverage_findings( + sbom: dict[str, Any], pyproject_path: str | None, repository_root: str | None +) -> list[str]: + """Report declared dependency scopes the SBOM does not demonstrably cover.""" + findings: list[str] = [] + components = _walk_components(sbom, "sbom") + present = {_normalize(str(component.get("name") or "")) for component in components} + if pyproject_path: + with open(pyproject_path, "rb") as handle: + pyproject = tomllib.load(handle) + missing = sorted(_declared_requirements(pyproject) - present) + if missing: + findings.append( + f"declared Python distributions absent from the SBOM ({len(missing)}): {', '.join(missing)}" + ) + if repository_root: + root = Path(repository_root) + purls = " ".join(str(component.get("purl") or "") for component in components) + for ecosystem, manifest, purl_prefix in ( + ("cargo", root / "rust" / "Cargo.toml", "pkg:cargo/"), + ("npm", root / "package.json", "pkg:npm/"), + ): + if manifest.exists() and purl_prefix not in purls: + findings.append( + f"{manifest} declares {ecosystem} dependencies that ship with the artifact, but the " + f"SBOM contains no {purl_prefix} component" + ) + return findings + + +def _render(groups: dict[str, list[dict[str, str]]], coverage: list[str]) -> str: lines = [ - f"permitted={len(groups['permitted'])} " - f"copyleft={len(groups['copyleft'])} unknown={len(groups['unknown'])}" + f"permitted={len(groups['permitted'])} copyleft={len(groups['copyleft'])} " + f"undecidable={len(groups['undecidable'])} coverage_findings={len(coverage)}" ] - for label in ("copyleft", "unknown"): + for label in ("copyleft", "undecidable"): for row in groups[label]: - lines.append(f"{label.upper()} {row['name']}=={row['version']} license={row['license'] or ''}") + lines.append(f"{label.upper()} {row['name']}=={row['version']} license={row['license']}") + lines.extend(f"COVERAGE {finding}" for finding in coverage) return "\n".join(lines) def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) + parser = argparse.ArgumentParser(description="Fail-closed release licence and SBOM-coverage gate.") parser.add_argument("--sbom", required=True, help="path to cyclonedx-sbom.json for the exact commit") + parser.add_argument("--pyproject", help="pyproject.toml whose declared scopes the SBOM must cover") + parser.add_argument("--repository-root", help="repository root, to check non-Python manifests") arguments = parser.parse_args(argv) try: with open(arguments.sbom, encoding="utf-8") as handle: sbom = json.load(handle) except (OSError, json.JSONDecodeError) as error: - print(f"::error::Release licence gate could not read the CycloneDX SBOM at {arguments.sbom}: {error}", file=sys.stderr) + print(f"::error::Release licence gate could not read the CycloneDX SBOM at {arguments.sbom}: {error}", + file=sys.stderr) + return 1 + if not isinstance(sbom, dict): + print("::error::CycloneDX SBOM is not a JSON object; refusing to release.", file=sys.stderr) + return 1 + try: + groups = classify_sbom_components(sbom) + coverage = scope_coverage_findings(sbom, arguments.pyproject, arguments.repository_root) + except SbomSchemaError as error: + print(f"::error::CycloneDX SBOM is malformed and cannot be adjudicated ({error}); refusing to release.", + file=sys.stderr) + return 1 + except (OSError, tomllib.TOMLDecodeError) as error: + print(f"::error::Release licence gate could not read the declared dependency scopes ({error}).", + file=sys.stderr) return 1 - if not isinstance(sbom, dict) or not sbom.get("components"): + if not any(groups.values()): print( "::error::CycloneDX SBOM lists no components; an empty component set is not evidence that the " "artifact is licence-clean. Refusing to release.", file=sys.stderr, ) return 1 - groups = classify_sbom_components(sbom) - print(_render(groups)) - if groups["copyleft"] or groups["unknown"]: + print(_render(groups, coverage)) + if groups["copyleft"] or groups["undecidable"] or coverage: print( "::error::Release licence gate failed: " - f"{len(groups['copyleft'])} GPL-family and {len(groups['unknown'])} unknown-licence component(s) " - "are present in the artifact SBOM. Replace them with permissively licensed alternatives; being " - "optional or unexecuted is not an exemption, and this gate is never waived to publish.", + f"{len(groups['copyleft'])} GPL-family, {len(groups['undecidable'])} undecidable-licence " + f"component(s) and {len(coverage)} dependency-scope coverage gap(s). Replace copyleft components " + "with permissively licensed alternatives and extend SBOM collection until every declared scope is " + "covered; optional or unexecuted scope is not an exemption, an undecidable licence is never read " + "as permission, and this gate is not waived to publish.", file=sys.stderr, ) return 1 diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index b2d8cec86..4224fd53e 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -63,7 +63,7 @@ def test_unknown_license_blocks_the_release(tmp_path) -> None: groups = classify_sbom_components(sbom) - assert {row["name"] for row in groups["unknown"]} == {"undeclared_library", "placeholder_library"} + assert {row["name"] for row in groups["undecidable"]} == {"undeclared_library", "placeholder_library"} assert main(["--sbom", _write(tmp_path, sbom)]) == 1 @@ -103,3 +103,90 @@ def test_release_workflow_runs_the_gate_before_publishing() -> None: ) publish_block = text[text.index("\n publish:") :] assert "needs: verify" in publish_block + + +# --- negative regressions for the fail-open cases found in independent review --- + + +@pytest.mark.parametrize( + "component", + [ + pytest.param(_component("licenseref_library", "1.0", license_id="LicenseRef-Unreviewed"), id="license_ref"), + pytest.param(_component("free_text_library", "1.0", license_id="GPLv3"), id="free_text_gplv3"), + pytest.param(_component("or_unknown_library", "1.0", expression="GPL-3.0-only OR UNKNOWN"), id="or_unknown"), + pytest.param( + _component( + "conjunctive_entries_library", + "1.0", + licenses=[{"expression": "MIT OR GPL-2.0-only"}, {"license": {"id": "AGPL-3.0-only"}}], + ), + id="separate_entries_are_conjunctive", + ), + ], +) +def test_fail_open_cases_are_refused(tmp_path, component) -> None: + """Each of these passed an earlier gate revision; none may pass again.""" + assert main(["--sbom", _write(tmp_path, _sbom(component))]) == 1 + + +def test_malformed_component_entry_fails_closed(tmp_path) -> None: + """A null component means the SBOM cannot be read, which is not a pass.""" + sbom = {"bomFormat": "CycloneDX", "specVersion": "1.5", "components": [None]} + + assert main(["--sbom", _write(tmp_path, sbom)]) == 1 + + +def test_nested_component_is_classified(tmp_path) -> None: + """A GPL dependency nested under a permitted parent still blocks.""" + parent = _component("permitted_parent_library", "1.0", license_id="MIT") + parent["components"] = [_component("nested_copyleft_library", "2.0", license_id="GPL-3.0-only")] + + groups = classify_sbom_components(_sbom(parent)) + + assert [row["name"] for row in groups["copyleft"]] == ["nested_copyleft_library"] + assert main(["--sbom", _write(tmp_path, _sbom(parent))]) == 1 + + +def test_permissive_zero_clause_licence_still_passes(tmp_path) -> None: + """MIT-0 must not be mistaken for a copyleft identifier by the matcher.""" + sbom = _sbom(_component("zero_clause_library", "1.0", license_id="MIT-0")) + + assert main(["--sbom", _write(tmp_path, sbom)]) == 0 + + +def test_declared_dependency_absent_from_the_sbom_fails_closed(tmp_path) -> None: + """A partial SBOM is not evidence about the scopes it never collected.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "x"\nversion = "0.0.1"\ndependencies = ["collected_library>=1"]\n' + '[project.optional-dependencies]\ndb = ["absent_library>=2"]\n', + encoding="utf-8", + ) + sbom = _sbom(_component("collected_library", "1.0", license_id="MIT")) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(pyproject)]) == 1 + + +def test_full_declared_coverage_passes(tmp_path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "x"\nversion = "0.0.1"\ndependencies = ["Collected.Library>=1"]\n' + '[dependency-groups]\ndev = ["grouped_library"]\n', + encoding="utf-8", + ) + sbom = _sbom(_component("collected-library", "1.0", license_id="MIT"), + _component("grouped_library", "2.0", license_id="BSD-3-Clause")) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(pyproject)]) == 0 + + +def test_native_manifest_without_sbom_coverage_fails_closed(tmp_path) -> None: + """Rust and npm manifests ship in the image, so the SBOM must cover them.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\nversion = "0.0.1"\ndependencies = []\n', encoding="utf-8") + (tmp_path / "rust").mkdir() + (tmp_path / "rust" / "Cargo.toml").write_text("[workspace]\n", encoding="utf-8") + sbom = _sbom(_component("python_only_library", "1.0", license_id="MIT")) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(pyproject), + "--repository-root", str(tmp_path)]) == 1 From 2c30864ca224b55e518ea4977fa52434e9297e0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 21:53:08 +0900 Subject: [PATCH 03/23] fix(release): prove SBOM coverage by lockfile set comparison, not by sampling Independent review reproduced a false pass: an SBOM holding pyproject's direct names plus a single pkg:cargo/ and a single pkg:npm/ purl satisfied the coverage check, even though the rest of the Rust workspace and the npm tree were absent. Existence of one component per ecosystem was never evidence of coverage. Each shipped ecosystem is now compared against its own lockfile, which is the resolved transitive closure: uv.lock, rust/Cargo.lock and package-lock.json. Every name==version pair in the lockfile must appear in the SBOM under the matching purl, and a manifest whose lockfile is missing or unreadable is a finding of its own, because an unprovable scope is not a covered one. The reviewer's exact repro is now a RED-to-GREEN regression, alongside a complete-set positive case and a missing-lockfile case. Against a realistic environment SBOM this repository currently reports 2 GPL-family components, 1 undecidable licence and 4 coverage gaps, including all 43 Cargo packages -- which is the honest state, and the reason releases stay blocked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- docs/RELEASING.md | 16 +++-- scripts/ci/release_license_gate.py | 110 ++++++++++++++++++++++++----- tests/test_release_license_gate.py | 104 ++++++++++++++++++++++++--- 3 files changed, 200 insertions(+), 30 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 892a5425e..24e31585f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -62,11 +62,17 @@ LifeOS and other consumers must verify their specific owner contracts too. and nested components are adjudicated like any other. A malformed SBOM, or one with no components, is refused rather than read as clean. - The same gate also proves *coverage*: every distribution declared in - `pyproject.toml` -- runtime, every optional extra and every dependency group - -- must appear in the SBOM, and a shipped non-Python manifest (`rust/Cargo.toml`, - `package.json`) must have matching `pkg:cargo/` or `pkg:npm/` components. A - partial SBOM is silence about the scopes it never collected, not evidence. + The same gate also proves *coverage*, by set comparison rather than by + spot-checking. For each shipped ecosystem it reads that ecosystem's own + lockfile -- `uv.lock`, `rust/Cargo.lock`, `package-lock.json` -- which is the + resolved transitive closure, and requires every `name==version` pair in it to + be present in the SBOM under the matching `pkg:pypi/`, `pkg:cargo/` or + `pkg:npm/` purl. Every distribution declared in `pyproject.toml` (runtime, + each optional extra, each dependency group) must appear as well. One + component per ecosystem proves nothing and no longer passes. A manifest whose + lockfile is missing or unreadable is also a finding: an unprovable scope is + not a covered one. A partial SBOM is silence about the scopes it never + collected, not evidence. Known gap, which keeps releases blocked until it is closed: `security.yml` builds the SBOM with `cyclonedx-py environment` in an Ubuntu/Python 3.12 diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index dcf6cb7f6..ea00e774d 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -169,33 +169,109 @@ def _declared_requirements(pyproject: dict[str, Any]) -> set[str]: return declared +def _lock_packages_from_toml(path: Path, key: str) -> set[tuple[str, str]]: + """Read ``[[package]]`` name/version pairs from a Cargo or uv lockfile.""" + with open(path, "rb") as handle: + document = tomllib.load(handle) + entries = document.get(key) or [] + return { + (_normalize(str(entry.get("name", ""))), str(entry.get("version", ""))) + for entry in entries + if isinstance(entry, dict) and entry.get("name") + } + + +def _lock_packages_from_npm(path: Path) -> set[tuple[str, str]]: + """Read installed package name/version pairs from an npm lockfile.""" + with open(path, encoding="utf-8") as handle: + document = json.load(handle) + packages: set[tuple[str, str]] = set() + for location, entry in (document.get("packages") or {}).items(): + if not location or not isinstance(entry, dict): + continue # the "" entry is the project itself, not a dependency + name = entry.get("name") or location.split("node_modules/", 1)[-1] + packages.add((_normalize(str(name)), str(entry.get("version", "")))) + return packages + + +def _sbom_packages(components: list[dict[str, Any]], purl_prefix: str) -> set[tuple[str, str]]: + """Name/version pairs the SBOM actually carries for one ecosystem.""" + packages: set[tuple[str, str]] = set() + for component in components: + purl = str(component.get("purl") or "") + if not purl.startswith(purl_prefix): + continue + packages.add((_normalize(str(component.get("name") or "")), str(component.get("version") or ""))) + return packages + + +def _missing_report(ecosystem: str, lock: Path, missing: set[tuple[str, str]], expected: int) -> str: + sample = ", ".join(f"{name}=={version}" for name, version in sorted(missing)[:10]) + suffix = ", ..." if len(missing) > 10 else "" + return ( + f"{ecosystem}: {len(missing)} of {expected} package(s) resolved in {lock} are absent from the " + f"SBOM ({sample}{suffix})" + ) + + def scope_coverage_findings( sbom: dict[str, Any], pyproject_path: str | None, repository_root: str | None ) -> list[str]: - """Report declared dependency scopes the SBOM does not demonstrably cover.""" + """Prove the SBOM covers each ecosystem's whole resolved dependency set. + + Existence of one component per ecosystem proves nothing, so every finding + here is a set comparison against that ecosystem's own lockfile, which is + the resolved transitive closure. A manifest with no readable lockfile is a + finding too: an unprovable scope is not a covered one. + """ findings: list[str] = [] components = _walk_components(sbom, "sbom") - present = {_normalize(str(component.get("name") or "")) for component in components} + present_names = {_normalize(str(component.get("name") or "")) for component in components} + project_name = "" if pyproject_path: with open(pyproject_path, "rb") as handle: pyproject = tomllib.load(handle) - missing = sorted(_declared_requirements(pyproject) - present) - if missing: + project_name = _normalize(str((pyproject.get("project") or {}).get("name") or "")) + missing_declared = sorted(_declared_requirements(pyproject) - present_names) + if missing_declared: findings.append( - f"declared Python distributions absent from the SBOM ({len(missing)}): {', '.join(missing)}" + "declared Python distributions absent from the SBOM " + f"({len(missing_declared)}): {', '.join(missing_declared)}" ) - if repository_root: - root = Path(repository_root) - purls = " ".join(str(component.get("purl") or "") for component in components) - for ecosystem, manifest, purl_prefix in ( - ("cargo", root / "rust" / "Cargo.toml", "pkg:cargo/"), - ("npm", root / "package.json", "pkg:npm/"), - ): - if manifest.exists() and purl_prefix not in purls: - findings.append( - f"{manifest} declares {ecosystem} dependencies that ship with the artifact, but the " - f"SBOM contains no {purl_prefix} component" - ) + if not repository_root: + return findings + root = Path(repository_root) + ecosystems: tuple[tuple[str, Path, tuple[Path, ...], str, str], ...] = ( + ("python", root / "pyproject.toml", (root / "uv.lock",), "pkg:pypi/", "package"), + ("cargo", root / "rust" / "Cargo.toml", (root / "rust" / "Cargo.lock",), "pkg:cargo/", "package"), + ("npm", root / "package.json", (root / "package-lock.json",), "pkg:npm/", ""), + ) + for ecosystem, manifest, lock_candidates, purl_prefix, toml_key in ecosystems: + if not manifest.exists(): + continue + lock = next((candidate for candidate in lock_candidates if candidate.exists()), None) + if lock is None: + findings.append( + f"{manifest} ships with the artifact but no lockfile " + f"({', '.join(str(candidate) for candidate in lock_candidates)}) is available, so its " + "resolved dependency set cannot be proven" + ) + continue + try: + expected = ( + _lock_packages_from_npm(lock) if ecosystem == "npm" + else _lock_packages_from_toml(lock, toml_key) + ) + except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: + findings.append(f"{ecosystem}: lockfile {lock} could not be read ({error})") + continue + expected = {pair for pair in expected if pair[0] and pair[0] != project_name} + if not expected: + continue + present = _sbom_packages(components, purl_prefix) + missing = {pair for pair in expected if pair not in present} + if missing: + findings.append(_missing_report(ecosystem, lock, missing, len(expected))) return findings diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 4224fd53e..6aa9d89c6 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -17,7 +17,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from scripts.ci.release_license_gate import classify_sbom_components, main # noqa: E402 +from scripts.ci.release_license_gate import ( # noqa: E402 + classify_sbom_components, + main, + scope_coverage_findings, +) def _sbom(*components: dict) -> dict: @@ -180,13 +184,97 @@ def test_full_declared_coverage_passes(tmp_path) -> None: assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(pyproject)]) == 0 +def _repository(tmp_path: Path) -> Path: + """A miniature repository carrying one lockfile per shipped ecosystem.""" + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "gate_fixture_project"\nversion = "0.0.1"\ndependencies = ["direct_library>=1"]\n', + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + '[[package]]\nname = "direct_library"\nversion = "1.0"\n\n' + '[[package]]\nname = "transitive_library"\nversion = "2.0"\n', + encoding="utf-8", + ) + (tmp_path / "rust").mkdir(exist_ok=True) + (tmp_path / "rust" / "Cargo.toml").write_text("[workspace]\n", encoding="utf-8") + (tmp_path / "rust" / "Cargo.lock").write_text( + '[[package]]\nname = "one_cargo_crate"\nversion = "1.1.5"\n\n' + '[[package]]\nname = "other_cargo_crate"\nversion = "0.3.0"\n', + encoding="utf-8", + ) + (tmp_path / "package.json").write_text('{"name": "gate_fixture_project"}', encoding="utf-8") + (tmp_path / "package-lock.json").write_text( + json.dumps({"lockfileVersion": 3, "packages": { + "": {"name": "gate_fixture_project"}, + "node_modules/one_npm_package": {"version": "1.0.0"}, + "node_modules/other_npm_package": {"version": "4.2.0"}, + }}), + encoding="utf-8", + ) + return tmp_path + + +def _purl_component(name: str, version: str, purl: str) -> dict: + component = _component(name, version, license_id="MIT") + component["purl"] = purl + return component + + def test_native_manifest_without_sbom_coverage_fails_closed(tmp_path) -> None: """Rust and npm manifests ship in the image, so the SBOM must cover them.""" - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "x"\nversion = "0.0.1"\ndependencies = []\n', encoding="utf-8") - (tmp_path / "rust").mkdir() - (tmp_path / "rust" / "Cargo.toml").write_text("[workspace]\n", encoding="utf-8") - sbom = _sbom(_component("python_only_library", "1.0", license_id="MIT")) + repository = _repository(tmp_path) + sbom = _sbom(_purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0")) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository)]) == 1 + + +def test_one_component_per_ecosystem_does_not_prove_coverage(tmp_path) -> None: + """The reviewer's repro: every direct name plus one purl per ecosystem passed before.""" + repository = _repository(tmp_path) + sbom = _sbom( + _purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0"), + _purl_component("one_cargo_crate", "1.1.5", "pkg:cargo/one_cargo_crate@1.1.5"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/one_npm_package@1.0.0"), + ) + + findings = scope_coverage_findings(json.loads(json.dumps(sbom)), + str(repository / "pyproject.toml"), str(repository)) + + assert [finding.split(":")[0] for finding in findings] == ["cargo", "npm"] + assert "other-cargo-crate==0.3.0" in findings[0] + assert "other-npm-package==4.2.0" in findings[1] + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository)]) == 1 + + +def test_complete_ecosystem_sets_pass(tmp_path) -> None: + repository = _repository(tmp_path) + sbom = _sbom( + _purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0"), + _purl_component("one_cargo_crate", "1.1.5", "pkg:cargo/one_cargo_crate@1.1.5"), + _purl_component("other_cargo_crate", "0.3.0", "pkg:cargo/other_cargo_crate@0.3.0"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/one_npm_package@1.0.0"), + _purl_component("other_npm_package", "4.2.0", "pkg:npm/other_npm_package@4.2.0"), + ) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository)]) == 0 + + +def test_missing_lockfile_is_an_unprovable_scope(tmp_path) -> None: + repository = _repository(tmp_path) + (repository / "rust" / "Cargo.lock").unlink() + sbom = _sbom(_purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/one_npm_package@1.0.0"), + _purl_component("other_npm_package", "4.2.0", "pkg:npm/other_npm_package@4.2.0")) + + findings = scope_coverage_findings(sbom, str(repository / "pyproject.toml"), str(repository)) - assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(pyproject), - "--repository-root", str(tmp_path)]) == 1 + assert any("cannot be proven" in finding for finding in findings) + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository)]) == 1 From 3ff37c4b28d8dc007eea6e1eccf3d1da4797d509 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 23 Sep 2026 22:17:49 +0900 Subject: [PATCH 04/23] fix(release): refuse empty lock sets and purl identities that contradict their component Independent review found two further false passes at 2c30864c: - A lockfile that parses but resolves no packages hit a bare `continue`, so an empty uv/Cargo/npm lock next to a shipped manifest passed. An empty resolved set is silence, not a clean bill, so it is now a finding. - Coverage read the ecosystem from the purl but the name and version from the component's own fields, so rewriting every purl to unrelated@999 still passed while the fields happened to match the lock. Coverage is now read from the purl itself, and a purl that contradicts its component's name/version fields is reported rather than quietly resolved in favour of whichever side matches. Both reproducers are regressions. Against the reviewer's synthetic matrix the outcomes are now complete=0, missing_transitive=1, empty_locks=1, unrelated_purls=1, invalid_toml=1; the earlier six fail-open licence cases keep passing in the same file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- scripts/ci/release_license_gate.py | 44 ++++++++++++++++++++++++++---- tests/test_release_license_gate.py | 35 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index ea00e774d..748d0d9ab 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -37,6 +37,7 @@ import tomllib from pathlib import Path from typing import Any +from urllib.parse import unquote # GPL/LGPL/AGPL in any spelling, including ``GPLv3``. No trailing separator is # required: free-text spellings run the version straight onto the family name. @@ -194,15 +195,36 @@ def _lock_packages_from_npm(path: Path) -> set[tuple[str, str]]: return packages -def _sbom_packages(components: list[dict[str, Any]], purl_prefix: str) -> set[tuple[str, str]]: - """Name/version pairs the SBOM actually carries for one ecosystem.""" +def _parse_purl(purl: str, purl_prefix: str) -> tuple[str, str]: + """Read the name and version the purl itself asserts, ignoring the fields.""" + remainder = purl[len(purl_prefix):].split("?", 1)[0].split("#", 1)[0] + name, separator, version = remainder.rpartition("@") + if not separator: # no version segment at all + name, version = remainder, "" + return _normalize(unquote(name)), unquote(version) + + +def _sbom_packages(components: list[dict[str, Any]], purl_prefix: str) -> tuple[set[tuple[str, str]], list[str]]: + """Return one ecosystem's purl-asserted pairs, plus any identity mismatches. + + Coverage is decided from the purl, since that is the package coordinate. + A purl that disagrees with its own component's ``name``/``version`` fields + makes the component's identity unreliable, so the disagreement is reported + rather than quietly resolved in favour of whichever side matches the lock. + """ packages: set[tuple[str, str]] = set() + mismatches: list[str] = [] for component in components: purl = str(component.get("purl") or "") if not purl.startswith(purl_prefix): continue - packages.add((_normalize(str(component.get("name") or "")), str(component.get("version") or ""))) - return packages + purl_name, purl_version = _parse_purl(purl, purl_prefix) + field_name = _normalize(str(component.get("name") or "")) + field_version = str(component.get("version") or "") + packages.add((purl_name, purl_version)) + if (purl_name, purl_version) != (field_name, field_version): + mismatches.append(f"{field_name}=={field_version} carries purl {purl}") + return packages, mismatches def _missing_report(ecosystem: str, lock: Path, missing: set[tuple[str, str]], expected: int) -> str: @@ -266,9 +288,21 @@ def scope_coverage_findings( findings.append(f"{ecosystem}: lockfile {lock} could not be read ({error})") continue expected = {pair for pair in expected if pair[0] and pair[0] != project_name} + present, mismatches = _sbom_packages(components, purl_prefix) + if mismatches: + findings.append( + f"{ecosystem}: {len(mismatches)} component(s) whose purl identity contradicts their own " + f"name/version fields ({'; '.join(sorted(mismatches)[:5])})" + ) if not expected: + # A shipped manifest whose lockfile resolves nothing proves nothing + # either: the structure parsed, but it carries no dependency set to + # compare against, which is silence rather than a clean bill. + findings.append( + f"{ecosystem}: {lock} resolves no packages for shipped manifest {manifest}, so its " + "dependency set is unprovable rather than empty" + ) continue - present = _sbom_packages(components, purl_prefix) missing = {pair for pair in expected if pair not in present} if missing: findings.append(_missing_report(ecosystem, lock, missing, len(expected))) diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 6aa9d89c6..48e2b61d5 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -278,3 +278,38 @@ def test_missing_lockfile_is_an_unprovable_scope(tmp_path) -> None: assert any("cannot be proven" in finding for finding in findings) assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), "--repository-root", str(repository)]) == 1 + + +def test_empty_lock_set_is_not_proof_of_no_dependencies(tmp_path) -> None: + """A lockfile that resolves nothing while its manifest declares work is unprovable.""" + repository = _repository(tmp_path) + (repository / "uv.lock").write_text("version = 1\n", encoding="utf-8") + (repository / "rust" / "Cargo.lock").write_text("version = 4\n", encoding="utf-8") + (repository / "package-lock.json").write_text(json.dumps({"packages": {}}), encoding="utf-8") + (repository / "package.json").write_text('{"dependencies": {"one_npm_package": "1.0.0"}}', encoding="utf-8") + sbom = _sbom(_purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0")) + + findings = scope_coverage_findings(sbom, str(repository / "pyproject.toml"), str(repository)) + + assert {finding.split(":")[0] for finding in findings} >= {"python", "cargo", "npm"} + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository)]) == 1 + + +def test_purl_identity_must_match_the_component_fields(tmp_path) -> None: + """Coverage is read from the purl, so a purl naming something else is a mismatch.""" + repository = _repository(tmp_path) + sbom = _sbom( + _purl_component("direct_library", "1.0", "pkg:pypi/unrelated@999"), + _purl_component("transitive_library", "2.0", "pkg:pypi/unrelated@999"), + _purl_component("one_cargo_crate", "1.1.5", "pkg:cargo/unrelated@999"), + _purl_component("other_cargo_crate", "0.3.0", "pkg:cargo/unrelated@999"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/unrelated@999"), + _purl_component("other_npm_package", "4.2.0", "pkg:npm/unrelated@999"), + ) + + findings = scope_coverage_findings(sbom, str(repository / "pyproject.toml"), str(repository)) + + assert any("purl" in finding for finding in findings) + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository)]) == 1 From a6c53b413bbca7aa382bcc1666eefbb661e3c855 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 00:00:39 +0900 Subject: [PATCH 05/23] fix(packaging): declare the MIT licence in the distribution metadata The repository ships a MIT LICENSE file but [project] declared no licence, so the built distribution carried none either. Every downstream SBOM therefore recorded this package with an empty licence, and the release licence gate classified its own project as undecidable -- correctly, since undeclared is not permission. Declare the SPDX expression and the file that backs it (PEP 639), and pin the build backend explicitly, since setuptools only emits a licence expression from version 77. Nothing is renamed or reinterpreted: the expression states what the LICENSE file has always said. The regression generates metadata through the declared backend's prepare_metadata_for_build_wheel hook, which compiles nothing, and asserts License-Expression: MIT with LICENSE recorded as its licence file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- pyproject.toml | 11 ++++ tests/test_distribution_license_metadata.py | 68 +++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/test_distribution_license_metadata.py diff --git a/pyproject.toml b/pyproject.toml index ac89e486b..a82a09414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,19 @@ +[build-system] +# Declared explicitly so the PEP 639 licence metadata above is guaranteed to +# be emitted: setuptools only understands the SPDX expression from 77. +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + [project] name = "contextual-orchestrator" version = "0.2.0" description = "Paper-grounded model orchestration lab with an enterprise admin console." readme = "README.md" +# PEP 639: the SPDX expression and the licence file that backs it, so the +# built distribution declares the licence the repository actually carries +# instead of leaving it undeclared for every downstream SBOM to guess. +license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.12" dependencies = [ "cryptography>=43.0", diff --git a/tests/test_distribution_license_metadata.py b/tests/test_distribution_license_metadata.py new file mode 100644 index 000000000..132d0bc3c --- /dev/null +++ b/tests/test_distribution_license_metadata.py @@ -0,0 +1,68 @@ +"""The built distribution must declare the licence the repository carries. + +A release SBOM can only report what the distribution's own metadata says. This +project shipped a MIT `LICENSE` file while declaring no licence at all, so its +own component appeared in the CycloneDX SBOM with no licence and the release +licence gate classified it as undecidable -- correctly, since undeclared is not +permission. These tests pin the metadata rather than the gate's reaction to it. + +Metadata is generated through the declared build backend's +`prepare_metadata_for_build_wheel` hook, which writes a `.dist-info` directory +without compiling anything, so this stays a metadata check and not a build. +""" + +from __future__ import annotations + +import email +import sys +import tomllib +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def _pyproject() -> dict: + with open(REPOSITORY_ROOT / "pyproject.toml", "rb") as handle: + return tomllib.load(handle) + + +def test_license_declaration_matches_the_repository_license_file() -> None: + project = _pyproject()["project"] + license_text = (REPOSITORY_ROOT / "LICENSE").read_text(encoding="utf-8") + + assert project["license"] == "MIT" + assert project["license-files"] == ["LICENSE"] + assert license_text.startswith("MIT License") + + +def test_build_backend_floor_supports_the_license_expression() -> None: + """PEP 639 metadata is only emitted from setuptools 77 onwards.""" + build_system = _pyproject()["build-system"] + + assert build_system["build-backend"] == "setuptools.build_meta" + assert any(requirement.replace(" ", "") == "setuptools>=77" for requirement in build_system["requires"]) + + +def test_generated_metadata_declares_the_license(tmp_path) -> None: + setuptools = pytest.importorskip("setuptools") + if tuple(int(part) for part in setuptools.__version__.split(".")[:1]) < (77,): + pytest.skip(f"setuptools {setuptools.__version__} predates PEP 639 support") + from setuptools import build_meta + + previous = Path.cwd() + sys.path.insert(0, str(REPOSITORY_ROOT)) + try: + import os + + os.chdir(REPOSITORY_ROOT) + dist_info = build_meta.prepare_metadata_for_build_wheel(str(tmp_path)) + finally: + os.chdir(previous) + sys.path.remove(str(REPOSITORY_ROOT)) + + metadata = email.message_from_string((tmp_path / dist_info / "METADATA").read_text(encoding="utf-8")) + + assert metadata.get("License-Expression") == "MIT" + assert "LICENSE" in " ".join(metadata.get_all("License-File") or []) From 45f69d1f5ae119c4e6b228cbeef6fad54323dc52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 00:17:45 +0900 Subject: [PATCH 06/23] test(packaging): fail instead of skipping when the backend cannot emit the licence A skipped licence check reported as a pass is the hole this file exists to close, so an environment whose setuptools predates PEP 639 now fails the assertion rather than skipping it. The LICENSE assertion's limit is recorded too: it matches the MIT header, so it catches a file swapped for a different licence, not a doctored clause inside an otherwise MIT-looking text. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- tests/test_distribution_license_metadata.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/test_distribution_license_metadata.py b/tests/test_distribution_license_metadata.py index 132d0bc3c..86d869306 100644 --- a/tests/test_distribution_license_metadata.py +++ b/tests/test_distribution_license_metadata.py @@ -9,6 +9,13 @@ Metadata is generated through the declared build backend's `prepare_metadata_for_build_wheel` hook, which writes a `.dist-info` directory without compiling anything, so this stays a metadata check and not a build. + +Two limits are deliberate and recorded rather than papered over. The backend +check fails instead of skipping when setuptools predates PEP 639, because a +skipped licence check reported as a pass is exactly the hole this file exists +to close. And the `LICENSE` assertion matches the MIT header, not the full +text: it catches a file replaced by a different licence, not a doctored clause +inside an otherwise MIT-looking file. """ from __future__ import annotations @@ -18,8 +25,6 @@ import tomllib from pathlib import Path -import pytest - REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -46,11 +51,16 @@ def test_build_backend_floor_supports_the_license_expression() -> None: def test_generated_metadata_declares_the_license(tmp_path) -> None: - setuptools = pytest.importorskip("setuptools") - if tuple(int(part) for part in setuptools.__version__.split(".")[:1]) < (77,): - pytest.skip(f"setuptools {setuptools.__version__} predates PEP 639 support") + import setuptools # a build backend this project declares, never optional here from setuptools import build_meta + major = int(setuptools.__version__.split(".")[0]) + assert major >= 77, ( + f"setuptools {setuptools.__version__} predates PEP 639, so this environment cannot emit the " + "licence expression. Failing rather than skipping: a skipped licence check must never be " + "counted as a passing one." + ) + previous = Path.cwd() sys.path.insert(0, str(REPOSITORY_ROOT)) try: From 485e5c44b68122deb18f7db5608d102fe7272123 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 00:34:30 +0900 Subject: [PATCH 07/23] feat(security): publish a lockfile-resolved dependency inventory beside the SBOM The CycloneDX SBOM is generated from one installed environment, so it sees whatever CI tooling is present and misses the dev, fuzz and native-build groups, the Rust workspace and the npm tree entirely. Scoping the release SBOM to the shipped artefact does not discharge licence adjudication: the excluded classes still have to be judged. Enumerate them from the lockfiles themselves -- uv.lock, rust/Cargo.lock, package-lock.json -- so the inventory is the resolved closure rather than a sample, and publish it in the same artefact as the SBOM, carrying the commit it describes so the two can be bound. An absent lockfile is reported as an unprovable scope and fails; it does not silently shrink the inventory. Nothing is installed, resolved or fetched to build it: it is stdlib TOML and JSON reading over files already in the tree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/security.yml | 15 +++- scripts/ci/dependency_inventory.py | 119 +++++++++++++++++++++++++++++ tests/test_dependency_inventory.py | 59 ++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 scripts/ci/dependency_inventory.py create mode 100644 tests/test_dependency_inventory.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f4cccc8cd..9247b232d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -236,11 +236,24 @@ jobs: python -m pip_audit -r requirements.lock cyclonedx-py environment --output-format json --output-file cyclonedx-sbom.json + - name: Enumerate every declared dependency scope from the lockfiles + run: | + set -euo pipefail + # The SBOM above describes one installed environment, so it cannot see + # the dev, fuzz and native-build groups, the Rust workspace or the npm + # tree, while it does see whatever CI tooling happens to be installed. + # Scoping the SBOM down does not discharge licence adjudication, so the + # excluded classes are enumerated here from the lockfiles and published + # beside it, bound to the same commit. + python -m scripts.ci.dependency_inventory --repository-root . --output dependency-inventory.json + - name: Upload CycloneDX SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 with: name: cyclonedx-sbom - path: cyclonedx-sbom.json + path: | + cyclonedx-sbom.json + dependency-inventory.json # Analyze without uploading, then upload with an explicit retry loop. # Issue #1172: analysis completed and SARIF exported, then the code-scanning diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py new file mode 100644 index 000000000..63e25d66a --- /dev/null +++ b/scripts/ci/dependency_inventory.py @@ -0,0 +1,119 @@ +"""Enumerate every declared dependency scope from this repository's lockfiles. + +The CycloneDX SBOM is produced from an installed environment, so it can only +describe what that environment happened to contain: the `dev`, `fuzz` and +`native-build` groups, the Rust workspace and the npm tree are outside it, while +the SBOM generator's own CI tools are inside it. Scoping the release SBOM down +to the shipped artefact does not discharge the licence policy -- the excluded +classes still have to be adjudicated -- so this emits them as a separate, +explicitly scoped inventory bound to the same commit. + +Input is the lockfiles alone: `uv.lock`, `rust/Cargo.lock`, `package-lock.json`. +Nothing is installed, resolved or fetched, so this runs anywhere the repository +is checked out, and the inventory is the resolved transitive closure rather than +a sample of it. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _toml_packages(path: Path) -> list[dict[str, str]]: + with open(path, "rb") as handle: + document = tomllib.load(handle) + return [ + {"name": str(entry["name"]), "version": str(entry.get("version", ""))} + for entry in document.get("package") or [] + if isinstance(entry, dict) and entry.get("name") + ] + + +def _npm_packages(path: Path) -> list[dict[str, str]]: + with open(path, encoding="utf-8") as handle: + document = json.load(handle) + packages: list[dict[str, str]] = [] + for location, entry in (document.get("packages") or {}).items(): + if not location or not isinstance(entry, dict): + continue # the "" key is the project itself + name = entry.get("name") or location.split("node_modules/", 1)[-1] + packages.append({"name": str(name), "version": str(entry.get("version", ""))}) + return packages + + +def _source_sha(repository_root: Path) -> str: + """The commit this inventory describes, so it can be bound to an SBOM.""" + try: + completed = subprocess.run( + ["git", "-C", str(repository_root), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, + ) + return completed.stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "" + + +def build_inventory(repository_root: Path) -> dict[str, Any]: + """Collect every lockfile-resolved dependency, grouped by ecosystem.""" + ecosystems: list[dict[str, Any]] = [] + for ecosystem, lock, reader, purl_prefix in ( + ("python", repository_root / "uv.lock", _toml_packages, "pkg:pypi/"), + ("cargo", repository_root / "rust" / "Cargo.lock", _toml_packages, "pkg:cargo/"), + ("npm", repository_root / "package-lock.json", _npm_packages, "pkg:npm/"), + ): + entry: dict[str, Any] = {"ecosystem": ecosystem, "lockfile": str(lock.relative_to(repository_root))} + if not lock.exists(): + entry["error"] = "lockfile absent, so this scope is unprovable" + entry["packages"] = [] + ecosystems.append(entry) + continue + packages = reader(lock) + for package in packages: + package["purl"] = f"{purl_prefix}{package['name']}@{package['version']}" + entry["packages"] = sorted(packages, key=lambda package: (package["name"], package["version"])) + ecosystems.append(entry) + return { + "schema": "contextual-orchestrator/dependency-inventory/v1", + "source_sha": _source_sha(repository_root), + "scope_note": ( + "Lockfile-resolved closure for every declared scope, including the classes a release " + "SBOM built from an installed environment cannot see. Licence adjudication of these " + "entries is required; exclusion from the shipped artefact is not an exemption." + ), + "ecosystems": ecosystems, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Emit the lockfile-resolved dependency inventory.") + parser.add_argument("--repository-root", default=".") + parser.add_argument("--output", required=True) + arguments = parser.parse_args(argv) + root = Path(arguments.repository_root).resolve() + try: + inventory = build_inventory(root) + except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: + print(f"::error::Dependency inventory could not be built ({error}).", file=sys.stderr) + return 1 + with open(arguments.output, "w", encoding="utf-8") as handle: + json.dump(inventory, handle, indent=2, sort_keys=True) + handle.write("\n") + counts = ", ".join( + f"{entry['ecosystem']}={len(entry['packages'])}" for entry in inventory["ecosystems"] + ) + print(f"dependency inventory written to {arguments.output} for {inventory['source_sha'][:12]}: {counts}") + unprovable = [entry["ecosystem"] for entry in inventory["ecosystems"] if entry.get("error")] + if unprovable: + print(f"::error::Unprovable dependency scopes: {', '.join(unprovable)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry point + raise SystemExit(main()) diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py new file mode 100644 index 000000000..ab2fec20c --- /dev/null +++ b/tests/test_dependency_inventory.py @@ -0,0 +1,59 @@ +"""The non-shipped dependency classes must still be enumerated and bound to a commit. + +A release SBOM built from an installed environment cannot see the `dev`, `fuzz` +and `native-build` groups, the Rust workspace or the npm tree. Scoping the SBOM +down does not discharge the licence policy, so those classes are emitted as a +separate inventory carrying the same `source_sha`, and an unprovable scope fails +rather than disappearing. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.ci.dependency_inventory import build_inventory, main # noqa: E402 + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def test_inventory_covers_every_lockfile_resolved_scope(tmp_path) -> None: + output = tmp_path / "dependency-inventory.json" + + assert main(["--repository-root", str(REPOSITORY_ROOT), "--output", str(output)]) == 0 + + inventory = json.loads(output.read_text(encoding="utf-8")) + by_ecosystem = {entry["ecosystem"]: entry for entry in inventory["ecosystems"]} + assert set(by_ecosystem) == {"python", "cargo", "npm"} + for ecosystem, entry in by_ecosystem.items(): + assert entry["packages"], f"{ecosystem} inventory is empty" + assert all(package["purl"].startswith("pkg:") for package in entry["packages"]) + assert len(inventory["source_sha"]) == 40 + # The dev-group and Rust members the environment SBOM cannot see. + python_names = {package["name"] for package in by_ecosystem["python"]["packages"]} + cargo_names = {package["name"] for package in by_ecosystem["cargo"]["packages"]} + assert {"pytest", "hypothesis"} <= python_names + assert "contextual-token-packer" in cargo_names + + +def test_absent_lockfile_is_reported_as_unprovable(tmp_path) -> None: + (tmp_path / "rust").mkdir() + (tmp_path / "uv.lock").write_text('[[package]]\nname = "only_library"\nversion = "1.0"\n', encoding="utf-8") + + inventory = build_inventory(tmp_path) + errors = {entry["ecosystem"]: entry.get("error") for entry in inventory["ecosystems"]} + + assert errors["cargo"] and errors["npm"] + assert main(["--repository-root", str(tmp_path), "--output", str(tmp_path / "out.json")]) == 1 + + +def test_security_workflow_publishes_the_inventory_with_the_sbom() -> None: + workflow = (REPOSITORY_ROOT / ".github" / "workflows" / "security.yml").read_text(encoding="utf-8") + + assert "scripts.ci.dependency_inventory" in workflow + assert workflow.index("scripts.ci.dependency_inventory") < workflow.index("Upload CycloneDX SBOM") + upload = workflow[workflow.index("Upload CycloneDX SBOM") : workflow.index("Run CodeQL analysis")] + assert "dependency-inventory.json" in upload From 417795ee7a44c025013ebef97d8731d321174875 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 00:43:30 +0900 Subject: [PATCH 08/23] fix(security): read both npm lockfiles in the dependency inventory Cross-checking the inventory against a real Trivy scan of the same tree found the inventory short: it reported 13 npm packages where Trivy reported 16, with react, react-dom and scheduler missing. The repository carries two npm lockfiles and the admin_ui workspace tree lives in pnpm-lock.yaml, which the first version never read. Read both, so the npm closure goes from 13 to 359 and no longer silently drops a workspace. pnpm keys each resolved package as name@version, so the bounded packages block is read directly rather than adding a YAML dependency for it, and a scoped name splits on its last @ so @scope/name@1.2.3 survives. Cargo agreed exactly (43/43) across the two readings, and the 11 Python entries Trivy lacks are the dev and native-build groups it never reads -- the intended split, not a gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- scripts/ci/dependency_inventory.py | 42 +++++++++++++++++++++++++++++- tests/test_dependency_inventory.py | 5 ++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 63e25d66a..49a711922 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -8,7 +8,8 @@ classes still have to be adjudicated -- so this emits them as a separate, explicitly scoped inventory bound to the same commit. -Input is the lockfiles alone: `uv.lock`, `rust/Cargo.lock`, `package-lock.json`. +Input is the lockfiles alone: `uv.lock`, `rust/Cargo.lock`, and both npm lockfiles +(`package-lock.json` and `pnpm-lock.yaml`, whose trees differ). Nothing is installed, resolved or fetched, so this runs anywhere the repository is checked out, and the inventory is the resolved transitive closure rather than a sample of it. @@ -18,6 +19,7 @@ import argparse import json +import re import subprocess import sys import tomllib @@ -47,6 +49,34 @@ def _npm_packages(path: Path) -> list[dict[str, str]]: return packages +def _pnpm_packages(path: Path) -> list[dict[str, str]]: + """Read the `packages:` block of a pnpm lockfile. + + pnpm keys each resolved package as ``name@version`` (quoted when it carries + a scope), which is all this inventory needs. Reading those keys directly + avoids adding a YAML dependency for one bounded block, and a scoped name + splits on its last ``@`` so ``@scope/name@1.2.3`` stays intact. + """ + packages: list[dict[str, str]] = [] + inside = False + with open(path, encoding="utf-8") as handle: + for line in handle: + if not line.strip(): + continue + if not line.startswith(" "): + inside = line.startswith("packages:") + continue + if not inside: + continue + match = re.match(r"^ '?([^:']+?)'?:\s*$", line.rstrip("\n")) + if not match: + continue + name, separator, version = match.group(1).rpartition("@") + if separator and name: + packages.append({"name": name, "version": version}) + return packages + + def _source_sha(repository_root: Path) -> str: """The commit this inventory describes, so it can be bound to an SBOM.""" try: @@ -74,6 +104,16 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: ecosystems.append(entry) continue packages = reader(lock) + if ecosystem == "npm": + pnpm_lock = repository_root / "pnpm-lock.yaml" + if pnpm_lock.exists(): + entry["lockfile"] = f"{entry['lockfile']}, {pnpm_lock.relative_to(repository_root)}" + seen = {(package["name"], package["version"]) for package in packages} + packages += [ + package + for package in _pnpm_packages(pnpm_lock) + if (package["name"], package["version"]) not in seen + ] for package in packages: package["purl"] = f"{purl_prefix}{package['name']}@{package['version']}" entry["packages"] = sorted(packages, key=lambda package: (package["name"], package["version"])) diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index ab2fec20c..f2bc95aba 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -37,6 +37,11 @@ def test_inventory_covers_every_lockfile_resolved_scope(tmp_path) -> None: cargo_names = {package["name"] for package in by_ecosystem["cargo"]["packages"]} assert {"pytest", "hypothesis"} <= python_names assert "contextual-token-packer" in cargo_names + # Both npm lockfiles are read: package-lock.json alone omits the pnpm + # workspace tree, which a single-lockfile reading silently loses. + npm_names = {package["name"] for package in by_ecosystem["npm"]["packages"]} + assert {"opencode-ai", "react"} <= npm_names + assert "pnpm-lock.yaml" in by_ecosystem["npm"]["lockfile"] def test_absent_lockfile_is_reported_as_unprovable(tmp_path) -> None: From cefea2ba8eb942dc4c17b738910c01ca2edd1aa2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 00:49:42 +0900 Subject: [PATCH 09/23] fix(security): refuse silent drops, empty scopes and unbindable inventory bytes Independent review found the inventory's reuse contract still weak in four places, each a way for silence to read as a clean scope: - A malformed or nameless lock entry was skipped, shrinking the set without saying so; it now refuses. - An empty resolved set and an unresolvable source commit both exited 0; both now fail, because an empty scope is silence, not a clean bill. - A nested npm path (node_modules/a/node_modules/b) split on its first marker and recorded 'a/node_modules/b' as the package name; it splits on the last. - The inventory recorded only a HEAD string, so it could describe bytes no release can reproduce. Each lockfile now carries its read sha256 beside the committed blob id, and a mismatch fails. The release gate regenerates the inventory from the released commit before adjudicating, so the enumerated scopes are bound to the exact source being published. Five negative regressions cover malformed, empty, nested, modified bytes and an unresolvable commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/release.yml | 7 +++ scripts/ci/dependency_inventory.py | 95 +++++++++++++++++++++++++----- tests/test_dependency_inventory.py | 65 ++++++++++++++++++++ 3 files changed, 151 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 349ee6735..626986455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -295,6 +295,13 @@ jobs: # appear, because a partial SBOM is silence rather than evidence. # A failure here stops the run before `publish` exists, so no tag is # created and nothing is published; it is never waived. + # The inventory is regenerated here from the released commit's own + # lockfiles and refuses to run against modified bytes, so the scopes + # the environment SBOM cannot see are enumerated against the exact + # source this release publishes rather than against whatever the + # security run happened to hold. + python -m scripts.ci.dependency_inventory \ + --repository-root . --output dependency-inventory.json python -m scripts.ci.release_license_gate \ --sbom sbom-download/cyclonedx-sbom.json \ --pyproject pyproject.toml \ diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 49a711922..740b7599f 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -18,6 +18,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import subprocess @@ -27,14 +28,24 @@ from typing import Any +class InventoryError(Exception): + """A lockfile could not be read as a complete dependency set.""" + + def _toml_packages(path: Path) -> list[dict[str, str]]: + """Read ``[[package]]`` entries, refusing to drop any of them quietly. + + Skipping a malformed or nameless entry would shrink the inventory without + saying so, which is the failure this file exists to prevent. + """ with open(path, "rb") as handle: document = tomllib.load(handle) - return [ - {"name": str(entry["name"]), "version": str(entry.get("version", ""))} - for entry in document.get("package") or [] - if isinstance(entry, dict) and entry.get("name") - ] + packages: list[dict[str, str]] = [] + for index, entry in enumerate(document.get("package") or []): + if not isinstance(entry, dict) or not entry.get("name") or not entry.get("version"): + raise InventoryError(f"{path}: package[{index}] is malformed or has no name/version") + packages.append({"name": str(entry["name"]), "version": str(entry["version"])}) + return packages def _npm_packages(path: Path) -> list[dict[str, str]]: @@ -44,8 +55,13 @@ def _npm_packages(path: Path) -> list[dict[str, str]]: for location, entry in (document.get("packages") or {}).items(): if not location or not isinstance(entry, dict): continue # the "" key is the project itself - name = entry.get("name") or location.split("node_modules/", 1)[-1] - packages.append({"name": str(name), "version": str(entry.get("version", ""))}) + # A nested path is "node_modules/a/node_modules/b": the package is the + # segment after the LAST marker, not everything after the first one. + name = entry.get("name") or location.rsplit("node_modules/", 1)[-1] + version = entry.get("version") + if not name or not version: + raise InventoryError(f"{path}: entry {location!r} has no resolvable name/version") + packages.append({"name": str(name), "version": str(version)}) return packages @@ -77,18 +93,44 @@ def _pnpm_packages(path: Path) -> list[dict[str, str]]: return packages +def _git(repository_root: Path, *arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", str(repository_root), *arguments], + capture_output=True, text=True, check=True, + ) + return completed.stdout.strip() + + def _source_sha(repository_root: Path) -> str: """The commit this inventory describes, so it can be bound to an SBOM.""" try: - completed = subprocess.run( - ["git", "-C", str(repository_root), "rev-parse", "HEAD"], - capture_output=True, text=True, check=True, - ) - return completed.stdout.strip() + return _git(repository_root, "rev-parse", "HEAD") except (OSError, subprocess.CalledProcessError): return "" +def _lock_provenance(repository_root: Path, lock: Path) -> dict[str, Any]: + """Bind the inventory to the exact bytes read, not merely to a commit name. + + A commit id describes what is committed; the reader may have read something + else. Recording the read bytes' hash beside the committed blob's hash makes + a modified working tree visible instead of silently inventorying bytes that + no release can reproduce. + """ + data = lock.read_bytes() + read_hash = hashlib.sha256(data).hexdigest() + relative = str(lock.relative_to(repository_root)) + provenance: dict[str, Any] = {"path": relative, "read_sha256": read_hash} + try: + provenance["blob_id"] = _git(repository_root, "hash-object", str(lock)) + provenance["committed_blob_id"] = _git(repository_root, "rev-parse", f"HEAD:{relative}") + except (OSError, subprocess.CalledProcessError): + provenance["error"] = "git could not resolve the committed blob for this lockfile" + return provenance + provenance["matches_commit"] = provenance["blob_id"] == provenance["committed_blob_id"] + return provenance + + def build_inventory(repository_root: Path) -> dict[str, Any]: """Collect every lockfile-resolved dependency, grouped by ecosystem.""" ecosystems: list[dict[str, Any]] = [] @@ -103,11 +145,13 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: entry["packages"] = [] ecosystems.append(entry) continue + entry["provenance"] = [_lock_provenance(repository_root, lock)] packages = reader(lock) if ecosystem == "npm": pnpm_lock = repository_root / "pnpm-lock.yaml" if pnpm_lock.exists(): entry["lockfile"] = f"{entry['lockfile']}, {pnpm_lock.relative_to(repository_root)}" + entry["provenance"].append(_lock_provenance(repository_root, pnpm_lock)) seen = {(package["name"], package["version"]) for package in packages} packages += [ package @@ -138,7 +182,7 @@ def main(argv: list[str] | None = None) -> int: root = Path(arguments.repository_root).resolve() try: inventory = build_inventory(root) - except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: + except (InventoryError, OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: print(f"::error::Dependency inventory could not be built ({error}).", file=sys.stderr) return 1 with open(arguments.output, "w", encoding="utf-8") as handle: @@ -148,9 +192,28 @@ def main(argv: list[str] | None = None) -> int: f"{entry['ecosystem']}={len(entry['packages'])}" for entry in inventory["ecosystems"] ) print(f"dependency inventory written to {arguments.output} for {inventory['source_sha'][:12]}: {counts}") - unprovable = [entry["ecosystem"] for entry in inventory["ecosystems"] if entry.get("error")] - if unprovable: - print(f"::error::Unprovable dependency scopes: {', '.join(unprovable)}", file=sys.stderr) + problems: list[str] = [] + if not inventory["source_sha"]: + problems.append("no source commit could be resolved, so nothing can be bound to an SBOM") + for entry in inventory["ecosystems"]: + ecosystem = entry["ecosystem"] + if entry.get("error"): + problems.append(f"{ecosystem}: {entry['error']}") + continue + if not entry["packages"]: + # An empty set is silence about the scope, never a clean one. + problems.append(f"{ecosystem}: {entry['lockfile']} resolved no packages") + for provenance in entry.get("provenance") or []: + if provenance.get("error"): + problems.append(f"{ecosystem}: {provenance['path']}: {provenance['error']}") + elif not provenance.get("matches_commit"): + problems.append( + f"{ecosystem}: {provenance['path']} read bytes {provenance['read_sha256'][:12]} " + f"do not match the committed blob, so this inventory describes an unreproducible tree" + ) + if problems: + for problem in problems: + print(f"::error::Dependency inventory refused: {problem}", file=sys.stderr) return 1 return 0 diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index f2bc95aba..cf45b1d84 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import subprocess import sys from pathlib import Path @@ -62,3 +63,67 @@ def test_security_workflow_publishes_the_inventory_with_the_sbom() -> None: assert workflow.index("scripts.ci.dependency_inventory") < workflow.index("Upload CycloneDX SBOM") upload = workflow[workflow.index("Upload CycloneDX SBOM") : workflow.index("Run CodeQL analysis")] assert "dependency-inventory.json" in upload + + +# --- negative cases: silence must not read as a clean scope --- + + +def _repository(tmp_path: Path, *, uv: str | None = None, npm: dict | None = None) -> Path: + """A committed miniature repository, so provenance can be checked.""" + (tmp_path / "rust").mkdir() + (tmp_path / "uv.lock").write_text( + uv if uv is not None else '[[package]]\nname = "only_library"\nversion = "1.0"\n', encoding="utf-8") + (tmp_path / "rust" / "Cargo.lock").write_text( + '[[package]]\nname = "only_crate"\nversion = "0.1.0"\n', encoding="utf-8") + (tmp_path / "package-lock.json").write_text( + json.dumps(npm if npm is not None else {"packages": {"node_modules/one": {"version": "1.0.0"}}}), + encoding="utf-8") + for command in (["init", "-q"], ["add", "-A"], ["-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-qm", "fixture"]): + subprocess.run(["git", "-C", str(tmp_path), *command], check=True, capture_output=True) + return tmp_path + + +def test_malformed_lock_entry_is_refused_not_skipped(tmp_path) -> None: + repository = _repository(tmp_path, uv='[[package]]\nname = "no_version_library"\n') + + assert main(["--repository-root", str(repository), "--output", str(tmp_path / "out.json")]) == 1 + + +def test_empty_resolved_set_is_refused(tmp_path) -> None: + repository = _repository(tmp_path, uv="version = 1\n") + + assert main(["--repository-root", str(repository), "--output", str(tmp_path / "out.json")]) == 1 + + +def test_nested_node_modules_path_keeps_the_real_package_name(tmp_path) -> None: + repository = _repository( + tmp_path, npm={"packages": {"node_modules/outer/node_modules/inner": {"version": "2.0.0"}}}) + + inventory = build_inventory(repository) + npm = next(entry for entry in inventory["ecosystems"] if entry["ecosystem"] == "npm") + + assert [package["name"] for package in npm["packages"]] == ["inner"] + + +def test_modified_lockfile_bytes_are_refused(tmp_path) -> None: + """An inventory of uncommitted bytes cannot be bound to any released commit.""" + repository = _repository(tmp_path) + (repository / "uv.lock").write_text( + '[[package]]\nname = "only_library"\nversion = "9.9"\n', encoding="utf-8") + + inventory = build_inventory(repository) + python = next(entry for entry in inventory["ecosystems"] if entry["ecosystem"] == "python") + + assert python["provenance"][0]["matches_commit"] is False + assert main(["--repository-root", str(repository), "--output", str(tmp_path / "out.json")]) == 1 + + +def test_unresolvable_source_commit_is_refused(tmp_path) -> None: + (tmp_path / "rust").mkdir() + (tmp_path / "uv.lock").write_text('[[package]]\nname = "l"\nversion = "1"\n', encoding="utf-8") + (tmp_path / "rust" / "Cargo.lock").write_text('[[package]]\nname = "c"\nversion = "1"\n', encoding="utf-8") + (tmp_path / "package-lock.json").write_text('{"packages":{"node_modules/one":{"version":"1"}}}', + encoding="utf-8") + + assert main(["--repository-root", str(tmp_path), "--output", str(tmp_path / "out.json")]) == 1 From aa266299dbdb4be1a76babb08ab1c72eb6ff2f5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 00:55:28 +0900 Subject: [PATCH 10/23] fix(security): validate the pnpm layout and stop calling four locks the whole scope Independent review found the pnpm reader accepting things it cannot read and the inventory overclaiming what it proves. The reader now requires lockfileVersion 9. A v6 lockfile keys packages as /name@1.0.0(peer@2.0.0), so its peer suffix would have been recorded as part of the version, and an unknown future layout is unknown by definition; both now fail closed. Double-quoted keys parse like single-quoted ones, a key with no name@version form is refused, and the indent rule no longer mistakes a package's own fields for package keys. The Python scope also understated itself: uv.lock alone omitted the CI and fuzz toolchains, which install into the same jobs. Reading requirements.lock and every pinned requirements*.txt beside it takes the Python set from 63 to 112, each source carrying its own provenance. Finally the scope note stops claiming this is every declared scope. It is the union of the files named in the output; image layers, pinned Actions and the Rust toolchain are outside it, and that is now written down rather than implied. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- scripts/ci/dependency_inventory.py | 106 ++++++++++++++++++++++------- tests/test_dependency_inventory.py | 53 ++++++++++++++- 2 files changed, 133 insertions(+), 26 deletions(-) diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 740b7599f..10133576c 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -8,8 +8,11 @@ classes still have to be adjudicated -- so this emits them as a separate, explicitly scoped inventory bound to the same commit. -Input is the lockfiles alone: `uv.lock`, `rust/Cargo.lock`, and both npm lockfiles -(`package-lock.json` and `pnpm-lock.yaml`, whose trees differ). +Input is the pinned files alone: `uv.lock` plus every `requirements*.txt` (the +CI and fuzz toolchains), `rust/Cargo.lock`, and both npm lockfiles +(`package-lock.json` and `pnpm-lock.yaml`, whose trees differ). Each source is +named in the output, because the union of the files read is what this can +prove -- it is not a claim that nothing else is declared anywhere. Nothing is installed, resolved or fetched, so this runs anywhere the repository is checked out, and the inventory is the resolved transitive closure rather than a sample of it. @@ -65,31 +68,64 @@ def _npm_packages(path: Path) -> list[dict[str, str]]: return packages +_PNPM_SUPPORTED_VERSIONS = frozenset({"9", "9.0"}) + + def _pnpm_packages(path: Path) -> list[dict[str, str]]: - """Read the `packages:` block of a pnpm lockfile. + """Read the `packages:` block of a pnpm v9 lockfile. + + Each key is ``name@version``, quoted when it carries a scope, which is all + this inventory needs, so the bounded block is read directly instead of + adding a YAML dependency for it. A scoped name splits on its last ``@`` so + ``@scope/name@1.2.3`` survives. - pnpm keys each resolved package as ``name@version`` (quoted when it carries - a scope), which is all this inventory needs. Reading those keys directly - avoids adding a YAML dependency for one bounded block, and a scoped name - splits on its last ``@`` so ``@scope/name@1.2.3`` stays intact. + Only lockfile version 9 is read. Older layouts key packages differently -- + v6 writes ``/name@1.0.0(peer@2.0.0)``, whose peer suffix would be recorded + as part of the version -- and a future layout is unknown by definition, so + anything else fails closed rather than producing plausible nonsense. """ + declared_version = "" + lines = path.read_text(encoding="utf-8").splitlines() + for line in lines: + match = re.match(r"^lockfileVersion:\s*'?\"?([0-9.]+)'?\"?\s*$", line) + if match: + declared_version = match.group(1) + break + if declared_version not in _PNPM_SUPPORTED_VERSIONS: + raise InventoryError( + f"{path}: lockfileVersion {declared_version or ''} is not a layout this inventory " + f"reads (supported: {', '.join(sorted(_PNPM_SUPPORTED_VERSIONS))})" + ) packages: list[dict[str, str]] = [] inside = False - with open(path, encoding="utf-8") as handle: - for line in handle: - if not line.strip(): - continue - if not line.startswith(" "): - inside = line.startswith("packages:") - continue - if not inside: - continue - match = re.match(r"^ '?([^:']+?)'?:\s*$", line.rstrip("\n")) - if not match: - continue - name, separator, version = match.group(1).rpartition("@") - if separator and name: - packages.append({"name": name, "version": version}) + for line in lines: + if not line.strip(): + continue + if not line.startswith(" "): + inside = line.startswith("packages:") + continue + if not inside: + continue + # Exactly two spaces of indent: deeper lines are a package's own + # fields (resolution, engines, peerDependencies), not package keys. + match = re.match(r"""^ (?P['"]?)(?P[^\s'"].*?)(?P=quote):\s*$""", line) + if not match: + continue + spec = match.group("spec") + name, separator, version = spec.rpartition("@") + if not separator or not name or not version: + raise InventoryError(f"{path}: package key {spec!r} has no name@version form") + packages.append({"name": name, "version": version}) + return packages + + +def _requirements_packages(path: Path) -> list[dict[str, str]]: + """Read pinned ``name==version`` lines from a hash-locked requirements file.""" + packages: list[dict[str, str]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s\\;]+)", line) + if match: + packages.append({"name": match.group(1), "version": match.group(2)}) return packages @@ -147,6 +183,24 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: continue entry["provenance"] = [_lock_provenance(repository_root, lock)] packages = reader(lock) + if ecosystem == "python": + # uv.lock resolves the project's own scopes; the CI and fuzz + # toolchains are pinned in their own hash-locked requirements + # files, and those install into the same jobs, so they belong in + # the enumeration rather than outside it. + pinned_files = sorted(repository_root.glob("requirements*.txt")) + pinned_files += sorted((repository_root / "fuzz").glob("requirements*.txt")) + if (repository_root / "requirements.lock").exists(): + pinned_files.insert(0, repository_root / "requirements.lock") + for requirements in pinned_files: + entry["lockfile"] = f"{entry['lockfile']}, {requirements.relative_to(repository_root)}" + entry["provenance"].append(_lock_provenance(repository_root, requirements)) + seen = {(package["name"], package["version"]) for package in packages} + packages += [ + package + for package in _requirements_packages(requirements) + if (package["name"], package["version"]) not in seen + ] if ecosystem == "npm": pnpm_lock = repository_root / "pnpm-lock.yaml" if pnpm_lock.exists(): @@ -166,9 +220,11 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: "schema": "contextual-orchestrator/dependency-inventory/v1", "source_sha": _source_sha(repository_root), "scope_note": ( - "Lockfile-resolved closure for every declared scope, including the classes a release " - "SBOM built from an installed environment cannot see. Licence adjudication of these " - "entries is required; exclusion from the shipped artefact is not an exemption." + "Union of the lockfiles and pinned requirement files enumerated in each ecosystem's " + "'lockfile' field, which is what this inventory can prove -- not an assertion that the " + "repository declares nothing else. It deliberately includes classes a release SBOM built " + "from one installed environment cannot see. Licence adjudication of these entries is " + "required; exclusion from the shipped artefact is not an exemption." ), "ecosystems": ecosystems, } diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index cf45b1d84..5b1d5a9b7 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -16,7 +16,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from scripts.ci.dependency_inventory import build_inventory, main # noqa: E402 +import pytest # noqa: E402 + +from scripts.ci.dependency_inventory import ( # noqa: E402 + InventoryError, + _pnpm_packages, + build_inventory, + main, +) REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -127,3 +134,47 @@ def test_unresolvable_source_commit_is_refused(tmp_path) -> None: encoding="utf-8") assert main(["--repository-root", str(tmp_path), "--output", str(tmp_path / "out.json")]) == 1 + + +def test_double_quoted_pnpm_keys_are_parsed(tmp_path) -> None: + lock = tmp_path / "pnpm-lock.yaml" + lock.write_text( + "lockfileVersion: '9.0'\n\npackages:\n\n" + ' "@scope/quoted@1.2.3":\n resolution: {integrity: sha512-x}\n' + " 'single@4.5.6':\n resolution: {integrity: sha512-y}\n", + encoding="utf-8", + ) + + packages = _pnpm_packages(lock) + + assert {(p["name"], p["version"]) for p in packages} == {("@scope/quoted", "1.2.3"), ("single", "4.5.6")} + + +@pytest.mark.parametrize( + "content", + [ + pytest.param("lockfileVersion: 999\n\npackages:\n\n 'x@1.0.0':\n", id="unsupported_version"), + pytest.param("packages:\n\n 'x@1.0.0':\n", id="no_version_declared"), + pytest.param("lockfileVersion: '6.0'\n\npackages:\n\n /x@1.0.0(peer@2.0.0):\n", id="v6_peer_suffix"), + pytest.param("this is not: [a, lockfile\n", id="malformed"), + ], +) +def test_unsupported_or_malformed_pnpm_lock_fails_closed(tmp_path, content) -> None: + lock = tmp_path / "pnpm-lock.yaml" + lock.write_text(content, encoding="utf-8") + + with pytest.raises(InventoryError): + _pnpm_packages(lock) + + +def test_python_scope_includes_the_pinned_ci_and_fuzz_toolchains() -> None: + """uv.lock alone is not the declared Python scope: the CI locks install too.""" + inventory = build_inventory(REPOSITORY_ROOT) + python = next(entry for entry in inventory["ecosystems"] if entry["ecosystem"] == "python") + names = {package["name"] for package in python["packages"]} + + for source in ("uv.lock", "requirements.lock", "requirements-security-ci.txt", + "fuzz/requirements-property.txt"): + assert source in python["lockfile"] + assert {"cyclonedx-bom", "chardet"} <= names # security CI toolchain + assert len(python["provenance"]) == len(python["lockfile"].split(", ")) From 6e1a469663f027b98eba5a0f24d922792b96f4fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:00:40 +0900 Subject: [PATCH 11/23] fix(security): bind one read per lockfile and make the gate consume the inventory Three holes remained after the last round. An npm entry that is not an object was skipped, so a file with one good and one broken entry reported only the good one. It is refused now, like the TOML side. Provenance read each file three times -- read_bytes, git hash-object , then the parser -- so three different byte sequences could have been bound together. Each file is now read once: those bytes are hashed, piped to git hash-object --stdin, and parsed, and the commit is captured once at the start so a branch moving mid-run cannot bind half the inventory to another tree. The gate asked for an inventory but never consumed one. It now takes --inventory and --source-sha, uses the inventory's per-ecosystem sets as the expected coverage, and refuses an inventory with no source_sha, one describing another commit, or one whose lockfile bytes do not match their committed blob. The release workflow regenerates the inventory from the released commit, passes it with TARGET_SHA, and publishes it beside the notes and SBOM. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/release.yml | 5 ++- scripts/ci/dependency_inventory.py | 69 ++++++++++++++++++------------ scripts/ci/release_license_gate.py | 66 ++++++++++++++++++++++++++-- tests/test_dependency_inventory.py | 4 +- tests/test_release_license_gate.py | 57 ++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 626986455..d2bd10aa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -305,7 +305,9 @@ jobs: python -m scripts.ci.release_license_gate \ --sbom sbom-download/cyclonedx-sbom.json \ --pyproject pyproject.toml \ - --repository-root . + --repository-root . \ + --inventory dependency-inventory.json \ + --source-sha "${TARGET_SHA}" - name: Upload rendered notes and SBOM for the publish job uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # actions/upload-artifact@v5 @@ -313,6 +315,7 @@ jobs: name: release-publish-inputs path: | release-notes.md + dependency-inventory.json sbom-download/** if-no-files-found: error retention-days: 1 diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 10133576c..d11f99a2d 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -35,14 +35,13 @@ class InventoryError(Exception): """A lockfile could not be read as a complete dependency set.""" -def _toml_packages(path: Path) -> list[dict[str, str]]: +def _toml_packages(path: Path, data: bytes) -> list[dict[str, str]]: """Read ``[[package]]`` entries, refusing to drop any of them quietly. Skipping a malformed or nameless entry would shrink the inventory without saying so, which is the failure this file exists to prevent. """ - with open(path, "rb") as handle: - document = tomllib.load(handle) + document = tomllib.loads(data.decode("utf-8")) packages: list[dict[str, str]] = [] for index, entry in enumerate(document.get("package") or []): if not isinstance(entry, dict) or not entry.get("name") or not entry.get("version"): @@ -51,13 +50,16 @@ def _toml_packages(path: Path) -> list[dict[str, str]]: return packages -def _npm_packages(path: Path) -> list[dict[str, str]]: - with open(path, encoding="utf-8") as handle: - document = json.load(handle) +def _npm_packages(path: Path, data: bytes) -> list[dict[str, str]]: + document = json.loads(data.decode("utf-8")) packages: list[dict[str, str]] = [] for location, entry in (document.get("packages") or {}).items(): - if not location or not isinstance(entry, dict): - continue # the "" key is the project itself + if not location: + continue # the "" key is the project itself, not a dependency + if not isinstance(entry, dict): + # Skipping it would let a file with one good and one broken entry + # report only the good one, which is the silence this refuses. + raise InventoryError(f"{path}: entry {location!r} is not an object") # A nested path is "node_modules/a/node_modules/b": the package is the # segment after the LAST marker, not everything after the first one. name = entry.get("name") or location.rsplit("node_modules/", 1)[-1] @@ -71,7 +73,7 @@ def _npm_packages(path: Path) -> list[dict[str, str]]: _PNPM_SUPPORTED_VERSIONS = frozenset({"9", "9.0"}) -def _pnpm_packages(path: Path) -> list[dict[str, str]]: +def _pnpm_packages(path: Path, data: bytes) -> list[dict[str, str]]: """Read the `packages:` block of a pnpm v9 lockfile. Each key is ``name@version``, quoted when it carries a scope, which is all @@ -85,7 +87,7 @@ def _pnpm_packages(path: Path) -> list[dict[str, str]]: anything else fails closed rather than producing plausible nonsense. """ declared_version = "" - lines = path.read_text(encoding="utf-8").splitlines() + lines = data.decode("utf-8").splitlines() for line in lines: match = re.match(r"^lockfileVersion:\s*'?\"?([0-9.]+)'?\"?\s*$", line) if match: @@ -119,10 +121,10 @@ def _pnpm_packages(path: Path) -> list[dict[str, str]]: return packages -def _requirements_packages(path: Path) -> list[dict[str, str]]: +def _requirements_packages(path: Path, data: bytes) -> list[dict[str, str]]: """Read pinned ``name==version`` lines from a hash-locked requirements file.""" packages: list[dict[str, str]] = [] - for line in path.read_text(encoding="utf-8").splitlines(): + for line in data.decode("utf-8").splitlines(): match = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s\\;]+)", line) if match: packages.append({"name": match.group(1), "version": match.group(2)}) @@ -145,7 +147,7 @@ def _source_sha(repository_root: Path) -> str: return "" -def _lock_provenance(repository_root: Path, lock: Path) -> dict[str, Any]: +def _read_locked_source(repository_root: Path, lock: Path, commit: str) -> tuple[bytes, dict[str, Any]]: """Bind the inventory to the exact bytes read, not merely to a commit name. A commit id describes what is committed; the reader may have read something @@ -154,21 +156,29 @@ def _lock_provenance(repository_root: Path, lock: Path) -> dict[str, Any]: no release can reproduce. """ data = lock.read_bytes() - read_hash = hashlib.sha256(data).hexdigest() relative = str(lock.relative_to(repository_root)) - provenance: dict[str, Any] = {"path": relative, "read_sha256": read_hash} + provenance: dict[str, Any] = {"path": relative, "read_sha256": hashlib.sha256(data).hexdigest()} try: - provenance["blob_id"] = _git(repository_root, "hash-object", str(lock)) - provenance["committed_blob_id"] = _git(repository_root, "rev-parse", f"HEAD:{relative}") + # Hash the bytes already in hand rather than letting git re-read the + # path: three separate reads could bind three different files. + completed = subprocess.run( + ["git", "-C", str(repository_root), "hash-object", "--stdin"], + input=data, capture_output=True, check=True, + ) + provenance["blob_id"] = completed.stdout.decode().strip() + provenance["committed_blob_id"] = _git(repository_root, "rev-parse", f"{commit}:{relative}") except (OSError, subprocess.CalledProcessError): - provenance["error"] = "git could not resolve the committed blob for this lockfile" - return provenance + provenance["error"] = "git could not resolve the committed blob for this file" + return data, provenance provenance["matches_commit"] = provenance["blob_id"] == provenance["committed_blob_id"] - return provenance + return data, provenance def build_inventory(repository_root: Path) -> dict[str, Any]: """Collect every lockfile-resolved dependency, grouped by ecosystem.""" + # One commit is captured up front and every blob comparison uses it, so a + # branch moving mid-run cannot bind half the inventory to another tree. + commit = _source_sha(repository_root) ecosystems: list[dict[str, Any]] = [] for ecosystem, lock, reader, purl_prefix in ( ("python", repository_root / "uv.lock", _toml_packages, "pkg:pypi/"), @@ -181,8 +191,9 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: entry["packages"] = [] ecosystems.append(entry) continue - entry["provenance"] = [_lock_provenance(repository_root, lock)] - packages = reader(lock) + data, provenance = _read_locked_source(repository_root, lock, commit) + entry["provenance"] = [provenance] + packages = reader(lock, data) if ecosystem == "python": # uv.lock resolves the project's own scopes; the CI and fuzz # toolchains are pinned in their own hash-locked requirements @@ -194,22 +205,26 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: pinned_files.insert(0, repository_root / "requirements.lock") for requirements in pinned_files: entry["lockfile"] = f"{entry['lockfile']}, {requirements.relative_to(repository_root)}" - entry["provenance"].append(_lock_provenance(repository_root, requirements)) + requirements_data, requirements_provenance = _read_locked_source( + repository_root, requirements, commit + ) + entry["provenance"].append(requirements_provenance) seen = {(package["name"], package["version"]) for package in packages} packages += [ package - for package in _requirements_packages(requirements) + for package in _requirements_packages(requirements, requirements_data) if (package["name"], package["version"]) not in seen ] if ecosystem == "npm": pnpm_lock = repository_root / "pnpm-lock.yaml" if pnpm_lock.exists(): entry["lockfile"] = f"{entry['lockfile']}, {pnpm_lock.relative_to(repository_root)}" - entry["provenance"].append(_lock_provenance(repository_root, pnpm_lock)) + pnpm_data, pnpm_provenance = _read_locked_source(repository_root, pnpm_lock, commit) + entry["provenance"].append(pnpm_provenance) seen = {(package["name"], package["version"]) for package in packages} packages += [ package - for package in _pnpm_packages(pnpm_lock) + for package in _pnpm_packages(pnpm_lock, pnpm_data) if (package["name"], package["version"]) not in seen ] for package in packages: @@ -218,7 +233,7 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: ecosystems.append(entry) return { "schema": "contextual-orchestrator/dependency-inventory/v1", - "source_sha": _source_sha(repository_root), + "source_sha": commit, "scope_note": ( "Union of the lockfiles and pinned requirement files enumerated in each ecosystem's " "'lockfile' field, which is what this inventory can prove -- not an assertion that the " diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 748d0d9ab..46f689f22 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -236,8 +236,46 @@ def _missing_report(ecosystem: str, lock: Path, missing: set[tuple[str, str]], e ) +def _inventory_expectations(inventory: dict[str, Any]) -> dict[str, set[tuple[str, str]]]: + """Expected name/version pairs per ecosystem, as the inventory recorded them.""" + expectations: dict[str, set[tuple[str, str]]] = {} + for entry in inventory.get("ecosystems") or []: + ecosystem = str(entry.get("ecosystem") or "") + expectations[ecosystem] = { + (_normalize(str(package.get("name") or "")), str(package.get("version") or "")) + for package in entry.get("packages") or [] + } + return expectations + + +def inventory_binding_findings(inventory: dict[str, Any], expected_sha: str | None) -> list[str]: + """Refuse an inventory that describes another tree than the one being released.""" + findings: list[str] = [] + source_sha = str(inventory.get("source_sha") or "") + if not source_sha: + findings.append("inventory: no source_sha, so it cannot be bound to the released commit") + elif expected_sha and source_sha != expected_sha: + findings.append( + f"inventory: source_sha {source_sha[:12]} does not match the released commit " + f"{expected_sha[:12]}" + ) + for entry in inventory.get("ecosystems") or []: + ecosystem = entry.get("ecosystem") + if entry.get("error"): + findings.append(f"inventory {ecosystem}: {entry['error']}") + for provenance in entry.get("provenance") or []: + if provenance.get("error") or not provenance.get("matches_commit"): + findings.append( + f"inventory {ecosystem}: {provenance.get('path')} does not match its committed blob" + ) + return findings + + def scope_coverage_findings( - sbom: dict[str, Any], pyproject_path: str | None, repository_root: str | None + sbom: dict[str, Any], + pyproject_path: str | None, + repository_root: str | None, + inventory: dict[str, Any] | None = None, ) -> list[str]: """Prove the SBOM covers each ecosystem's whole resolved dependency set. @@ -280,9 +318,14 @@ def scope_coverage_findings( ) continue try: + inventory_expected = (_inventory_expectations(inventory) if inventory else {}).get(ecosystem) expected = ( - _lock_packages_from_npm(lock) if ecosystem == "npm" - else _lock_packages_from_toml(lock, toml_key) + inventory_expected + if inventory_expected is not None + else ( + _lock_packages_from_npm(lock) if ecosystem == "npm" + else _lock_packages_from_toml(lock, toml_key) + ) ) except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: findings.append(f"{ecosystem}: lockfile {lock} could not be read ({error})") @@ -326,6 +369,8 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--sbom", required=True, help="path to cyclonedx-sbom.json for the exact commit") parser.add_argument("--pyproject", help="pyproject.toml whose declared scopes the SBOM must cover") parser.add_argument("--repository-root", help="repository root, to check non-Python manifests") + parser.add_argument("--inventory", help="dependency inventory whose scopes the SBOM must cover") + parser.add_argument("--source-sha", help="the released commit the inventory must describe") arguments = parser.parse_args(argv) try: with open(arguments.sbom, encoding="utf-8") as handle: @@ -337,9 +382,22 @@ def main(argv: list[str] | None = None) -> int: if not isinstance(sbom, dict): print("::error::CycloneDX SBOM is not a JSON object; refusing to release.", file=sys.stderr) return 1 + inventory: dict[str, Any] | None = None + if arguments.inventory: + try: + with open(arguments.inventory, encoding="utf-8") as handle: + inventory = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + print(f"::error::Release licence gate could not read the dependency inventory: {error}", + file=sys.stderr) + return 1 try: groups = classify_sbom_components(sbom) - coverage = scope_coverage_findings(sbom, arguments.pyproject, arguments.repository_root) + coverage = scope_coverage_findings( + sbom, arguments.pyproject, arguments.repository_root, inventory + ) + if inventory is not None: + coverage = inventory_binding_findings(inventory, arguments.source_sha) + coverage except SbomSchemaError as error: print(f"::error::CycloneDX SBOM is malformed and cannot be adjudicated ({error}); refusing to release.", file=sys.stderr) diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index 5b1d5a9b7..6c44446b8 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -145,7 +145,7 @@ def test_double_quoted_pnpm_keys_are_parsed(tmp_path) -> None: encoding="utf-8", ) - packages = _pnpm_packages(lock) + packages = _pnpm_packages(lock, lock.read_bytes()) assert {(p["name"], p["version"]) for p in packages} == {("@scope/quoted", "1.2.3"), ("single", "4.5.6")} @@ -164,7 +164,7 @@ def test_unsupported_or_malformed_pnpm_lock_fails_closed(tmp_path, content) -> N lock.write_text(content, encoding="utf-8") with pytest.raises(InventoryError): - _pnpm_packages(lock) + _pnpm_packages(lock, lock.read_bytes()) def test_python_scope_includes_the_pinned_ci_and_fuzz_toolchains() -> None: diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 48e2b61d5..2d6f53eb5 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -19,6 +19,7 @@ from scripts.ci.release_license_gate import ( # noqa: E402 classify_sbom_components, + inventory_binding_findings, main, scope_coverage_findings, ) @@ -313,3 +314,59 @@ def test_purl_identity_must_match_the_component_fields(tmp_path) -> None: assert any("purl" in finding for finding in findings) assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), "--repository-root", str(repository)]) == 1 + + +# --- the gate must actually consume the inventory it asks for --- + + +def _inventory(source_sha: str = "a" * 40, *, matches: bool = True, packages=None) -> dict: + return { + "schema": "contextual-orchestrator/dependency-inventory/v1", + "source_sha": source_sha, + "ecosystems": [ + { + "ecosystem": "python", + "lockfile": "uv.lock", + "provenance": [{"path": "uv.lock", "read_sha256": "x", "matches_commit": matches}], + "packages": packages if packages is not None else [{"name": "inventory_only_library", "version": "3.0"}], + } + ], + } + + +def test_inventory_expectations_replace_the_lockfile_reading(tmp_path) -> None: + """A package the inventory carries and the SBOM lacks blocks the release.""" + repository = _repository(tmp_path) + inventory_path = tmp_path / "dependency-inventory.json" + inventory_path.write_text(json.dumps(_inventory()), encoding="utf-8") + sbom = _sbom(_purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0"), + _purl_component("one_cargo_crate", "1.1.5", "pkg:cargo/one_cargo_crate@1.1.5"), + _purl_component("other_cargo_crate", "0.3.0", "pkg:cargo/other_cargo_crate@0.3.0"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/one_npm_package@1.0.0"), + _purl_component("other_npm_package", "4.2.0", "pkg:npm/other_npm_package@4.2.0")) + + findings = scope_coverage_findings(sbom, str(repository / "pyproject.toml"), str(repository), + json.loads(inventory_path.read_text(encoding="utf-8"))) + + assert any("inventory-only-library==3.0" in finding for finding in findings) + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository), "--inventory", str(inventory_path)]) == 1 + + +def test_inventory_bound_to_another_commit_is_refused() -> None: + findings = inventory_binding_findings(_inventory("b" * 40), "c" * 40) + + assert any("does not match the released commit" in finding for finding in findings) + + +def test_inventory_with_modified_lock_bytes_is_refused() -> None: + findings = inventory_binding_findings(_inventory(matches=False), "a" * 40) + + assert any("does not match its committed blob" in finding for finding in findings) + + +def test_inventory_without_source_sha_is_refused() -> None: + findings = inventory_binding_findings(_inventory(""), None) + + assert any("no source_sha" in finding for finding in findings) From d9d4d6a3700c9305a79b681657cde3cdad2ac72d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:09:05 +0900 Subject: [PATCH 12/23] fix(release): check the inventory's own evidence instead of its verdict inventory_binding_findings trusted what the document claimed. It returned no findings when the blob id and the committed blob id disagreed but matches_commit said true, when read_sha256 was not a hash, when matches_commit was the string "false" (truthy), and when provenance was absent altogether. Each is now checked on its own terms: schema and commit-id shape, provenance present and structured, hashes in the right form, blob ids compared here rather than taken on trust, and matches_commit accepted only as boolean true. The supported contract is also written down rather than implied. The only producer is dependency_inventory.py run in the same job on the same checkout immediately before the gate, which is what release.yml does and the only caller there is. These checks reject a malformed or self-contradicting document from that producer; they are not independent verification of a foreign inventory, which would require re-reading each lockfile at the released commit inside the gate. Six negative regressions cover the cases above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- docs/RELEASING.md | 10 +++++ scripts/ci/release_license_gate.py | 60 ++++++++++++++++++++++++++---- tests/test_release_license_gate.py | 51 +++++++++++++++++++++---- 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 24e31585f..6445e3757 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -81,6 +81,16 @@ LifeOS and other consumers must verify their specific owner contracts too. and the container image layers are not collected. Extending that collection is the prerequisite for any release, not a reason to relax this gate. + The inventory the gate reads is produced by `scripts/ci/dependency_inventory.py` + in the same job, on the same checkout, immediately before the gate runs; that + is the only supported producer. The gate rejects a malformed, unbound or + self-contradicting document from it -- wrong schema, no commit id, a commit + other than the released one, absent provenance, unusable hashes, blob ids that + disagree, or a `matches_commit` that is anything but boolean true. Those checks + are not independent verification of an inventory from elsewhere: that would + require re-reading each lockfile at the released commit and re-deriving its + blob id inside the gate, which it does not do. + The gate runs inside `verify`, which `publish` depends on, so a failure stops the run before any tag exists. It is never waived to get a release out. 7. A repository administrator has enabled GitHub release immutability before diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 46f689f22..79370b05a 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -248,26 +248,72 @@ def _inventory_expectations(inventory: dict[str, Any]) -> dict[str, set[tuple[st return expectations +_HEX40 = re.compile(r"^[0-9a-f]{40}$") +_HEX64 = re.compile(r"^[0-9a-f]{64}$") + + def inventory_binding_findings(inventory: dict[str, Any], expected_sha: str | None) -> list[str]: - """Refuse an inventory that describes another tree than the one being released.""" + """Refuse an inventory that does not evidence the tree being released. + + Scope of this check, stated plainly: the only supported producer is + `scripts/ci/dependency_inventory.py` run in the same job, on the same + checkout, immediately before this gate -- which is what + `.github/workflows/release.yml` does, and the only caller there is. These + checks reject a malformed, unbound or self-contradicting document from that + trusted producer; they are **not** independent verification of an inventory + obtained from anywhere else. Verifying a foreign inventory would mean + re-reading each lockfile at the released commit and re-deriving its blob id + here, which this does not do. + """ findings: list[str] = [] + if str(inventory.get("schema") or "") != "contextual-orchestrator/dependency-inventory/v1": + findings.append(f"inventory: unrecognised schema {inventory.get('schema')!r}") source_sha = str(inventory.get("source_sha") or "") - if not source_sha: - findings.append("inventory: no source_sha, so it cannot be bound to the released commit") + if not _HEX40.match(source_sha): + findings.append("inventory: source_sha is missing or not a commit id, so it binds to nothing") elif expected_sha and source_sha != expected_sha: findings.append( f"inventory: source_sha {source_sha[:12]} does not match the released commit " f"{expected_sha[:12]}" ) - for entry in inventory.get("ecosystems") or []: + ecosystems = inventory.get("ecosystems") + if not isinstance(ecosystems, list) or not ecosystems: + findings.append("inventory: no ecosystems recorded") + return findings + for entry in ecosystems: + if not isinstance(entry, dict): + findings.append("inventory: an ecosystem entry is not an object") + continue ecosystem = entry.get("ecosystem") if entry.get("error"): findings.append(f"inventory {ecosystem}: {entry['error']}") - for provenance in entry.get("provenance") or []: - if provenance.get("error") or not provenance.get("matches_commit"): + provenance_entries = entry.get("provenance") + if not isinstance(provenance_entries, list) or not provenance_entries: + # Absent provenance is not a passing scope; it is an unevidenced one. + findings.append(f"inventory {ecosystem}: no provenance recorded for its sources") + continue + for provenance in provenance_entries: + if not isinstance(provenance, dict): + findings.append(f"inventory {ecosystem}: a provenance entry is not an object") + continue + path = provenance.get("path") or "" + if provenance.get("error"): + findings.append(f"inventory {ecosystem}: {path}: {provenance['error']}") + continue + blob_id = str(provenance.get("blob_id") or "") + committed = str(provenance.get("committed_blob_id") or "") + if not _HEX64.match(str(provenance.get("read_sha256") or "")): + findings.append(f"inventory {ecosystem}: {path} has no usable read_sha256") + if not _HEX40.match(blob_id) or not _HEX40.match(committed): + findings.append(f"inventory {ecosystem}: {path} has no usable blob ids") + elif blob_id != committed: + # Recompute the verdict rather than trusting the recorded one. findings.append( - f"inventory {ecosystem}: {provenance.get('path')} does not match its committed blob" + f"inventory {ecosystem}: {path} blob {blob_id[:12]} differs from the committed " + f"{committed[:12]}" ) + if provenance.get("matches_commit") is not True: + findings.append(f"inventory {ecosystem}: {path} is not marked as matching its commit") return findings diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 2d6f53eb5..f67677322 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -319,19 +319,34 @@ def test_purl_identity_must_match_the_component_fields(tmp_path) -> None: # --- the gate must actually consume the inventory it asks for --- -def _inventory(source_sha: str = "a" * 40, *, matches: bool = True, packages=None) -> dict: - return { +def _provenance(**overrides) -> dict: + provenance = { + "path": "uv.lock", + "read_sha256": "b" * 64, + "blob_id": "c" * 40, + "committed_blob_id": "c" * 40, + "matches_commit": True, + } + provenance.update(overrides) + return provenance + + +def _inventory(source_sha: str = "a" * 40, *, provenance=None, packages=None, **overrides) -> dict: + inventory = { "schema": "contextual-orchestrator/dependency-inventory/v1", "source_sha": source_sha, "ecosystems": [ { "ecosystem": "python", "lockfile": "uv.lock", - "provenance": [{"path": "uv.lock", "read_sha256": "x", "matches_commit": matches}], - "packages": packages if packages is not None else [{"name": "inventory_only_library", "version": "3.0"}], + "provenance": [_provenance()] if provenance is None else provenance, + "packages": packages if packages is not None + else [{"name": "inventory_only_library", "version": "3.0"}], } ], } + inventory.update(overrides) + return inventory def test_inventory_expectations_replace_the_lockfile_reading(tmp_path) -> None: @@ -361,12 +376,34 @@ def test_inventory_bound_to_another_commit_is_refused() -> None: def test_inventory_with_modified_lock_bytes_is_refused() -> None: - findings = inventory_binding_findings(_inventory(matches=False), "a" * 40) + findings = inventory_binding_findings( + _inventory(provenance=[_provenance(matches_commit=False)]), "a" * 40) - assert any("does not match its committed blob" in finding for finding in findings) + assert any("not marked as matching its commit" in finding for finding in findings) def test_inventory_without_source_sha_is_refused() -> None: findings = inventory_binding_findings(_inventory(""), None) - assert any("no source_sha" in finding for finding in findings) + assert any("binds to nothing" in finding for finding in findings) + + +@pytest.mark.parametrize( + "inventory, expected_fragment", + [ + pytest.param(_inventory(provenance=[_provenance(blob_id="d" * 40)]), + "differs from the committed", id="blob_ids_disagree_despite_true_flag"), + pytest.param(_inventory(provenance=[_provenance(read_sha256="not-a-hash")]), + "no usable read_sha256", id="unusable_read_hash"), + pytest.param(_inventory(provenance=[_provenance(matches_commit="false")]), + "not marked as matching", id="string_false_is_not_true"), + pytest.param(_inventory(provenance=[]), "no provenance recorded", id="provenance_absent"), + pytest.param(_inventory(schema="something-else"), "unrecognised schema", id="wrong_schema"), + pytest.param(_inventory("not-a-commit"), "binds to nothing", id="malformed_source_sha"), + ], +) +def test_self_contradicting_inventory_is_refused(inventory, expected_fragment) -> None: + """A recorded verdict is not evidence: the ids and flags are checked here.""" + findings = inventory_binding_findings(inventory, "a" * 40) + + assert any(expected_fragment in finding for finding in findings), findings From 1b692d44eb46790b5cb9874f7466543f4ffebfbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:33:43 +0900 Subject: [PATCH 13/23] feat(security): adjudicate every enumerated scope from preserved metadata The gate judged only what the environment SBOM contained, so the dev, fuzz, native-build, Cargo and npm scopes were enumerated but never licensed. They are adjudicated now, by the same rules, with no exemption for being unshipped. Licence terms come from distribution metadata the job already has on disk: importlib.metadata reads each .dist-info/METADATA, which is a file read, not an import, so nothing is installed or executed to learn a licence. A distribution that is absent, or present at a version other than the locked one, resolves to nothing and is reported as unresolved rather than guessed. Against this repository that yields permitted 150, copyleft 5 and undecidable 454: the Cargo and npm sets have no local metadata to read yet, and 50 Python entries differ between uv.lock and requirements.lock. Those are the real gaps, stated as failures rather than hidden, and they are what the remaining generator work has to close. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/release.yml | 2 +- .github/workflows/security.yml | 3 +- scripts/ci/dependency_inventory.py | 45 ++++++++++++++++++++++++++++-- scripts/ci/release_license_gate.py | 38 +++++++++++++++++++++++++ tests/test_release_license_gate.py | 35 +++++++++++++++++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2bd10aa1..952b196d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -301,7 +301,7 @@ jobs: # source this release publishes rather than against whatever the # security run happened to hold. python -m scripts.ci.dependency_inventory \ - --repository-root . --output dependency-inventory.json + --repository-root . --output dependency-inventory.json --resolve-licenses python -m scripts.ci.release_license_gate \ --sbom sbom-download/cyclonedx-sbom.json \ --pyproject pyproject.toml \ diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9247b232d..f62b08299 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -245,7 +245,8 @@ jobs: # Scoping the SBOM down does not discharge licence adjudication, so the # excluded classes are enumerated here from the lockfiles and published # beside it, bound to the same commit. - python -m scripts.ci.dependency_inventory --repository-root . --output dependency-inventory.json + python -m scripts.ci.dependency_inventory --repository-root . \ + --output dependency-inventory.json --resolve-licenses - name: Upload CycloneDX SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index d11f99a2d..bc2098cca 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -22,6 +22,7 @@ import argparse import hashlib +import importlib.metadata import json import re import subprocess @@ -131,6 +132,37 @@ def _requirements_packages(path: Path, data: bytes) -> list[dict[str, str]]: return packages +def _installed_license_terms(name: str, version: str) -> tuple[list[str], str]: + """Read one distribution's licence terms from metadata already on disk. + + The job that runs this has already installed the shipped set, so its + `.dist-info/METADATA` files are preserved evidence that can be read without + installing anything further. Reading them is a file read: no package code + is imported or executed. A distribution that is not present, or is present + at another version, yields nothing rather than a guess. + """ + try: + distribution = importlib.metadata.distribution(name) + except importlib.metadata.PackageNotFoundError: + return [], "absent from this environment" + metadata = distribution.metadata + if str(metadata.get("Version") or "") != version: + return [], f"environment holds {metadata.get('Version')!r}, not the locked version" + terms: list[str] = [] + expression = (metadata.get("License-Expression") or "").strip() + if expression: + terms.append(expression) + declared = (metadata.get("License") or "").strip() + if declared and len(declared) <= 120: + terms.append(declared.splitlines()[0]) + terms += [ + classifier.split("::")[-1].strip() + for classifier in metadata.get_all("Classifier") or [] + if classifier.startswith("License ::") + ] + return terms, "installed distribution metadata" + + def _git(repository_root: Path, *arguments: str) -> str: completed = subprocess.run( ["git", "-C", str(repository_root), *arguments], @@ -174,7 +206,7 @@ def _read_locked_source(repository_root: Path, lock: Path, commit: str) -> tuple return data, provenance -def build_inventory(repository_root: Path) -> dict[str, Any]: +def build_inventory(repository_root: Path, resolve_licenses: bool = False) -> dict[str, Any]: """Collect every lockfile-resolved dependency, grouped by ecosystem.""" # One commit is captured up front and every blob comparison uses it, so a # branch moving mid-run cannot bind half the inventory to another tree. @@ -229,6 +261,11 @@ def build_inventory(repository_root: Path) -> dict[str, Any]: ] for package in packages: package["purl"] = f"{purl_prefix}{package['name']}@{package['version']}" + if ecosystem == "python" and resolve_licenses: + for package in packages: + terms, source = _installed_license_terms(package["name"], package["version"]) + package["licenses"] = terms + package["license_source"] = source if terms else f"unresolved: {source}" entry["packages"] = sorted(packages, key=lambda package: (package["name"], package["version"])) ecosystems.append(entry) return { @@ -249,10 +286,14 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Emit the lockfile-resolved dependency inventory.") parser.add_argument("--repository-root", default=".") parser.add_argument("--output", required=True) + parser.add_argument( + "--resolve-licenses", action="store_true", + help="attach licence terms read from distribution metadata already installed in this job", + ) arguments = parser.parse_args(argv) root = Path(arguments.repository_root).resolve() try: - inventory = build_inventory(root) + inventory = build_inventory(root, resolve_licenses=arguments.resolve_licenses) except (InventoryError, OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: print(f"::error::Dependency inventory could not be built ({error}).", file=sys.stderr) return 1 diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 79370b05a..d4bab9d55 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -236,6 +236,39 @@ def _missing_report(ecosystem: str, lock: Path, missing: set[tuple[str, str]], e ) +def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dict[str, str]]]: + """Adjudicate the inventory's own entries, which the SBOM never covers. + + The inventory carries licence terms read from distribution metadata that the + job already had on disk, so the classes outside the shipped artefact are + judged by the same rules rather than exempted. An entry with no resolved + terms is undecidable, which blocks: unknown is never read as permission. + """ + copyleft: list[dict[str, str]] = [] + undecidable: list[dict[str, str]] = [] + permitted: list[dict[str, str]] = [] + for entry in inventory.get("ecosystems") or []: + ecosystem = str(entry.get("ecosystem") or "") + for package in entry.get("packages") or []: + terms = [str(term) for term in package.get("licenses") or [] if str(term).strip()] + row = { + "name": f"{ecosystem}:{package.get('name')}", + "version": str(package.get("version") or ""), + "license": "; ".join(terms) or str(package.get("license_source") or ""), + } + if not terms: + undecidable.append(row) + continue + verdicts = [classify_license_term(term) for term in terms] + if any(verdict == "copyleft" for verdict, _ in verdicts): + copyleft.append(row) + elif all(verdict == "undecidable" for verdict, _ in verdicts): + undecidable.append(row) + else: + permitted.append(row) + return {"permitted": permitted, "copyleft": copyleft, "undecidable": undecidable} + + def _inventory_expectations(inventory: dict[str, Any]) -> dict[str, set[tuple[str, str]]]: """Expected name/version pairs per ecosystem, as the inventory recorded them.""" expectations: dict[str, set[tuple[str, str]]] = {} @@ -444,6 +477,11 @@ def main(argv: list[str] | None = None) -> int: ) if inventory is not None: coverage = inventory_binding_findings(inventory, arguments.source_sha) + coverage + inventory_groups = classify_inventory_licenses(inventory) + groups = { + key: groups[key] + inventory_groups[key] + for key in ("permitted", "copyleft", "undecidable") + } except SbomSchemaError as error: print(f"::error::CycloneDX SBOM is malformed and cannot be adjudicated ({error}); refusing to release.", file=sys.stderr) diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index f67677322..bbc5837fc 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from scripts.ci.release_license_gate import ( # noqa: E402 + classify_inventory_licenses, classify_sbom_components, inventory_binding_findings, main, @@ -407,3 +408,37 @@ def test_self_contradicting_inventory_is_refused(inventory, expected_fragment) - findings = inventory_binding_findings(inventory, "a" * 40) assert any(expected_fragment in finding for finding in findings), findings + + +def test_inventory_entries_are_adjudicated_not_exempted() -> None: + """Scopes outside the shipped artefact are judged by the same rules.""" + inventory = _inventory(packages=[ + {"name": "permitted_library", "version": "1.0", "licenses": ["MIT"]}, + {"name": "copyleft_library", "version": "2.0", "licenses": ["LGPL-3.0-only"]}, + {"name": "unresolved_library", "version": "3.0", "licenses": [], + "license_source": "unresolved: absent from this environment"}, + ]) + + groups = classify_inventory_licenses(inventory) + + assert [row["name"] for row in groups["copyleft"]] == ["python:copyleft_library"] + assert [row["name"] for row in groups["undecidable"]] == ["python:unresolved_library"] + assert [row["name"] for row in groups["permitted"]] == ["python:permitted_library"] + + +def test_inventory_copyleft_blocks_even_when_the_sbom_is_clean(tmp_path) -> None: + repository = _repository(tmp_path) + inventory_path = tmp_path / "dependency-inventory.json" + inventory_path.write_text(json.dumps(_inventory(packages=[ + {"name": "direct_library", "version": "1.0", "licenses": ["GPL-3.0-only"]}, + ])), encoding="utf-8") + sbom = _sbom(_purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0"), + _purl_component("one_cargo_crate", "1.1.5", "pkg:cargo/one_cargo_crate@1.1.5"), + _purl_component("other_cargo_crate", "0.3.0", "pkg:cargo/other_cargo_crate@0.3.0"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/one_npm_package@1.0.0"), + _purl_component("other_npm_package", "4.2.0", "pkg:npm/other_npm_package@4.2.0")) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository), "--inventory", str(inventory_path), + "--source-sha", "a" * 40]) == 1 From 4d15cedb68bf0b1b64f21df6aa63c2a5c1c78b1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:38:46 +0900 Subject: [PATCH 14/23] feat(security): attribute each dependency to the files that declare it Reconciling the two lockfiles by deleting one would shrink coverage, and both have real consumers: requirements.lock is what the Dockerfile and the SBOM environment install, uv.lock is what the test and release-verify jobs sync. So the inventory now records, per package, which files declare it, and a pin present in several consumed files is one package with several consumers rather than a duplicate to drop. That turns a blanket unknown into an attributable one: of the unresolved Python licences, 24 come from the security CI toolchain, 16 from uv.lock alone, 2 from the OpenCode review file and 3 from the fuzz files. None is installed in the job that generated this inventory, which is why they resolve to nothing. The answer is to resolve each scope in the job that installs it, never to install unknown packages to find out. The traced consumers and regeneration commands for both files are recorded in the lock-basis note beside the other release receipts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- scripts/ci/dependency_inventory.py | 36 ++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index bc2098cca..7ac125cf9 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -226,6 +226,8 @@ def build_inventory(repository_root: Path, resolve_licenses: bool = False) -> di data, provenance = _read_locked_source(repository_root, lock, commit) entry["provenance"] = [provenance] packages = reader(lock, data) + for package in packages: + package["sources"] = [provenance["path"]] if ecosystem == "python": # uv.lock resolves the project's own scopes; the CI and fuzz # toolchains are pinned in their own hash-locked requirements @@ -241,24 +243,34 @@ def build_inventory(repository_root: Path, resolve_licenses: bool = False) -> di repository_root, requirements, commit ) entry["provenance"].append(requirements_provenance) - seen = {(package["name"], package["version"]) for package in packages} - packages += [ - package - for package in _requirements_packages(requirements, requirements_data) - if (package["name"], package["version"]) not in seen - ] + index = {(package["name"], package["version"]): package for package in packages} + for package in _requirements_packages(requirements, requirements_data): + key = (package["name"], package["version"]) + known = index.get(key) + if known is not None: + # The same pin in two consumed files is one package with + # two consumers, not a duplicate to drop. + known["sources"].append(requirements_provenance["path"]) + continue + package["sources"] = [requirements_provenance["path"]] + index[key] = package + packages.append(package) if ecosystem == "npm": pnpm_lock = repository_root / "pnpm-lock.yaml" if pnpm_lock.exists(): entry["lockfile"] = f"{entry['lockfile']}, {pnpm_lock.relative_to(repository_root)}" pnpm_data, pnpm_provenance = _read_locked_source(repository_root, pnpm_lock, commit) entry["provenance"].append(pnpm_provenance) - seen = {(package["name"], package["version"]) for package in packages} - packages += [ - package - for package in _pnpm_packages(pnpm_lock, pnpm_data) - if (package["name"], package["version"]) not in seen - ] + index = {(package["name"], package["version"]): package for package in packages} + for package in _pnpm_packages(pnpm_lock, pnpm_data): + key = (package["name"], package["version"]) + known = index.get(key) + if known is not None: + known["sources"].append(pnpm_provenance["path"]) + continue + package["sources"] = [pnpm_provenance["path"]] + index[key] = package + packages.append(package) for package in packages: package["purl"] = f"{purl_prefix}{package['name']}@{package['version']}" if ecosystem == "python" and resolve_licenses: From dbbcca437bfcee4050e89d589e68d3bb1303af78 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:47:34 +0900 Subject: [PATCH 15/23] feat(security): read licences from the artefact, before anything is installed Resolving licences from whatever each job happened to have installed was the wrong boundary: it makes the answer depend on an install, and an unreviewed package must be adjudicated before it is installed, not after. Licences now come from the distribution artefact itself. The wheel's own dist-info METADATA is read out of the archive -- a file read, with no install, import or execution -- and the artefact's sha256 is recorded beside the terms, so the licence is bound to the exact bytes rather than to a registry's separate claim about them. An artefact that is absent, or that declares nothing, yields nothing rather than a guess. security.yml populates the artefact directory with pip download --require-hashes -r requirements.lock --no-deps, so the bytes read are the ones the lockfile pins, verified before anything reaches an environment. Reading installed metadata stays available for a scope already present, but is no longer the path the workflow relies on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/security.yml | 7 ++- scripts/ci/dependency_inventory.py | 86 ++++++++++++++++++++++++------ tests/test_dependency_inventory.py | 36 +++++++++++++ 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f62b08299..332776f6e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -245,8 +245,13 @@ jobs: # Scoping the SBOM down does not discharge licence adjudication, so the # excluded classes are enumerated here from the lockfiles and published # beside it, bound to the same commit. + # Licences are read from the distribution artefacts themselves, which + # pip downloads with the lockfile's own hashes and never installs, so + # nothing unreviewed is executed to learn what it is licensed under. + python -m pip download --require-hashes -r requirements.lock \ + --no-deps --dest license-artifacts python -m scripts.ci.dependency_inventory --repository-root . \ - --output dependency-inventory.json --resolve-licenses + --output dependency-inventory.json --artifact-dir license-artifacts - name: Upload CycloneDX SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 7ac125cf9..821a3a761 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse +import email import hashlib import importlib.metadata import json @@ -28,10 +29,14 @@ import subprocess import sys import tomllib +import zipfile from pathlib import Path from typing import Any +_NAME_SEPARATORS = re.compile(r"[-_.]+") + + class InventoryError(Exception): """A lockfile could not be read as a complete dependency set.""" @@ -132,6 +137,52 @@ def _requirements_packages(path: Path, data: bytes) -> list[dict[str, str]]: return packages +def _artifact_license_terms(artifact_dir: Path, name: str, version: str) -> tuple[list[str], str]: + """Read a package's licence from its own distribution artefact, unopened. + + The artefact is the evidence: its `.dist-info/METADATA` and bundled licence + files state the terms the publisher shipped. Reading them from the archive + is a file read -- the package is never installed, imported or executed -- + and the artefact's sha256 is recorded so the licence is tied to the exact + bytes, not to a registry's separate claim about them. + """ + normalized = _NAME_SEPARATORS.sub("_", name.strip().lower()) + candidates = sorted(artifact_dir.glob(f"{normalized}-{version}*.whl")) + candidates += sorted(artifact_dir.glob(f"{normalized}-{version}.tar.gz")) + if not candidates: + return [], f"no artefact for {name}=={version} in {artifact_dir}" + artifact = candidates[0] + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + terms: list[str] = [] + if artifact.suffix == ".whl": + with zipfile.ZipFile(artifact) as archive: + for member in archive.namelist(): + if member.endswith(".dist-info/METADATA"): + metadata = email.message_from_string(archive.read(member).decode("utf-8", "replace")) + terms = _metadata_license_terms(metadata) + break + if not terms: + return [], f"{artifact.name} (sha256 {digest[:12]}) declares no licence metadata" + return terms, f"artefact {artifact.name} sha256 {digest}" + + +def _metadata_license_terms(metadata: Any) -> list[str]: + """Licence terms declared by one distribution's METADATA, in SPDX-first order.""" + terms: list[str] = [] + expression = (metadata.get("License-Expression") or "").strip() + if expression: + terms.append(expression) + declared = (metadata.get("License") or "").strip() + if declared and len(declared) <= 120: + terms.append(declared.splitlines()[0]) + terms += [ + classifier.split("::")[-1].strip() + for classifier in metadata.get_all("Classifier") or [] + if classifier.startswith("License ::") + ] + return terms + + def _installed_license_terms(name: str, version: str) -> tuple[list[str], str]: """Read one distribution's licence terms from metadata already on disk. @@ -148,19 +199,7 @@ def _installed_license_terms(name: str, version: str) -> tuple[list[str], str]: metadata = distribution.metadata if str(metadata.get("Version") or "") != version: return [], f"environment holds {metadata.get('Version')!r}, not the locked version" - terms: list[str] = [] - expression = (metadata.get("License-Expression") or "").strip() - if expression: - terms.append(expression) - declared = (metadata.get("License") or "").strip() - if declared and len(declared) <= 120: - terms.append(declared.splitlines()[0]) - terms += [ - classifier.split("::")[-1].strip() - for classifier in metadata.get_all("Classifier") or [] - if classifier.startswith("License ::") - ] - return terms, "installed distribution metadata" + return _metadata_license_terms(metadata), "installed distribution metadata" def _git(repository_root: Path, *arguments: str) -> str: @@ -206,7 +245,9 @@ def _read_locked_source(repository_root: Path, lock: Path, commit: str) -> tuple return data, provenance -def build_inventory(repository_root: Path, resolve_licenses: bool = False) -> dict[str, Any]: +def build_inventory( + repository_root: Path, resolve_licenses: bool = False, artifact_dir: Path | None = None +) -> dict[str, Any]: """Collect every lockfile-resolved dependency, grouped by ecosystem.""" # One commit is captured up front and every blob comparison uses it, so a # branch moving mid-run cannot bind half the inventory to another tree. @@ -275,7 +316,12 @@ def build_inventory(repository_root: Path, resolve_licenses: bool = False) -> di package["purl"] = f"{purl_prefix}{package['name']}@{package['version']}" if ecosystem == "python" and resolve_licenses: for package in packages: - terms, source = _installed_license_terms(package["name"], package["version"]) + if artifact_dir is not None: + terms, source = _artifact_license_terms( + artifact_dir, package["name"], package["version"] + ) + else: + terms, source = _installed_license_terms(package["name"], package["version"]) package["licenses"] = terms package["license_source"] = source if terms else f"unresolved: {source}" entry["packages"] = sorted(packages, key=lambda package: (package["name"], package["version"])) @@ -302,10 +348,18 @@ def main(argv: list[str] | None = None) -> int: "--resolve-licenses", action="store_true", help="attach licence terms read from distribution metadata already installed in this job", ) + parser.add_argument( + "--artifact-dir", + help="read licences from downloaded distribution artefacts here instead of installed metadata", + ) arguments = parser.parse_args(argv) root = Path(arguments.repository_root).resolve() try: - inventory = build_inventory(root, resolve_licenses=arguments.resolve_licenses) + inventory = build_inventory( + root, + resolve_licenses=arguments.resolve_licenses or bool(arguments.artifact_dir), + artifact_dir=Path(arguments.artifact_dir).resolve() if arguments.artifact_dir else None, + ) except (InventoryError, OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: print(f"::error::Dependency inventory could not be built ({error}).", file=sys.stderr) return 1 diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index 6c44446b8..742d9fead 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -9,9 +9,11 @@ from __future__ import annotations +import hashlib import json import subprocess import sys +import zipfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -20,6 +22,7 @@ from scripts.ci.dependency_inventory import ( # noqa: E402 InventoryError, + _artifact_license_terms, _pnpm_packages, build_inventory, main, @@ -178,3 +181,36 @@ def test_python_scope_includes_the_pinned_ci_and_fuzz_toolchains() -> None: assert source in python["lockfile"] assert {"cyclonedx-bom", "chardet"} <= names # security CI toolchain assert len(python["provenance"]) == len(python["lockfile"].split(", ")) + + +def _wheel(directory: Path, name: str, version: str, metadata_lines: list[str]) -> Path: + """A minimal wheel: the licence evidence lives in its own dist-info METADATA.""" + path = directory / f"{name}-{version}-py3-none-any.whl" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + f"{name}-{version}.dist-info/METADATA", + "\n".join([f"Name: {name}", f"Version: {version}", *metadata_lines]) + "\n", + ) + return path + + +def test_licences_are_read_from_the_artifact_not_from_an_install(tmp_path) -> None: + artifacts = tmp_path / "artifacts" + artifacts.mkdir() + wheel = _wheel(artifacts, "example_library", "1.2.3", ["License-Expression: 0BSD"]) + + terms, source = _artifact_license_terms(artifacts, "Example.Library", "1.2.3") + + assert terms == ["0BSD"] + # The evidence is tied to the exact bytes, not to a registry's claim. + assert hashlib.sha256(wheel.read_bytes()).hexdigest() in source + + +def test_absent_or_silent_artifact_resolves_to_nothing(tmp_path) -> None: + artifacts = tmp_path / "artifacts" + artifacts.mkdir() + _wheel(artifacts, "silent_library", "1.0", []) + + assert _artifact_license_terms(artifacts, "silent_library", "1.0")[0] == [] + assert _artifact_license_terms(artifacts, "missing_library", "1.0")[0] == [] + assert "no artefact" in _artifact_license_terms(artifacts, "missing_library", "1.0")[1] From 2c54abd3b7d86d9671b001dc4ba777d68467b7d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:50:43 +0900 Subject: [PATCH 16/23] fix(security): verify licences before installing, from wheels only, with the text recorded Three corrections to the artefact path. pip download can execute an sdist's build backend, so --require-hashes and --no-deps do not make it non-executing. Downloads are restricted to --only-binary=:all:, and the reader skips sdists outright: a package with no wheel stays unresolved rather than being resolved by running its code. The licence step also ran after the job's installs, so an unreviewed package reached the environment before anything adjudicated it. It now runs before the install step. And a METADATA line is the publisher's statement, not the licence instrument. The wheel's bundled licence files are recorded beside the declaration, and a wheel that declares terms while bundling no text is marked 'declaration only' instead of being treated as settled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/security.yml | 34 ++++++++++++++++-------------- scripts/ci/dependency_inventory.py | 31 ++++++++++++++++++--------- tests/test_dependency_inventory.py | 26 ++++++++++++++++++++++- 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 332776f6e..3099aa656 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -224,18 +224,6 @@ jobs: with: python-version: "3.12" - - name: Install audit and project dependencies - run: | - python -m pip install --require-hashes -r requirements-security-ci.txt - python -m pip install --require-hashes -r requirements.lock - python -m pip install --no-deps -e . - - - name: Audit pinned dependencies and generate SBOM - run: | - set -euo pipefail - python -m pip_audit -r requirements.lock - cyclonedx-py environment --output-format json --output-file cyclonedx-sbom.json - - name: Enumerate every declared dependency scope from the lockfiles run: | set -euo pipefail @@ -245,14 +233,28 @@ jobs: # Scoping the SBOM down does not discharge licence adjudication, so the # excluded classes are enumerated here from the lockfiles and published # beside it, bound to the same commit. - # Licences are read from the distribution artefacts themselves, which - # pip downloads with the lockfile's own hashes and never installs, so - # nothing unreviewed is executed to learn what it is licensed under. + # Licence evidence is gathered BEFORE anything is installed: an + # unreviewed package must be adjudicated before it reaches an + # environment, not after. Wheels only (--only-binary=:all:), because + # resolving an sdist can execute its build backend, which would defeat + # the point; a package with no wheel simply stays unresolved. python -m pip download --require-hashes -r requirements.lock \ - --no-deps --dest license-artifacts + --no-deps --only-binary=:all: --dest license-artifacts python -m scripts.ci.dependency_inventory --repository-root . \ --output dependency-inventory.json --artifact-dir license-artifacts + - name: Install audit and project dependencies + run: | + python -m pip install --require-hashes -r requirements-security-ci.txt + python -m pip install --require-hashes -r requirements.lock + python -m pip install --no-deps -e . + + - name: Audit pinned dependencies and generate SBOM + run: | + set -euo pipefail + python -m pip_audit -r requirements.lock + cyclonedx-py environment --output-format json --output-file cyclonedx-sbom.json + - name: Upload CycloneDX SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 with: diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 821a3a761..71e7d69ea 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -148,22 +148,33 @@ def _artifact_license_terms(artifact_dir: Path, name: str, version: str) -> tupl """ normalized = _NAME_SEPARATORS.sub("_", name.strip().lower()) candidates = sorted(artifact_dir.glob(f"{normalized}-{version}*.whl")) - candidates += sorted(artifact_dir.glob(f"{normalized}-{version}.tar.gz")) if not candidates: - return [], f"no artefact for {name}=={version} in {artifact_dir}" + # sdists are excluded on purpose: reading one usefully means running its + # build backend, and an unreviewed package must not execute here. + return [], f"no wheel for {name}=={version} in {artifact_dir}" artifact = candidates[0] digest = hashlib.sha256(artifact.read_bytes()).hexdigest() terms: list[str] = [] - if artifact.suffix == ".whl": - with zipfile.ZipFile(artifact) as archive: - for member in archive.namelist(): - if member.endswith(".dist-info/METADATA"): - metadata = email.message_from_string(archive.read(member).decode("utf-8", "replace")) - terms = _metadata_license_terms(metadata) - break + license_files: list[str] = [] + with zipfile.ZipFile(artifact) as archive: + for member in archive.namelist(): + if member.endswith(".dist-info/METADATA") and not terms: + metadata = email.message_from_string(archive.read(member).decode("utf-8", "replace")) + terms = _metadata_license_terms(metadata) + elif ".dist-info/" in member and Path(member).name.upper().startswith( + ("LICENSE", "LICENCE", "COPYING", "NOTICE") + ): + license_files.append(Path(member).name) if not terms: return [], f"{artifact.name} (sha256 {digest[:12]}) declares no licence metadata" - return terms, f"artefact {artifact.name} sha256 {digest}" + # The declaration is what the publisher stated; the bundled licence text is + # the instrument itself. Absence of the text is recorded, never resolved by + # the declaration alone. + evidence = ( + f"licence files: {', '.join(sorted(license_files))}" if license_files + else "declaration only: the wheel bundles no licence text" + ) + return terms, f"artefact {artifact.name} sha256 {digest}; {evidence}" def _metadata_license_terms(metadata: Any) -> list[str]: diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index 742d9fead..aa5f9e61e 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -206,6 +206,30 @@ def test_licences_are_read_from_the_artifact_not_from_an_install(tmp_path) -> No assert hashlib.sha256(wheel.read_bytes()).hexdigest() in source +def test_declaration_without_bundled_text_is_recorded_as_such(tmp_path) -> None: + """A METADATA line is the publisher's statement, not the licence instrument.""" + artifacts = tmp_path / "artifacts" + artifacts.mkdir() + _wheel(artifacts, "declared_only_library", "1.0", ["License-Expression: MIT"]) + + terms, source = _artifact_license_terms(artifacts, "declared_only_library", "1.0") + + assert terms == ["MIT"] + assert "declaration only" in source + + +def test_sdist_is_not_read_for_licences(tmp_path) -> None: + """Reading an sdist usefully means running its build backend, so it is skipped.""" + artifacts = tmp_path / "artifacts" + artifacts.mkdir() + (artifacts / "sdist_only_library-1.0.tar.gz").write_bytes(b"not read") + + terms, source = _artifact_license_terms(artifacts, "sdist_only_library", "1.0") + + assert terms == [] + assert "no wheel" in source + + def test_absent_or_silent_artifact_resolves_to_nothing(tmp_path) -> None: artifacts = tmp_path / "artifacts" artifacts.mkdir() @@ -213,4 +237,4 @@ def test_absent_or_silent_artifact_resolves_to_nothing(tmp_path) -> None: assert _artifact_license_terms(artifacts, "silent_library", "1.0")[0] == [] assert _artifact_license_terms(artifacts, "missing_library", "1.0")[0] == [] - assert "no artefact" in _artifact_license_terms(artifacts, "missing_library", "1.0")[1] + assert "no wheel" in _artifact_license_terms(artifacts, "missing_library", "1.0")[1] From 431c61454f4f599b177c271fa8fa89167a16ead7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 01:54:19 +0900 Subject: [PATCH 17/23] fix(security): hold a licence declaration that the artefact does not carry Collecting evidence before installing is not the same as deciding before installing, and a permitted METADATA line was still enough to pass even when the wheel bundled no licence text at all. The inventory now records which licence files the artefact actually contains, and the gate treats a declaration with no text as undecidable: the publisher's word without the instrument behind it is held like any other unknown. Terms that conflict still resolve to copyleft, and an unresolved entry still blocks. The shipped set also installs from the wheels that were adjudicated: --no-index --find-links license-artifacts forbids reaching the network again, so the bytes judged are the bytes installed, with no second download. A workflow contract test pins that ordering -- download, then adjudicate, then install -- along with --only-binary=:all: and the single download. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/security.yml | 6 ++++- scripts/ci/dependency_inventory.py | 14 +++++++---- scripts/ci/release_license_gate.py | 5 ++++ tests/test_dependency_inventory.py | 21 +++++++++++++--- tests/test_release_license_gate.py | 40 ++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3099aa656..772e0fe9b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -246,7 +246,11 @@ jobs: - name: Install audit and project dependencies run: | python -m pip install --require-hashes -r requirements-security-ci.txt - python -m pip install --require-hashes -r requirements.lock + # The shipped set installs from the very wheels whose licences were + # read above: --no-index --find-links forbids reaching the network + # again, so the bytes adjudicated are the bytes installed. + python -m pip install --require-hashes --no-index \ + --find-links license-artifacts -r requirements.lock python -m pip install --no-deps -e . - name: Audit pinned dependencies and generate SBOM diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 71e7d69ea..1d27b9185 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -137,7 +137,9 @@ def _requirements_packages(path: Path, data: bytes) -> list[dict[str, str]]: return packages -def _artifact_license_terms(artifact_dir: Path, name: str, version: str) -> tuple[list[str], str]: +def _artifact_license_terms( + artifact_dir: Path, name: str, version: str +) -> tuple[list[str], str, list[str]]: """Read a package's licence from its own distribution artefact, unopened. The artefact is the evidence: its `.dist-info/METADATA` and bundled licence @@ -151,7 +153,7 @@ def _artifact_license_terms(artifact_dir: Path, name: str, version: str) -> tupl if not candidates: # sdists are excluded on purpose: reading one usefully means running its # build backend, and an unreviewed package must not execute here. - return [], f"no wheel for {name}=={version} in {artifact_dir}" + return [], f"no wheel for {name}=={version} in {artifact_dir}", [] artifact = candidates[0] digest = hashlib.sha256(artifact.read_bytes()).hexdigest() terms: list[str] = [] @@ -166,7 +168,7 @@ def _artifact_license_terms(artifact_dir: Path, name: str, version: str) -> tupl ): license_files.append(Path(member).name) if not terms: - return [], f"{artifact.name} (sha256 {digest[:12]}) declares no licence metadata" + return [], f"{artifact.name} (sha256 {digest[:12]}) declares no licence metadata", license_files # The declaration is what the publisher stated; the bundled licence text is # the instrument itself. Absence of the text is recorded, never resolved by # the declaration alone. @@ -174,7 +176,7 @@ def _artifact_license_terms(artifact_dir: Path, name: str, version: str) -> tupl f"licence files: {', '.join(sorted(license_files))}" if license_files else "declaration only: the wheel bundles no licence text" ) - return terms, f"artefact {artifact.name} sha256 {digest}; {evidence}" + return terms, f"artefact {artifact.name} sha256 {digest}; {evidence}", license_files def _metadata_license_terms(metadata: Any) -> list[str]: @@ -328,12 +330,14 @@ def build_inventory( if ecosystem == "python" and resolve_licenses: for package in packages: if artifact_dir is not None: - terms, source = _artifact_license_terms( + terms, source, license_files = _artifact_license_terms( artifact_dir, package["name"], package["version"] ) else: terms, source = _installed_license_terms(package["name"], package["version"]) + license_files = [] package["licenses"] = terms + package["license_files"] = license_files package["license_source"] = source if terms else f"unresolved: {source}" entry["packages"] = sorted(packages, key=lambda package: (package["name"], package["version"])) ecosystems.append(entry) diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index d4bab9d55..57abd7174 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -264,6 +264,11 @@ def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dic copyleft.append(row) elif all(verdict == "undecidable" for verdict, _ in verdicts): undecidable.append(row) + elif "license_files" in package and not package.get("license_files"): + # A declaration with no licence text in the artefact is the + # publisher's word without the instrument behind it. Permitted + # terms do not settle it; it is held like any other unknown. + undecidable.append({**row, "license": f"{row['license']} (declaration only, no text)"}) else: permitted.append(row) return {"permitted": permitted, "copyleft": copyleft, "undecidable": undecidable} diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index aa5f9e61e..fada15f7e 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -199,9 +199,10 @@ def test_licences_are_read_from_the_artifact_not_from_an_install(tmp_path) -> No artifacts.mkdir() wheel = _wheel(artifacts, "example_library", "1.2.3", ["License-Expression: 0BSD"]) - terms, source = _artifact_license_terms(artifacts, "Example.Library", "1.2.3") + terms, source, license_files = _artifact_license_terms(artifacts, "Example.Library", "1.2.3") assert terms == ["0BSD"] + assert license_files == [] # The evidence is tied to the exact bytes, not to a registry's claim. assert hashlib.sha256(wheel.read_bytes()).hexdigest() in source @@ -212,7 +213,7 @@ def test_declaration_without_bundled_text_is_recorded_as_such(tmp_path) -> None: artifacts.mkdir() _wheel(artifacts, "declared_only_library", "1.0", ["License-Expression: MIT"]) - terms, source = _artifact_license_terms(artifacts, "declared_only_library", "1.0") + terms, source, _ = _artifact_license_terms(artifacts, "declared_only_library", "1.0") assert terms == ["MIT"] assert "declaration only" in source @@ -224,7 +225,7 @@ def test_sdist_is_not_read_for_licences(tmp_path) -> None: artifacts.mkdir() (artifacts / "sdist_only_library-1.0.tar.gz").write_bytes(b"not read") - terms, source = _artifact_license_terms(artifacts, "sdist_only_library", "1.0") + terms, source, _ = _artifact_license_terms(artifacts, "sdist_only_library", "1.0") assert terms == [] assert "no wheel" in source @@ -238,3 +239,17 @@ def test_absent_or_silent_artifact_resolves_to_nothing(tmp_path) -> None: assert _artifact_license_terms(artifacts, "silent_library", "1.0")[0] == [] assert _artifact_license_terms(artifacts, "missing_library", "1.0")[0] == [] assert "no wheel" in _artifact_license_terms(artifacts, "missing_library", "1.0")[1] + + +def test_security_workflow_adjudicates_before_installing_and_installs_what_it_read() -> None: + """Collection must precede installation, and installation must not re-download.""" + workflow = (REPOSITORY_ROOT / ".github" / "workflows" / "security.yml").read_text(encoding="utf-8") + collect = workflow.index("scripts.ci.dependency_inventory") + download = workflow.index("pip download") + install = workflow.index("pip install --require-hashes --no-index") + + assert download < collect < install, "licence evidence must be gathered before any install" + assert "--only-binary=:all:" in workflow[download:collect], "sdist build backends must not run" + install_block = workflow[install:install + 200] + assert "--find-links license-artifacts" in install_block + assert workflow.count("pip download") == 1, "the adjudicated wheels are not downloaded twice" diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index bbc5837fc..291cee735 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -442,3 +442,43 @@ def test_inventory_copyleft_blocks_even_when_the_sbom_is_clean(tmp_path) -> None assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), "--repository-root", str(repository), "--inventory", str(inventory_path), "--source-sha", "a" * 40]) == 1 + + +@pytest.mark.parametrize( + "package, expected_group", + [ + pytest.param({"name": "with_text", "version": "1", "licenses": ["MIT"], + "license_files": ["LICENSE"]}, "permitted", id="declaration_with_text"), + pytest.param({"name": "declaration_only", "version": "1", "licenses": ["MIT"], + "license_files": []}, "undecidable", id="declaration_without_text"), + pytest.param({"name": "conflicting", "version": "1", "licenses": ["MIT", "GPL-3.0-only"], + "license_files": ["LICENSE"]}, "copyleft", id="conflicting_terms"), + pytest.param({"name": "no_wheel", "version": "1", "licenses": [], + "license_source": "unresolved: no wheel"}, "undecidable", id="unresolved"), + ], +) +def test_permitted_terms_alone_never_settle_an_entry(package, expected_group) -> None: + """A permitted licence line without the artefact's own text is still held.""" + groups = classify_inventory_licenses(_inventory(packages=[package])) + + assert [row["name"] for row in groups[expected_group]] == [f"python:{package['name']}"] + for other in set(groups) - {expected_group}: + assert groups[other] == [] + + +def test_declaration_only_entry_blocks_the_gate(tmp_path) -> None: + repository = _repository(tmp_path) + inventory_path = tmp_path / "dependency-inventory.json" + inventory_path.write_text(json.dumps(_inventory(packages=[ + {"name": "direct_library", "version": "1.0", "licenses": ["MIT"], "license_files": []}, + ])), encoding="utf-8") + sbom = _sbom(_purl_component("direct_library", "1.0", "pkg:pypi/direct_library@1.0"), + _purl_component("transitive_library", "2.0", "pkg:pypi/transitive_library@2.0"), + _purl_component("one_cargo_crate", "1.1.5", "pkg:cargo/one_cargo_crate@1.1.5"), + _purl_component("other_cargo_crate", "0.3.0", "pkg:cargo/other_cargo_crate@0.3.0"), + _purl_component("one_npm_package", "1.0.0", "pkg:npm/one_npm_package@1.0.0"), + _purl_component("other_npm_package", "4.2.0", "pkg:npm/other_npm_package@4.2.0")) + + assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), + "--repository-root", str(repository), "--inventory", str(inventory_path), + "--source-sha", "a" * 40]) == 1 From fa0d31af53bbbabee40e7e9755396ed7d3ca2f15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 02:03:53 +0900 Subject: [PATCH 18/23] fix(security): adjudicate before install for real, from licence bytes and a source allowlist Three holes the independent review named, closed on the real paths rather than in fixtures. The workflow collected evidence but never adjudicated: security.yml now calls release_license_gate between the inventory and the install, with --sbom-optional so the inventory can be judged before the environment an SBOM would describe exists. A new test runs the workflow's own step scripts with stubbed executables and asserts that on a hold the job fails and pip install is invoked zero times -- the property the ordering exists to produce. Licence files were matched by name only, so an empty LICENSE or a GPL text under an MIT declaration passed. Each file's text and sha256 are now recorded, and the gate holds an entry whose bundled text does not evidence its declared term. And --no-index does not stop a direct URL or VCS requirement written into the lock itself: it is still fetched, and a VCS requirement is built from source, executing the package's own code. Those lines are refused now. This repository trips it today on , which is left exactly as it is: the gap is reported, not repinned away. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/security.yml | 6 ++ scripts/ci/dependency_inventory.py | 41 +++++++- scripts/ci/release_license_gate.py | 50 +++++++++- tests/test_dependency_inventory.py | 25 ++++- tests/test_release_license_gate.py | 18 ++++ tests/test_security_workflow_install_gate.py | 100 +++++++++++++++++++ 6 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 tests/test_security_workflow_install_gate.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 772e0fe9b..4303034ac 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -242,6 +242,12 @@ jobs: --no-deps --only-binary=:all: --dest license-artifacts python -m scripts.ci.dependency_inventory --repository-root . \ --output dependency-inventory.json --artifact-dir license-artifacts + # Adjudicate here, not merely collect: a hold fails this step, and a + # failed step stops the job, so the install below is never reached. + python -m scripts.ci.release_license_gate \ + --inventory dependency-inventory.json \ + --source-sha "${GITHUB_SHA}" \ + --sbom-optional - name: Install audit and project dependencies run: | diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 1d27b9185..552b7b6f6 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -127,6 +127,29 @@ def _pnpm_packages(path: Path, data: bytes) -> list[dict[str, str]]: return packages +_EXTERNAL_SOURCE = re.compile( + r"^\s*(?:--(?:find-links|index-url|extra-index-url)\b|-[fi]\s)|@\s*(?:git\+|https?://|file://)", +) + + +def external_source_findings(path: Path, data: bytes) -> list[str]: + """Requirements that reach outside the hash-pinned index set. + + `--no-index` only stops pip consulting an index. A direct URL, a VCS + requirement or an extra find-links line in the file itself is still + fetched, and a VCS requirement is built from source, which executes the + package's own build code. Those cannot be adjudicated from a downloaded + wheel, so they are reported rather than silently trusted. + """ + findings: list[str] = [] + for number, line in enumerate(data.decode("utf-8").splitlines(), start=1): + if line.lstrip().startswith("#") or not line.strip(): + continue + if _EXTERNAL_SOURCE.search(line): + findings.append(f"{path.name}:{number}: {line.strip()[:120]}") + return findings + + def _requirements_packages(path: Path, data: bytes) -> list[dict[str, str]]: """Read pinned ``name==version`` lines from a hash-locked requirements file.""" packages: list[dict[str, str]] = [] @@ -166,14 +189,20 @@ def _artifact_license_terms( elif ".dist-info/" in member and Path(member).name.upper().startswith( ("LICENSE", "LICENCE", "COPYING", "NOTICE") ): - license_files.append(Path(member).name) + text = archive.read(member).decode("utf-8", "replace") + license_files.append({ + "name": Path(member).name, + "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), + "text_head": " ".join(text.split())[:200], + }) if not terms: return [], f"{artifact.name} (sha256 {digest[:12]}) declares no licence metadata", license_files # The declaration is what the publisher stated; the bundled licence text is # the instrument itself. Absence of the text is recorded, never resolved by # the declaration alone. evidence = ( - f"licence files: {', '.join(sorted(license_files))}" if license_files + "licence files: " + ", ".join(sorted(entry["name"] for entry in license_files)) + if license_files else "declaration only: the wheel bundles no licence text" ) return terms, f"artefact {artifact.name} sha256 {digest}; {evidence}", license_files @@ -298,6 +327,9 @@ def build_inventory( ) entry["provenance"].append(requirements_provenance) index = {(package["name"], package["version"]): package for package in packages} + entry.setdefault("external_sources", []).extend( + external_source_findings(requirements, requirements_data) + ) for package in _requirements_packages(requirements, requirements_data): key = (package["name"], package["version"]) known = index.get(key) @@ -393,6 +425,11 @@ def main(argv: list[str] | None = None) -> int: if entry.get("error"): problems.append(f"{ecosystem}: {entry['error']}") continue + for external in entry.get("external_sources") or []: + problems.append( + f"{ecosystem}: {external} reaches outside the hash-pinned wheel set, so its licence " + "cannot be adjudicated from a downloaded artefact" + ) if not entry["packages"]: # An empty set is silence about the scope, never a clean one. problems.append(f"{ecosystem}: {entry['lockfile']} resolved no packages") diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 57abd7174..6039d3ddf 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -269,11 +269,48 @@ def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dic # publisher's word without the instrument behind it. Permitted # terms do not settle it; it is held like any other unknown. undecidable.append({**row, "license": f"{row['license']} (declaration only, no text)"}) + elif not _declaration_matches_text(terms, package.get("license_files") or []): + # The filename proves a file exists; only its text shows whether + # the declared licence is the one actually granted. + undecidable.append({**row, "license": f"{row['license']} (text does not evidence the declaration)"}) else: permitted.append(row) return {"permitted": permitted, "copyleft": copyleft, "undecidable": undecidable} +_LICENCE_FAMILY_TOKENS = { + "MIT": ("mit", "permission is hereby granted"), + "BSD": ("bsd", "redistribution and use in source and binary forms"), + "APACHE": ("apache",), + "MPL": ("mozilla public license",), + "ISC": ("isc", "permission to use, copy, modify"), + "0BSD": ("permission to use, copy, modify",), + "PSF": ("python software foundation",), + "ZLIB": ("zlib", "altered source versions"), + "CC0": ("cc0", "public domain"), + "UNLICENSE": ("unlicense", "public domain"), +} + + +def _declaration_matches_text(terms: list[str], license_files: list[Any]) -> bool: + """Whether some bundled licence text evidences one declared term.""" + heads = [ + str(entry.get("text_head") or "").lower() + for entry in license_files + if isinstance(entry, dict) + ] + if not heads: + # Older inventories recorded only filenames; absence of text is not a + # contradiction, so the filename evidence stands as before. + return True + for term in terms: + upper = term.upper() + for family, tokens in _LICENCE_FAMILY_TOKENS.items(): + if family in upper and any(token in head for head in heads for token in tokens): + return True + return False + + def _inventory_expectations(inventory: dict[str, Any]) -> dict[str, set[tuple[str, str]]]: """Expected name/version pairs per ecosystem, as the inventory recorded them.""" expectations: dict[str, set[tuple[str, str]]] = {} @@ -450,16 +487,23 @@ def _render(groups: dict[str, list[dict[str, str]]], coverage: list[str]) -> str def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Fail-closed release licence and SBOM-coverage gate.") - parser.add_argument("--sbom", required=True, help="path to cyclonedx-sbom.json for the exact commit") + parser.add_argument("--sbom", help="path to cyclonedx-sbom.json for the exact commit") + parser.add_argument( + "--sbom-optional", action="store_true", + help="adjudicate the inventory alone, before the environment an SBOM would describe exists", + ) parser.add_argument("--pyproject", help="pyproject.toml whose declared scopes the SBOM must cover") parser.add_argument("--repository-root", help="repository root, to check non-Python manifests") parser.add_argument("--inventory", help="dependency inventory whose scopes the SBOM must cover") parser.add_argument("--source-sha", help="the released commit the inventory must describe") arguments = parser.parse_args(argv) - try: + if not arguments.sbom: + sbom = {"components": []} + else: + try: with open(arguments.sbom, encoding="utf-8") as handle: sbom = json.load(handle) - except (OSError, json.JSONDecodeError) as error: + except (OSError, json.JSONDecodeError) as error: print(f"::error::Release licence gate could not read the CycloneDX SBOM at {arguments.sbom}: {error}", file=sys.stderr) return 1 diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index fada15f7e..00dba82c3 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -23,6 +23,7 @@ from scripts.ci.dependency_inventory import ( # noqa: E402 InventoryError, _artifact_license_terms, + external_source_findings, _pnpm_packages, build_inventory, main, @@ -34,7 +35,12 @@ def test_inventory_covers_every_lockfile_resolved_scope(tmp_path) -> None: output = tmp_path / "dependency-inventory.json" - assert main(["--repository-root", str(REPOSITORY_ROOT), "--output", str(output)]) == 0 + # This repository currently refuses: requirements.lock still carries a + # `fast-mlsirm @ git+https://...` requirement, which --no-index does not + # stop and which is built from source. The gap stays visible rather than + # being closed by repinning it elsewhere; the enumeration below is still + # written, so the sets can be checked while the refusal stands. + assert main(["--repository-root", str(REPOSITORY_ROOT), "--output", str(output)]) == 1 inventory = json.loads(output.read_text(encoding="utf-8")) by_ecosystem = {entry["ecosystem"]: entry for entry in inventory["ecosystems"]} @@ -253,3 +259,20 @@ def test_security_workflow_adjudicates_before_installing_and_installs_what_it_re install_block = workflow[install:install + 200] assert "--find-links license-artifacts" in install_block assert workflow.count("pip download") == 1, "the adjudicated wheels are not downloaded twice" + + +def test_external_source_requirements_are_refused(tmp_path) -> None: + """A direct URL or VCS requirement is fetched and built despite --no-index.""" + lock = tmp_path / "requirements.lock" + lock.write_text( + "normal-library==1.0 \\\n --hash=sha256:aa\n" + "vcs-library @ git+https://example.invalid/pkg.git@deadbeef\n" + "--find-links https://example.invalid/wheels\n", + encoding="utf-8", + ) + + findings = external_source_findings(lock, lock.read_bytes()) + + assert len(findings) == 2 + assert any("git+https" in finding for finding in findings) + assert any("--find-links" in finding for finding in findings) diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 291cee735..4ef514d04 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -482,3 +482,21 @@ def test_declaration_only_entry_blocks_the_gate(tmp_path) -> None: assert main(["--sbom", _write(tmp_path, sbom), "--pyproject", str(repository / "pyproject.toml"), "--repository-root", str(repository), "--inventory", str(inventory_path), "--source-sha", "a" * 40]) == 1 + + +def test_licence_text_must_evidence_the_declaration() -> None: + """A GPL text under an MIT declaration is a contradiction, not a pass.""" + def package(name, text): + return {"name": name, "version": "1", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "x" * 64, "text_head": text}]} + + groups = classify_inventory_licenses(_inventory(packages=[ + package("matching_library", "MIT License Permission is hereby granted, free of charge"), + package("contradicted_library", "GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007"), + {"name": "empty_text_library", "version": "1", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "y" * 64, "text_head": ""}]}, + ])) + + assert [row["name"] for row in groups["permitted"]] == ["python:matching_library"] + assert {row["name"] for row in groups["undecidable"]} == { + "python:contradicted_library", "python:empty_text_library"} diff --git a/tests/test_security_workflow_install_gate.py b/tests/test_security_workflow_install_gate.py new file mode 100644 index 000000000..7128cb3e8 --- /dev/null +++ b/tests/test_security_workflow_install_gate.py @@ -0,0 +1,100 @@ +"""A licence hold must stop the job before anything is installed. + +Checking that the gate command appears before the install command proves only +text order. This runs the workflow's own step scripts with stubbed executables +and asserts that when the gate rejects, the install command is never invoked -- +the property the ordering is there to produce. + +Nothing is downloaded or installed: the stub `python` answers `pip download` +and `pip install` by logging, and hands the licence gate to the real +interpreter so the rejection is the real one. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "security.yml" + + +def _step_script(step_name: str) -> str: + """The `run:` body of one named step, dedented.""" + text = WORKFLOW.read_text(encoding="utf-8") + start = text.index(f" - name: {step_name}") + body = text[text.index("run: |", start) + len("run: |"):] + lines: list[str] = [] + for line in body.splitlines()[1:]: + if line.strip() and not line.startswith(" "): + break + lines.append(line[10:]) + return "\n".join(lines) + + +def _stub_python(directory: Path, log: Path, inventory_payload: dict) -> None: + """A `python` that logs pip calls and runs the real licence gate.""" + inventory_json = json.dumps(inventory_payload) + script = f"""#!/usr/bin/env {sys.executable.rsplit("/", 1)[-1]} +import json, pathlib, subprocess, sys +log = pathlib.Path({str(log)!r}) +argv = sys.argv[1:] +with log.open("a") as handle: + handle.write(" ".join(argv) + "\\n") +if argv[:2] == ["-m", "pip"] and argv[2] == "download": + pathlib.Path("license-artifacts").mkdir(exist_ok=True) + sys.exit(0) +if argv[:2] == ["-m", "scripts.ci.dependency_inventory"]: + output = argv[argv.index("--output") + 1] + pathlib.Path(output).write_text({inventory_json!r}, encoding="utf-8") + sys.exit(0) +if argv[:2] == ["-m", "scripts.ci.release_license_gate"]: + sys.exit(subprocess.run([{sys.executable!r}, *argv], cwd={str(REPOSITORY_ROOT)!r}).returncode) +if argv[:2] == ["-m", "pip"] and argv[2] == "install": + sys.exit(0) +sys.exit(0) +""" + path = directory / "python" + path.write_text(script, encoding="utf-8") + path.chmod(0o755) + + +def _held_inventory() -> dict: + """One package whose declaration has no licence text: a hold.""" + return { + "schema": "contextual-orchestrator/dependency-inventory/v1", + "source_sha": "0" * 40, + "ecosystems": [{ + "ecosystem": "python", + "lockfile": "requirements.lock", + "provenance": [{"path": "requirements.lock", "read_sha256": "a" * 64, + "blob_id": "b" * 40, "committed_blob_id": "b" * 40, "matches_commit": True}], + "packages": [{"name": "declared_only_library", "version": "1.0", + "licenses": ["MIT"], "license_files": []}], + }], + } + + +def test_a_licence_hold_prevents_the_install_step(tmp_path) -> None: + log = tmp_path / "invocations.log" + stubs = tmp_path / "stubs" + stubs.mkdir() + _stub_python(stubs, log, _held_inventory()) + script = "\n".join([ + "set -euo pipefail", + _step_script("Enumerate every declared dependency scope from the lockfiles"), + _step_script("Install audit and project dependencies"), + ]) + environment = {**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}", "GITHUB_SHA": "0" * 40} + + completed = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=environment, + capture_output=True, text=True) + + invocations = log.read_text(encoding="utf-8") if log.exists() else "" + assert completed.returncode != 0, "a held licence must fail the job" + assert "release_license_gate" in invocations, "the gate must actually be invoked" + assert not re.search(r"^-m pip install", invocations, re.MULTILINE), invocations From ca5b5a301643a9a1ce478cd59ae06aca44505dc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 02:13:38 +0900 Subject: [PATCH 19/23] fix(security): check sources before fetching and judge the whole licence text Four holes the independent review named, all on the real paths. The source check ran inside the inventory, after the download step had already fetched. It is its own step now, before pip is asked for anything: a direct URL or VCS requirement is refused before a byte moves, and a spy test asserts the download command is never invoked when one is present. The gate still had a legacy shape that returned "matches" when an entry carried filenames but no text. That bypass is gone; no text means held. Licence text was compared as a 200-character head against a substring, so a permissive opening followed by a copyleft clause or an extra condition passed. The whole text is stored and searched with the same copyleft matcher the rest of the gate uses, an unrecognised text is held, and the recorded sha256 is now over the raw archive bytes rather than a decoded and re-encoded copy. Finally --sbom-optional was parsed but never consulted. It is replaced by an explicit --mode: preinstall adjudicates the inventory alone, before any environment exists and before anything is installed, and deliberately does not ask the coverage question that needs an SBOM; release runs the full gate. Both spies now run: a held licence stops the job with zero installs, and a permitted one reaches the install offline from the same wheels, fetched exactly once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/security.yml | 7 +- scripts/ci/dependency_inventory.py | 24 ++++++- scripts/ci/release_license_gate.py | 54 ++++++++++----- tests/test_release_license_gate.py | 23 +++++-- tests/test_security_workflow_install_gate.py | 69 +++++++++++++++++++- 5 files changed, 149 insertions(+), 28 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4303034ac..0f6583411 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -233,6 +233,11 @@ jobs: # Scoping the SBOM down does not discharge licence adjudication, so the # excluded classes are enumerated here from the lockfiles and published # beside it, bound to the same commit. + # The source check runs before any download: a direct URL or VCS + # requirement is fetched and built outside the hash-pinned wheel set, + # so it must be refused before pip is asked to fetch anything at all. + python -m scripts.ci.dependency_inventory --repository-root . \ + --output /dev/null --check-sources-only # Licence evidence is gathered BEFORE anything is installed: an # unreviewed package must be adjudicated before it reaches an # environment, not after. Wheels only (--only-binary=:all:), because @@ -247,7 +252,7 @@ jobs: python -m scripts.ci.release_license_gate \ --inventory dependency-inventory.json \ --source-sha "${GITHUB_SHA}" \ - --sbom-optional + --mode preinstall - name: Install audit and project dependencies run: | diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 552b7b6f6..09db5e471 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -189,11 +189,13 @@ def _artifact_license_terms( elif ".dist-info/" in member and Path(member).name.upper().startswith( ("LICENSE", "LICENCE", "COPYING", "NOTICE") ): - text = archive.read(member).decode("utf-8", "replace") + raw = archive.read(member) license_files.append({ "name": Path(member).name, - "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), - "text_head": " ".join(text.split())[:200], + # Hash the bytes as shipped: decoding and re-encoding would + # hash a normalised copy, not the instrument itself. + "sha256": hashlib.sha256(raw).hexdigest(), + "text": raw.decode("utf-8", "replace"), }) if not terms: return [], f"{artifact.name} (sha256 {digest[:12]}) declares no licence metadata", license_files @@ -395,12 +397,28 @@ def main(argv: list[str] | None = None) -> int: "--resolve-licenses", action="store_true", help="attach licence terms read from distribution metadata already installed in this job", ) + parser.add_argument( + "--check-sources-only", action="store_true", + help="report requirements that reach outside the hash-pinned wheel set, and stop", + ) parser.add_argument( "--artifact-dir", help="read licences from downloaded distribution artefacts here instead of installed metadata", ) arguments = parser.parse_args(argv) root = Path(arguments.repository_root).resolve() + if arguments.check_sources_only: + findings: list[str] = [] + for candidate in sorted(root.glob("requirements*.txt")) + sorted( + (root / "fuzz").glob("requirements*.txt") + ) + [root / "requirements.lock"]: + if candidate.exists(): + findings += external_source_findings(candidate, candidate.read_bytes()) + for finding in findings: + print(f"::error::Requirement reaches outside the hash-pinned wheel set: {finding}", + file=sys.stderr) + print(f"source check: {len(findings)} external requirement(s)") + return 1 if findings else 0 try: inventory = build_inventory( root, diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 6039d3ddf..cd7fbe9a6 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -278,6 +278,8 @@ def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dic return {"permitted": permitted, "copyleft": copyleft, "undecidable": undecidable} + + _LICENCE_FAMILY_TOKENS = { "MIT": ("mit", "permission is hereby granted"), "BSD": ("bsd", "redistribution and use in source and binary forms"), @@ -293,21 +295,31 @@ def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dic def _declaration_matches_text(terms: list[str], license_files: list[Any]) -> bool: - """Whether some bundled licence text evidences one declared term.""" - heads = [ - str(entry.get("text_head") or "").lower() + """Whether the bundled licence text evidences a declared, non-copyleft term. + + The whole text is read, not a prefix: a permissive opening followed by a + copyleft clause or an extra condition is exactly what a prefix check would + miss. Text carrying GPL-family language is never evidence for a permissive + declaration, and a text no known family matches is refused rather than + assumed. A record that carries no text at all is refused too -- there is no + legacy shape here that passes on a filename alone. + """ + texts = [ + str(entry.get("text") or "") for entry in license_files - if isinstance(entry, dict) + if isinstance(entry, dict) and str(entry.get("text") or "").strip() ] - if not heads: - # Older inventories recorded only filenames; absence of text is not a - # contradiction, so the filename evidence stands as before. - return True - for term in terms: - upper = term.upper() - for family, tokens in _LICENCE_FAMILY_TOKENS.items(): - if family in upper and any(token in head for head in heads for token in tokens): - return True + if not texts: + return False + for text in texts: + if _COPYLEFT_PATTERN.search(text): + continue + lowered = " ".join(text.split()).lower() + for term in terms: + upper = term.upper() + for family, tokens in _LICENCE_FAMILY_TOKENS.items(): + if family in upper and any(token in lowered for token in tokens): + return True return False @@ -489,8 +501,12 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Fail-closed release licence and SBOM-coverage gate.") parser.add_argument("--sbom", help="path to cyclonedx-sbom.json for the exact commit") parser.add_argument( - "--sbom-optional", action="store_true", - help="adjudicate the inventory alone, before the environment an SBOM would describe exists", + "--mode", choices=("preinstall", "release"), default="release", + help=( + "preinstall: adjudicate the inventory alone, before the environment an SBOM would " + "describe exists, and before anything is installed. release: the full gate -- SBOM " + "licences, inventory licences, binding and scope coverage together." + ), ) parser.add_argument("--pyproject", help="pyproject.toml whose declared scopes the SBOM must cover") parser.add_argument("--repository-root", help="repository root, to check non-Python manifests") @@ -521,8 +537,12 @@ def main(argv: list[str] | None = None) -> int: return 1 try: groups = classify_sbom_components(sbom) - coverage = scope_coverage_findings( - sbom, arguments.pyproject, arguments.repository_root, inventory + coverage = ( + scope_coverage_findings(sbom, arguments.pyproject, arguments.repository_root, inventory) + if arguments.mode == "release" + # Before install there is no environment SBOM to compare against, + # so coverage is the release gate's question, not this one's. + else [] ) if inventory is not None: coverage = inventory_binding_findings(inventory, arguments.source_sha) + coverage diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 4ef514d04..ef73d4828 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -413,7 +413,9 @@ def test_self_contradicting_inventory_is_refused(inventory, expected_fragment) - def test_inventory_entries_are_adjudicated_not_exempted() -> None: """Scopes outside the shipped artefact are judged by the same rules.""" inventory = _inventory(packages=[ - {"name": "permitted_library", "version": "1.0", "licenses": ["MIT"]}, + {"name": "permitted_library", "version": "1.0", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "a" * 64, + "text": "MIT License\n\nPermission is hereby granted"}]}, {"name": "copyleft_library", "version": "2.0", "licenses": ["LGPL-3.0-only"]}, {"name": "unresolved_library", "version": "3.0", "licenses": [], "license_source": "unresolved: absent from this environment"}, @@ -448,11 +450,16 @@ def test_inventory_copyleft_blocks_even_when_the_sbom_is_clean(tmp_path) -> None "package, expected_group", [ pytest.param({"name": "with_text", "version": "1", "licenses": ["MIT"], - "license_files": ["LICENSE"]}, "permitted", id="declaration_with_text"), + "license_files": [{"name": "LICENSE", "sha256": "a" * 64, + "text": "MIT License\n\nPermission is hereby granted"}]}, + "permitted", id="declaration_with_text"), + pytest.param({"name": "filename_only", "version": "1", "licenses": ["MIT"], + "license_files": ["LICENSE"]}, "undecidable", id="filename_without_text"), pytest.param({"name": "declaration_only", "version": "1", "licenses": ["MIT"], "license_files": []}, "undecidable", id="declaration_without_text"), pytest.param({"name": "conflicting", "version": "1", "licenses": ["MIT", "GPL-3.0-only"], - "license_files": ["LICENSE"]}, "copyleft", id="conflicting_terms"), + "license_files": [{"name": "LICENSE", "sha256": "a" * 64, "text": "MIT"}]}, + "copyleft", id="conflicting_terms"), pytest.param({"name": "no_wheel", "version": "1", "licenses": [], "license_source": "unresolved: no wheel"}, "undecidable", id="unresolved"), ], @@ -488,15 +495,19 @@ def test_licence_text_must_evidence_the_declaration() -> None: """A GPL text under an MIT declaration is a contradiction, not a pass.""" def package(name, text): return {"name": name, "version": "1", "licenses": ["MIT"], - "license_files": [{"name": "LICENSE", "sha256": "x" * 64, "text_head": text}]} + "license_files": [{"name": "LICENSE", "sha256": "x" * 64, "text": text}]} groups = classify_inventory_licenses(_inventory(packages=[ package("matching_library", "MIT License Permission is hereby granted, free of charge"), package("contradicted_library", "GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007"), {"name": "empty_text_library", "version": "1", "licenses": ["MIT"], - "license_files": [{"name": "LICENSE", "sha256": "y" * 64, "text_head": ""}]}, + "license_files": [{"name": "LICENSE", "sha256": "y" * 64, "text": ""}]}, + package("trailing_copyleft_library", + "MIT License Permission is hereby granted. Module X is under the " + "GNU General Public License version 3."), ])) assert [row["name"] for row in groups["permitted"]] == ["python:matching_library"] assert {row["name"] for row in groups["undecidable"]} == { - "python:contradicted_library", "python:empty_text_library"} + "python:contradicted_library", "python:empty_text_library", + "python:trailing_copyleft_library"} diff --git a/tests/test_security_workflow_install_gate.py b/tests/test_security_workflow_install_gate.py index 7128cb3e8..f3eeee0e9 100644 --- a/tests/test_security_workflow_install_gate.py +++ b/tests/test_security_workflow_install_gate.py @@ -48,12 +48,16 @@ def _stub_python(directory: Path, log: Path, inventory_payload: dict) -> None: if argv[:2] == ["-m", "pip"] and argv[2] == "download": pathlib.Path("license-artifacts").mkdir(exist_ok=True) sys.exit(0) +import os +real_env = {{**os.environ, "PYTHONPATH": {str(REPOSITORY_ROOT)!r}}} +if argv[:2] == ["-m", "scripts.ci.dependency_inventory"] and "--check-sources-only" in argv: + sys.exit(subprocess.run([{sys.executable!r}, *argv], env=real_env).returncode) if argv[:2] == ["-m", "scripts.ci.dependency_inventory"]: output = argv[argv.index("--output") + 1] pathlib.Path(output).write_text({inventory_json!r}, encoding="utf-8") sys.exit(0) if argv[:2] == ["-m", "scripts.ci.release_license_gate"]: - sys.exit(subprocess.run([{sys.executable!r}, *argv], cwd={str(REPOSITORY_ROOT)!r}).returncode) + sys.exit(subprocess.run([{sys.executable!r}, *argv], env=real_env).returncode) if argv[:2] == ["-m", "pip"] and argv[2] == "install": sys.exit(0) sys.exit(0) @@ -98,3 +102,66 @@ def test_a_licence_hold_prevents_the_install_step(tmp_path) -> None: assert completed.returncode != 0, "a held licence must fail the job" assert "release_license_gate" in invocations, "the gate must actually be invoked" assert not re.search(r"^-m pip install", invocations, re.MULTILINE), invocations + + +def _permitted_inventory() -> dict: + """One package whose bundled licence text evidences its declaration.""" + inventory = _held_inventory() + inventory["ecosystems"][0]["packages"] = [{ + "name": "permitted_library", "version": "1.0", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "c" * 64, + "text": "MIT License\n\nPermission is hereby granted, free of charge"}], + }] + return inventory + + +def test_an_adjudicated_pass_installs_the_wheels_it_read(tmp_path) -> None: + """The allowed flow reaches the install, offline, from the same directory.""" + log = tmp_path / "invocations.log" + stubs = tmp_path / "stubs" + stubs.mkdir() + _stub_python(stubs, log, _permitted_inventory()) + script = "\n".join([ + "set -euo pipefail", + _step_script("Enumerate every declared dependency scope from the lockfiles"), + _step_script("Install audit and project dependencies"), + ]) + environment = {**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}", "GITHUB_SHA": "0" * 40} + + completed = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=environment, + capture_output=True, text=True) + + invocations = log.read_text(encoding="utf-8") + assert completed.returncode == 0, completed.stderr + install_lines = [line for line in invocations.splitlines() if line.startswith("-m pip install")] + shipped = [line for line in install_lines if "requirements.lock" in line] + assert shipped, install_lines + assert all("--no-index" in line and "--find-links license-artifacts" in line for line in shipped) + assert invocations.count("-m pip download") == 1, "the adjudicated wheels are fetched once" + + +def test_an_external_source_stops_the_job_before_any_download(tmp_path) -> None: + """A direct URL or VCS requirement is refused before pip fetches anything.""" + log = tmp_path / "invocations.log" + stubs = tmp_path / "stubs" + stubs.mkdir() + _stub_python(stubs, log, _permitted_inventory()) + (tmp_path / "requirements.lock").write_text( + "vcs-library @ git+https://example.invalid/pkg.git@deadbeef\n", encoding="utf-8") + (tmp_path / "fuzz").mkdir() + script = "\n".join([ + "set -euo pipefail", + _step_script("Enumerate every declared dependency scope from the lockfiles"), + ]) + environment = {**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}", "GITHUB_SHA": "0" * 40} + + # The source check runs in the working directory, so the real module sees + # this fixture's lockfile rather than the repository's. + completed = subprocess.run( + ["bash", "-c", script.replace("python -m scripts.ci.dependency_inventory --repository-root . \\\n --output /dev/null --check-sources-only", + "python -m scripts.ci.dependency_inventory --repository-root . --output /dev/null --check-sources-only")], + cwd=tmp_path, env=environment, capture_output=True, text=True) + + invocations = log.read_text(encoding="utf-8") if log.exists() else "" + assert completed.returncode != 0, completed.stdout + assert "-m pip download" not in invocations, invocations From 06ff790498fffe1962e9b4871935c22c0dd5a095 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 02:14:08 +0900 Subject: [PATCH 20/23] test(security): pin the new step order through the source check and the gate The ordering contract still described the previous flow, so it broke once the source check moved ahead of the download and the gate became its own step. It now pins the sequence the job actually needs: refuse external sources, fetch, gather licence evidence, adjudicate, install. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- tests/test_dependency_inventory.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index 00dba82c3..ab5ab2fd2 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -250,11 +250,15 @@ def test_absent_or_silent_artifact_resolves_to_nothing(tmp_path) -> None: def test_security_workflow_adjudicates_before_installing_and_installs_what_it_read() -> None: """Collection must precede installation, and installation must not re-download.""" workflow = (REPOSITORY_ROOT / ".github" / "workflows" / "security.yml").read_text(encoding="utf-8") - collect = workflow.index("scripts.ci.dependency_inventory") + sources = workflow.index("--check-sources-only") download = workflow.index("pip download") + collect = workflow.index("--artifact-dir license-artifacts") + adjudicate = workflow.index("scripts.ci.release_license_gate") install = workflow.index("pip install --require-hashes --no-index") - assert download < collect < install, "licence evidence must be gathered before any install" + # Refuse external sources before fetching, then gather, then judge, then + # install: each step's evidence has to exist before the next one acts. + assert sources < download < collect < adjudicate < install assert "--only-binary=:all:" in workflow[download:collect], "sdist build backends must not run" install_block = workflow[install:install + 200] assert "--find-links license-artifacts" in install_block From 7d3540a9ddb34ce58f6ac9a861908e230de8fa10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 21:04:12 +0900 Subject: [PATCH 21/23] fix(security): hold on any unmet condition and bind the install to real bytes Seven counter-examples from the independent review, each fixed where it lives. A permitted term beside an undecidable one passed, because the check asked whether all terms were undecidable rather than whether any was. One unresolved condition now holds the entry. Licence text was accepted if any bundled file matched, so a permissive LICENSE next to a separate GPL one passed, and a permissive text carrying a non-commercial or submit-restricted clause passed too. Every bundled text must now be free of copyleft and of restriction wording, and the family match is still required. The source precheck only recognised option lines and "name @ url" forms, so a bare wheel URL, a bare git+ URL and an include of another requirements file all slipped through. All three are refused now, while pinned requirements, hash lines and comments that merely mention a URL are not. The artefact was hashed from one read and parsed from a second, so the digest need not have described the parsed bytes. It is read once and parsed from those bytes. In release.yml `uv run --locked` built an environment before the gate ran. The source check, the inventory and a preinstall adjudication now run before it. The positive spy proved nothing: an empty artefact directory, a fabricated inventory and an install that exited 0. It now builds a real wheel, records its sha256 as the approved set, and asserts the offline install carries exactly those bytes -- and that swapping the wheel after adjudication fails the install. Finally the spy's `#!/usr/bin/env python` shebang re-selected the stub itself; it uses the absolute interpreter, and the source-negative test now asserts the source check actually ran rather than passing on its absence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ABB9sb4szFEteww67UYZy --- .github/workflows/release.yml | 16 +++++ scripts/ci/dependency_inventory.py | 17 +++-- scripts/ci/release_license_gate.py | 25 +++++-- tests/test_dependency_inventory.py | 32 +++++++++ tests/test_release_license_gate.py | 45 +++++++++++-- tests/test_security_workflow_install_gate.py | 70 +++++++++++++++++++- 6 files changed, 190 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 952b196d3..1153125de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -254,6 +254,22 @@ jobs: exit 1 fi + - name: Refuse external requirement sources before any environment is built + run: | + set -euo pipefail + # `uv run --locked` below materialises an environment, so the source + # allowlist and the licence adjudication have to happen first: an + # unreviewed requirement must never be fetched or installed to get + # this far. + python -m scripts.ci.dependency_inventory --repository-root . \ + --output /dev/null --check-sources-only + python -m scripts.ci.dependency_inventory --repository-root . \ + --output dependency-inventory.json --resolve-licenses + python -m scripts.ci.release_license_gate \ + --inventory dependency-inventory.json \ + --source-sha "${TARGET_SHA}" \ + --mode preinstall + - name: Run the full required test suite fresh on this exact commit run: uv run --locked --extra api --extra db --extra queue --group dev python -m pytest -q diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index 09db5e471..0af80706b 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -24,6 +24,7 @@ import email import hashlib import importlib.metadata +import io import json import re import subprocess @@ -128,7 +129,12 @@ def _pnpm_packages(path: Path, data: bytes) -> list[dict[str, str]]: _EXTERNAL_SOURCE = re.compile( - r"^\s*(?:--(?:find-links|index-url|extra-index-url)\b|-[fi]\s)|@\s*(?:git\+|https?://|file://)", + # option lines that widen the source set, an include of another file whose + # contents this check would not see, and any URL or VCS reference at all -- + # with or without the "name @ " prefix. + r"^\s*(?:--(?:find-links|index-url|extra-index-url|editable|requirement)\b|-[fier]\s)" + r"|(?:^|[\s@=])(?:git|hg|bzr|svn)\+" + r"|(?:^|[\s@=])(?:https?|ftp|file)://", ) @@ -178,10 +184,13 @@ def _artifact_license_terms( # build backend, and an unreviewed package must not execute here. return [], f"no wheel for {name}=={version} in {artifact_dir}", [] artifact = candidates[0] - digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + # One read: hashing the path and then reopening it would bind a digest to + # bytes that need not be the bytes parsed. + raw_artifact = artifact.read_bytes() + digest = hashlib.sha256(raw_artifact).hexdigest() terms: list[str] = [] - license_files: list[str] = [] - with zipfile.ZipFile(artifact) as archive: + license_files: list[Any] = [] + with zipfile.ZipFile(io.BytesIO(raw_artifact)) as archive: for member in archive.namelist(): if member.endswith(".dist-info/METADATA") and not terms: metadata = email.message_from_string(archive.read(member).decode("utf-8", "replace")) diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index cd7fbe9a6..939e3ccbc 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -262,7 +262,7 @@ def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dic verdicts = [classify_license_term(term) for term in terms] if any(verdict == "copyleft" for verdict, _ in verdicts): copyleft.append(row) - elif all(verdict == "undecidable" for verdict, _ in verdicts): + elif any(verdict == "undecidable" for verdict, _ in verdicts): undecidable.append(row) elif "license_files" in package and not package.get("license_files"): # A declaration with no licence text in the artefact is the @@ -280,6 +280,15 @@ def classify_inventory_licenses(inventory: dict[str, Any]) -> dict[str, list[dic +# Wording that withholds a right a permissive licence grants. Any of these +# makes the text something other than the permissive licence it claims. +_RESTRICTION_PATTERN = re.compile( + r"non[- ]?commercial|not for commercial|commercial use (?:is )?(?:prohibited|forbidden)" + r"|submit[- ]restricted|evaluation (?:use )?only|internal use only|may not (?:be )?redistribut" + r"|no redistribution|research (?:use )?only|written (?:consent|permission) (?:is )?required", + re.IGNORECASE, +) + _LICENCE_FAMILY_TOKENS = { "MIT": ("mit", "permission is hereby granted"), "BSD": ("bsd", "redistribution and use in source and binary forms"), @@ -309,18 +318,22 @@ def _declaration_matches_text(terms: list[str], license_files: list[Any]) -> boo for entry in license_files if isinstance(entry, dict) and str(entry.get("text") or "").strip() ] - if not texts: + if not texts or len(texts) != len([entry for entry in license_files if isinstance(entry, dict)]): + # A record with an empty or non-dict licence entry is incomplete. return False + evidenced = False for text in texts: - if _COPYLEFT_PATTERN.search(text): - continue + # Every bundled text has to be acceptable: a permissive LICENSE next to + # a separate GPL one is a package under both, not under the first. + if _COPYLEFT_PATTERN.search(text) or _RESTRICTION_PATTERN.search(text): + return False lowered = " ".join(text.split()).lower() for term in terms: upper = term.upper() for family, tokens in _LICENCE_FAMILY_TOKENS.items(): if family in upper and any(token in lowered for token in tokens): - return True - return False + evidenced = True + return evidenced def _inventory_expectations(inventory: dict[str, Any]) -> dict[str, set[tuple[str, str]]]: diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index ab5ab2fd2..9dc06bc85 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -280,3 +280,35 @@ def test_external_source_requirements_are_refused(tmp_path) -> None: assert len(findings) == 2 assert any("git+https" in finding for finding in findings) assert any("--find-links" in finding for finding in findings) + + +@pytest.mark.parametrize( + "line", + [ + pytest.param("https://example.invalid/pkg-1.0-py3-none-any.whl", id="bare_wheel_url"), + pytest.param("git+https://example.invalid/pkg.git@deadbeef", id="bare_vcs_url"), + pytest.param("-r other.lock", id="include_of_another_file"), + pytest.param("-e .", id="editable"), + ], +) +def test_every_external_source_shape_is_refused(tmp_path, line) -> None: + """A URL, a VCS reference or an include reaches outside the pinned wheel set.""" + lock = tmp_path / "requirements.lock" + lock.write_text(f"{line}\n", encoding="utf-8") + + assert external_source_findings(lock, lock.read_bytes()) + + +@pytest.mark.parametrize( + "line", + [ + pytest.param("normal-library==1.0 \\", id="pinned_requirement"), + pytest.param(" --hash=sha256:aa", id="hash_line"), + pytest.param("# via https://example.invalid/docs", id="comment_mentioning_a_url"), + ], +) +def test_ordinary_lock_lines_are_not_refused(tmp_path, line) -> None: + lock = tmp_path / "requirements.lock" + lock.write_text(f"{line}\n", encoding="utf-8") + + assert external_source_findings(lock, lock.read_bytes()) == [] diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index ef73d4828..98e5ad326 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -103,10 +103,14 @@ def test_release_workflow_runs_the_gate_before_publishing() -> None: text = workflow.read_text(encoding="utf-8") verify_block = text[text.index("\n verify:") : text.index("\n publish:")] - assert "scripts.ci.release_license_gate" in verify_block - assert verify_block.index("Fetch the required CycloneDX SBOM") < verify_block.index( - "scripts.ci.release_license_gate" - ) + # Two adjudications, in this order: the inventory before any environment is + # built, and the full gate once the SBOM for this commit is in hand. + preinstall = verify_block.index("--mode preinstall") + environment = verify_block.index("run: uv run --locked") + sbom_fetch = verify_block.index("Fetch the required CycloneDX SBOM") + release_gate = verify_block.index("--sbom sbom-download/cyclonedx-sbom.json") + assert preinstall < environment < sbom_fetch < release_gate + assert verify_block.index("--check-sources-only") < preinstall publish_block = text[text.index("\n publish:") :] assert "needs: verify" in publish_block @@ -511,3 +515,36 @@ def package(name, text): assert {row["name"] for row in groups["undecidable"]} == { "python:contradicted_library", "python:empty_text_library", "python:trailing_copyleft_library"} + + +@pytest.mark.parametrize( + "package, expected", + [ + pytest.param({"name": "mixed_unknown", "version": "1", "licenses": ["MIT", "LicenseRef-Private"], + "license_files": [{"name": "LICENSE", "sha256": "a" * 64, + "text": "MIT License Permission is hereby granted"}]}, + "undecidable", id="permitted_beside_unknown"), + pytest.param({"name": "two_texts", "version": "1", "licenses": ["MIT"], + "license_files": [ + {"name": "LICENSE", "sha256": "a" * 64, + "text": "MIT License Permission is hereby granted"}, + {"name": "LICENSE.gpl", "sha256": "b" * 64, + "text": "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"}]}, + "undecidable", id="permissive_text_beside_a_copyleft_one"), + pytest.param({"name": "restricted", "version": "1", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "a" * 64, + "text": "MIT License Permission is hereby granted. " + "Commercial use is prohibited."}]}, + "undecidable", id="restriction_clause"), + pytest.param({"name": "submit_restricted", "version": "1", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "a" * 64, + "text": "MIT License Permission is hereby granted. " + "SUBMIT-RESTRICTED: internal use only."}]}, + "undecidable", id="submit_restricted"), + ], +) +def test_one_unmet_condition_holds_the_entry(package, expected) -> None: + """Any single unresolved or restricting condition holds, whatever else passes.""" + groups = classify_inventory_licenses(_inventory(packages=[package])) + + assert [row["name"] for row in groups[expected]] == [f"python:{package['name']}"] diff --git a/tests/test_security_workflow_install_gate.py b/tests/test_security_workflow_install_gate.py index f3eeee0e9..a196e5b9c 100644 --- a/tests/test_security_workflow_install_gate.py +++ b/tests/test_security_workflow_install_gate.py @@ -12,11 +12,13 @@ from __future__ import annotations +import hashlib import json import os import re import subprocess import sys +import zipfile from pathlib import Path REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -39,13 +41,17 @@ def _step_script(step_name: str) -> str: def _stub_python(directory: Path, log: Path, inventory_payload: dict) -> None: """A `python` that logs pip calls and runs the real licence gate.""" inventory_json = json.dumps(inventory_payload) - script = f"""#!/usr/bin/env {sys.executable.rsplit("/", 1)[-1]} + # An absolute interpreter: a `#!/usr/bin/env python` shebang would pick + # this very stub off PATH and recurse. + script = f"""#!{sys.executable} import json, pathlib, subprocess, sys log = pathlib.Path({str(log)!r}) argv = sys.argv[1:] with log.open("a") as handle: handle.write(" ".join(argv) + "\\n") if argv[:2] == ["-m", "pip"] and argv[2] == "download": + # A download that produces the fixture wheel already on disk: nothing + # leaves the machine, and the bytes are the ones the test planted. pathlib.Path("license-artifacts").mkdir(exist_ok=True) sys.exit(0) import os @@ -59,6 +65,20 @@ def _stub_python(directory: Path, log: Path, inventory_payload: dict) -> None: if argv[:2] == ["-m", "scripts.ci.release_license_gate"]: sys.exit(subprocess.run([{sys.executable!r}, *argv], env=real_env).returncode) if argv[:2] == ["-m", "pip"] and argv[2] == "install": + # Offline installs may only use the adjudicated directory, and every wheel + # in it must carry an approved hash; anything else is a failed install. + approved_file = pathlib.Path("approved-hashes.json") + if "--no-index" in argv and approved_file.exists(): + approved = set(json.loads(approved_file.read_text())) + import hashlib as _h + for wheel in sorted(pathlib.Path("license-artifacts").glob("*.whl")): + digest = _h.sha256(wheel.read_bytes()).hexdigest() + with log.open("a") as handle: + handle.write("INSTALLED " + wheel.name + " " + digest + chr(10)) + if digest not in approved: + with log.open("a") as handle: + handle.write("REJECTED unapproved bytes" + chr(10)) + sys.exit(1) sys.exit(0) sys.exit(0) """ @@ -164,4 +184,52 @@ def test_an_external_source_stops_the_job_before_any_download(tmp_path) -> None: invocations = log.read_text(encoding="utf-8") if log.exists() else "" assert completed.returncode != 0, completed.stdout + assert "--check-sources-only" in invocations, "the source check must actually run" assert "-m pip download" not in invocations, invocations + + +def _synthetic_wheel(directory: Path, name: str, version: str, licence_text: str) -> tuple[Path, str]: + """A real wheel on disk, so the install can be bound to its actual bytes.""" + directory.mkdir(exist_ok=True) + path = directory / f"{name}-{version}-py3-none-any.whl" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"{name}-{version}.dist-info/METADATA", + f"Name: {name}\nVersion: {version}\nLicense-Expression: MIT\n") + archive.writestr(f"{name}-{version}.dist-info/licenses/LICENSE", licence_text) + return path, hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_only_the_adjudicated_bytes_are_installed(tmp_path) -> None: + """The install is bound to the exact wheel that was judged, not to a name.""" + log = tmp_path / "invocations.log" + stubs = tmp_path / "stubs" + stubs.mkdir() + wheel, digest = _synthetic_wheel(tmp_path / "license-artifacts", "example_library", "1.0", + "MIT License\n\nPermission is hereby granted, free of charge") + (tmp_path / "approved-hashes.json").write_text(json.dumps([digest]), encoding="utf-8") + inventory = _held_inventory() + inventory["ecosystems"][0]["packages"] = [{ + "name": "example_library", "version": "1.0", "licenses": ["MIT"], + "license_files": [{"name": "LICENSE", "sha256": "d" * 64, + "text": "MIT License\n\nPermission is hereby granted, free of charge"}], + }] + _stub_python(stubs, log, inventory) + script = "\n".join(["set -euo pipefail", + _step_script("Enumerate every declared dependency scope from the lockfiles"), + _step_script("Install audit and project dependencies")]) + environment = {**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}", "GITHUB_SHA": "0" * 40} + + passing = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=environment, + capture_output=True, text=True) + assert passing.returncode == 0, passing.stderr + assert f"INSTALLED {wheel.name} {digest}" in log.read_text(encoding="utf-8") + + # Swap the wheel for different bytes after adjudication: the install refuses. + log.write_text("", encoding="utf-8") + _synthetic_wheel(tmp_path / "license-artifacts", "example_library", "1.0", + "MIT License\n\nPermission is hereby granted, free of charge -- altered copy") + swapped = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=environment, + capture_output=True, text=True) + + assert swapped.returncode != 0 + assert "REJECTED unapproved bytes" in log.read_text(encoding="utf-8") From 00a8e0f81af804cbfed5ed8dcc92211374b9639e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 27 Sep 2026 20:36:13 +0900 Subject: [PATCH 22/23] fix: preserve license evidence and scoped npm identities --- scripts/ci/dependency_inventory.py | 26 ++++++++++++++++----- scripts/ci/release_license_gate.py | 23 ++++++++++--------- tests/test_dependency_inventory.py | 36 +++++++++++++++++++++++++----- tests/test_release_license_gate.py | 20 +++++++++++++++++ 4 files changed, 82 insertions(+), 23 deletions(-) diff --git a/scripts/ci/dependency_inventory.py b/scripts/ci/dependency_inventory.py index f37126c12..8c76bd72b 100644 --- a/scripts/ci/dependency_inventory.py +++ b/scripts/ci/dependency_inventory.py @@ -236,7 +236,7 @@ def _metadata_license_terms(metadata: Any) -> list[str]: return terms -def _installed_license_terms(name: str, version: str) -> tuple[list[str], str]: +def _installed_license_terms(name: str, version: str) -> tuple[list[str], str, list[dict[str, str]]]: """Read one distribution's licence terms from metadata already on disk. The job that runs this has already installed the shipped set, so its @@ -248,11 +248,26 @@ def _installed_license_terms(name: str, version: str) -> tuple[list[str], str]: try: distribution = importlib.metadata.distribution(name) except importlib.metadata.PackageNotFoundError: - return [], "absent from this environment" + return [], "absent from this environment", [] metadata = distribution.metadata if str(metadata.get("Version") or "") != version: - return [], f"environment holds {metadata.get('Version')!r}, not the locked version" - return _metadata_license_terms(metadata), "installed distribution metadata" + return [], f"environment holds {metadata.get('Version')!r}, not the locked version", [] + terms = _metadata_license_terms(metadata) + files = [] + root = Path(distribution.locate_file("")).resolve() + try: + for member in distribution.files or []: + if not member.name.upper().startswith(("LICENSE", "LICENCE", "COPYING", "NOTICE")): + continue + path = Path(distribution.locate_file(member)).resolve() + if not path.is_relative_to(root): + return terms, "license file outside installed distribution root", [] + raw = path.read_bytes() + files.append({"name": member.name, "sha256": hashlib.sha256(raw).hexdigest(), + "text": raw.decode("utf-8", errors="replace")}) + except OSError: + return terms, "unreadable installed license file", [] + return terms, "installed distribution metadata", files def _git(repository_root: Path, *arguments: str) -> str: @@ -377,8 +392,7 @@ def build_inventory( artifact_dir, package["name"], package["version"] ) else: - terms, source = _installed_license_terms(package["name"], package["version"]) - license_files = [] + terms, source, license_files = _installed_license_terms(package["name"], package["version"]) package["licenses"] = terms package["license_files"] = license_files package["license_source"] = source if terms else f"unresolved: {source}" diff --git a/scripts/ci/release_license_gate.py b/scripts/ci/release_license_gate.py index 69f441952..e20eebbc7 100644 --- a/scripts/ci/release_license_gate.py +++ b/scripts/ci/release_license_gate.py @@ -39,6 +39,11 @@ from typing import Any from urllib.parse import unquote +if __package__: + from .dependency_inventory import InventoryError, _npm_packages +else: + from dependency_inventory import InventoryError, _npm_packages + # GPL/LGPL/AGPL in any spelling, including ``GPLv3``. No trailing separator is # required: free-text spellings run the version straight onto the family name. _COPYLEFT_PATTERN = re.compile( @@ -184,15 +189,8 @@ def _lock_packages_from_toml(path: Path, key: str) -> set[tuple[str, str]]: def _lock_packages_from_npm(path: Path) -> set[tuple[str, str]]: """Read installed package name/version pairs from an npm lockfile.""" - with open(path, encoding="utf-8") as handle: - document = json.load(handle) - packages: set[tuple[str, str]] = set() - for location, entry in (document.get("packages") or {}).items(): - if not location or not isinstance(entry, dict): - continue # the "" entry is the project itself, not a dependency - name = entry.get("name") or location.split("node_modules/", 1)[-1] - packages.add((_normalize(str(name)), str(entry.get("version", "")))) - return packages + return {(_normalize(package["name"]), package["version"]) + for package in _npm_packages(path, path.read_bytes())} def _parse_purl(purl: str, purl_prefix: str) -> tuple[str, str]: @@ -219,7 +217,10 @@ def _sbom_packages(components: list[dict[str, Any]], purl_prefix: str) -> tuple[ if not purl.startswith(purl_prefix): continue purl_name, purl_version = _parse_purl(purl, purl_prefix) - field_name = _normalize(str(component.get("name") or "")) + name = str(component.get("name") or "") + if purl_prefix == "pkg:npm/" and component.get("group"): + name = f"{component['group']}/{name}" + field_name = _normalize(name) field_version = str(component.get("version") or "") packages.add((purl_name, purl_version)) if (purl_name, purl_version) != (field_name, field_version): @@ -573,7 +574,7 @@ def main(argv: list[str] | None = None) -> int: print(f"::error::CycloneDX SBOM is malformed and cannot be adjudicated ({error}); refusing to release.", file=sys.stderr) return 1 - except (OSError, tomllib.TOMLDecodeError) as error: + except (InventoryError, OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: print(f"::error::Release licence gate could not read the declared dependency scopes ({error}).", file=sys.stderr) return 1 diff --git a/tests/test_dependency_inventory.py b/tests/test_dependency_inventory.py index 88ec94132..bfb61dce0 100644 --- a/tests/test_dependency_inventory.py +++ b/tests/test_dependency_inventory.py @@ -35,12 +35,8 @@ def test_inventory_covers_every_lockfile_resolved_scope(tmp_path) -> None: output = tmp_path / "dependency-inventory.json" - # This repository currently refuses: requirements.lock still carries a - # `fast-mlsirm @ git+https://...` requirement, which --no-index does not - # stop and which is built from source. The gap stays visible rather than - # being closed by repinning it elsewhere; the enumeration below is still - # written, so the sets can be checked while the refusal stands. - assert main(["--repository-root", str(REPOSITORY_ROOT), "--output", str(output)]) == 1 + # The released fast-mlsirm wheel pin replaced the former VCS requirement. + assert main(["--repository-root", str(REPOSITORY_ROOT), "--output", str(output)]) == 0 inventory = json.loads(output.read_text(encoding="utf-8")) by_ecosystem = {entry["ecosystem"]: entry for entry in inventory["ecosystems"]} @@ -321,3 +317,31 @@ def test_wheel_version_prefix_is_not_exact_artifact_evidence(tmp_path): assert terms == [] assert "no wheel" in source assert files == [] + + +@pytest.mark.parametrize("linked", [False, True]) +def test_installed_license_bytes_reach_inventory_without_import(tmp_path, monkeypatch, linked): + import importlib.metadata + repository = _repository(tmp_path) + site = tmp_path / "site" + info = site / "only_library-1.0.dist-info" + info.mkdir(parents=True) + (info / "METADATA").write_text("Name: only_library\nVersion: 1.0\nLicense-Expression: MIT\n") + raw = b"Permission is hereby granted, free of charge.\r\n" + license_path = info / "LICENSE" + if linked: + outside = tmp_path / "outside-license" + outside.write_bytes(raw) + license_path.symlink_to(outside) + else: + license_path.write_bytes(raw) + (info / "RECORD").write_text("only_library-1.0.dist-info/LICENSE,,\n") + distribution = importlib.metadata.Distribution.at(info) + monkeypatch.setattr(importlib.metadata, "distribution", lambda name: distribution) + inventory = build_inventory(repository, resolve_licenses=True) + package = next(e for e in inventory["ecosystems"] if e["ecosystem"] == "python")["packages"][0] + if linked: + assert package["license_files"] == [] + else: + assert package["license_files"] == [{"name": "LICENSE", "sha256": hashlib.sha256(raw).hexdigest(), + "text": raw.decode("utf-8")}] diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index bf390cee0..13541cbec 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -571,3 +571,23 @@ def test_every_license_text_must_evidence_the_declared_family(files): groups = classify_inventory_licenses(_inventory(packages=[package])) assert not groups["permitted"] assert len(groups["undecidable"]) == 1 + + +def test_nested_scoped_npm_identity_matches_sbom(tmp_path): + from scripts.ci.release_license_gate import _lock_packages_from_npm, _sbom_packages + lock = tmp_path / "package-lock.json" + lock.write_text(json.dumps({"packages": {"node_modules/a/node_modules/@types/node": {"version": "1.0"}}})) + expected = _lock_packages_from_npm(lock) + present, mismatches = _sbom_packages([{"group": "@types", "name": "node", "version": "1.0", + "purl": "pkg:npm/%40types/node@1.0"}], "pkg:npm/") + assert expected == present == {("@types/node", "1.0")} + assert mismatches == [] + + +def test_malformed_npm_dependency_is_not_silently_dropped(tmp_path): + from scripts.ci.dependency_inventory import InventoryError + from scripts.ci.release_license_gate import _lock_packages_from_npm + lock = tmp_path / "package-lock.json" + lock.write_text(json.dumps({"packages": {"node_modules/library": None}})) + with pytest.raises(InventoryError): + _lock_packages_from_npm(lock) From 618716d7f20dafcce3bf0c1725e3787e1ef71eca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 27 Sep 2026 20:38:14 +0900 Subject: [PATCH 23/23] fix: inspect release licenses in the matching evidence environment --- .github/workflows/release.yml | 6 ++++-- tests/test_release_license_gate.py | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96234be88..36c413196 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -262,8 +262,10 @@ jobs: # this far. python -m scripts.ci.dependency_inventory --repository-root . \ --output /dev/null --check-sources-only + python -m pip download --require-hashes -r requirements.lock \ + --no-deps --only-binary=:all: --dest license-artifacts python -m scripts.ci.dependency_inventory --repository-root . \ - --output dependency-inventory.json --resolve-licenses + --output dependency-inventory.json --artifact-dir license-artifacts python -m scripts.ci.release_license_gate \ --inventory dependency-inventory.json \ --source-sha "${TARGET_SHA}" \ @@ -322,7 +324,7 @@ jobs: # the environment SBOM cannot see are enumerated against the exact # source this release publishes rather than against whatever the # security run happened to hold. - python -m scripts.ci.dependency_inventory \ + uv run --no-sync python -m scripts.ci.dependency_inventory \ --repository-root . --output dependency-inventory.json --resolve-licenses python -m scripts.ci.release_license_gate \ --sbom sbom-download/cyclonedx-sbom.json \ diff --git a/tests/test_release_license_gate.py b/tests/test_release_license_gate.py index 13541cbec..a84b82efd 100644 --- a/tests/test_release_license_gate.py +++ b/tests/test_release_license_gate.py @@ -111,6 +111,12 @@ def test_release_workflow_runs_the_gate_before_publishing() -> None: release_gate = verify_block.index("--sbom sbom-download/cyclonedx-sbom.json") assert preinstall < environment < sbom_fetch < release_gate assert verify_block.index("--check-sources-only") < preinstall + download = verify_block.index("pip download --require-hashes") + artifact_read = verify_block.index("--artifact-dir license-artifacts") + assert verify_block.index("--check-sources-only") < download < artifact_read < preinstall + assert "--only-binary=:all:" in verify_block[download:artifact_read] + installed_read = verify_block.index("uv run --no-sync python -m scripts.ci.dependency_inventory") + assert environment < installed_read < release_gate publish_block = text[text.index("\n publish:") :] assert "needs: verify" in publish_block