From 654e554fd15788acaba59e2c6c0c50f6066ebe5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:16:40 -0700 Subject: [PATCH 01/50] test(osv): define direct-source provenance contracts --- tests/test_osv_direct_source_reconcile.py | 189 ++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/test_osv_direct_source_reconcile.py diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py new file mode 100644 index 000000000..b69d2c47f --- /dev/null +++ b/tests/test_osv_direct_source_reconcile.py @@ -0,0 +1,189 @@ +"""Regression tests for provenance-aware OSV direct-source reconciliation.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "ci" / "osv_direct_source_reconcile.py" +OFFICIAL_URL = "https://cdn.sheetjs.com/xlsx-{version}/xlsx-{version}.tgz" +INTEGRITY = "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==" + + +def vulnerability(vuln_id: str, affected_range: str | None) -> dict[str, object]: + """Return a minimal OSV vulnerability record.""" + + record: dict[str, object] = {"id": vuln_id, "aliases": []} + if affected_range is not None: + record["database_specific"] = { + "last_known_affected_version_range": affected_range + } + return record + + +def results(version: str, vulnerabilities: list[dict[str, object]]) -> dict[str, object]: + """Return a minimal OSV Scanner JSON document.""" + + return { + "results": [ + { + "source": {"path": "pnpm-lock.yaml", "type": "lockfile"}, + "packages": [ + { + "package": { + "name": "xlsx", + "version": version, + "ecosystem": "npm", + }, + "vulnerabilities": vulnerabilities, + } + ], + } + ] + } + + +def direct_lock(version: str, *, integrity: str | None = INTEGRITY, host: str = "cdn.sheetjs.com") -> str: + """Return the exact pnpm v9 direct-tarball evidence shape used by Inkspan.""" + + url = OFFICIAL_URL.format(version=version).replace("cdn.sheetjs.com", host) + resolution_parts = [] + if integrity is not None: + resolution_parts.append(f"integrity: {integrity}") + resolution_parts.append(f"tarball: {url}") + resolution = ", ".join(resolution_parts) + return ( + "lockfileVersion: '9.0'\n\n" + "packages:\n\n" + f" xlsx@{url}:\n" + f" resolution: {{{resolution}}}\n" + f" version: {version}\n" + ) + + +def registry_lock(version: str) -> str: + """Return an ordinary registry-backed pnpm package record.""" + + return ( + "lockfileVersion: '9.0'\n\n" + "packages:\n\n" + f" xlsx@{version}:\n" + f" resolution: {{integrity: {INTEGRITY}}}\n" + ) + + +def reconcile(tmp_path: Path, payload: dict[str, object], lock_text: str) -> tuple[dict[str, object], list[dict[str, object]]]: + """Run the production reconciler and return its rewritten result and audit.""" + + assert SCRIPT.is_file(), "production OSV provenance reconciler is missing" + result_path = tmp_path / "results.json" + lock_path = tmp_path / "pnpm-lock.yaml" + audit_path = tmp_path / "audit.json" + result_path.write_text(json.dumps(payload), encoding="utf-8") + lock_path.write_text(lock_text, encoding="utf-8") + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--results", + str(result_path), + "--lockfile", + str(lock_path), + "--audit", + str(audit_path), + ], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + return ( + json.loads(result_path.read_text(encoding="utf-8")), + json.loads(audit_path.read_text(encoding="utf-8")), + ) + + +def remaining_ids(payload: dict[str, object]) -> list[str]: + """Return vulnerability IDs retained in a reconciled document.""" + + package = payload["results"][0]["packages"][0] # type: ignore[index] + return [item["id"] for item in package["vulnerabilities"]] # type: ignore[index] + + +class DirectSourceReconcileTests(unittest.TestCase): + """Exercise exact provenance, affected-range, and fail-closed boundaries.""" + + def run_case( + self, payload: dict[str, object], lock_text: str + ) -> tuple[dict[str, object], list[dict[str, object]]]: + """Run one isolated production reconciliation case.""" + + with tempfile.TemporaryDirectory() as directory: + return reconcile(Path(directory), payload, lock_text) + + def test_official_immutable_xlsx_0203_drops_only_metadata_disproven_findings(self) -> None: + payload = results( + "0.20.3", + [ + vulnerability("GHSA-4r6h-8v6p-xvw6", "< 0.19.3"), + vulnerability("GHSA-5pgg-2g8v-p4x9", "< 0.20.2"), + ], + ) + + reconciled, audit = self.run_case(payload, direct_lock("0.20.3")) + + self.assertEqual(remaining_ids(reconciled), []) + self.assertEqual( + [entry["status"] for entry in audit], ["RECONCILED", "RECONCILED"] + ) + self.assertTrue(all(entry["integrity"] == INTEGRITY for entry in audit)) + + def test_official_but_affected_versions_remain_findings(self) -> None: + for version, affected_range in ( + ("0.18.5", "< 0.19.3"), + ("0.19.2", "< 0.19.3"), + ("0.20.1", "< 0.20.2"), + ): + with self.subTest(version=version): + payload = results( + version, [vulnerability("GHSA-control", affected_range)] + ) + reconciled, audit = self.run_case(payload, direct_lock(version)) + self.assertEqual(remaining_ids(reconciled), ["GHSA-control"]) + self.assertEqual(audit[0]["status"], "AFFECTED") + + def test_registry_package_never_borrows_direct_source_exception(self) -> None: + payload = results("0.18.5", [vulnerability("GHSA-registry", "< 0.19.3")]) + reconciled, audit = self.run_case(payload, registry_lock("0.18.5")) + self.assertEqual(remaining_ids(reconciled), ["GHSA-registry"]) + self.assertEqual(audit, []) + + def test_unverifiable_direct_provenance_fails_closed(self) -> None: + for lock_text in ( + direct_lock("0.20.3", integrity=None), + direct_lock("0.20.3", host="example.invalid"), + ): + with self.subTest(lock_text=lock_text): + payload = results( + "0.20.3", + [vulnerability("GHSA-unknown-source", "< 0.20.2")], + ) + reconciled, audit = self.run_case(payload, lock_text) + self.assertEqual(remaining_ids(reconciled), ["GHSA-unknown-source"]) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + + def test_missing_authoritative_affected_range_fails_closed(self) -> None: + payload = results("0.20.3", [vulnerability("GHSA-unknown-range", None)]) + reconciled, audit = self.run_case(payload, direct_lock("0.20.3")) + self.assertEqual(remaining_ids(reconciled), ["GHSA-unknown-range"]) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + + +if __name__ == "__main__": + unittest.main() From 327f9c760190fbcf6a15e77805cefa161c34add1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:16:56 -0700 Subject: [PATCH 02/50] ci(osv): exercise direct-source provenance contract --- .../osv-direct-source-quality-ci.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/osv-direct-source-quality-ci.yml diff --git a/.github/workflows/osv-direct-source-quality-ci.yml b/.github/workflows/osv-direct-source-quality-ci.yml new file mode 100644 index 000000000..c6461e79f --- /dev/null +++ b/.github/workflows/osv-direct-source-quality-ci.yml @@ -0,0 +1,35 @@ +name: OSV Direct Source Quality CI + +on: + pull_request: + paths: + - '.github/workflows/security-scan.yml' + - '.github/workflows/osv-direct-source-quality-ci.yml' + - 'scripts/ci/osv_direct_source_reconcile.py' + - 'tests/test_osv_direct_source_reconcile.py' + - 'docs/doctoring/osv-direct-source-provenance.md' + workflow_dispatch: + +permissions: + contents: read + +jobs: + provenance-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + - name: Verify provenance reconciliation contracts + run: | + set -euo pipefail + python3 -m unittest -v tests.test_osv_direct_source_reconcile + python3 -m py_compile scripts/ci/osv_direct_source_reconcile.py + git diff --check From 93dd991a22547f1b15e6562c1770476a7afd124b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:20:29 -0700 Subject: [PATCH 03/50] fix(osv): reconcile exact immutable source evidence --- scripts/ci/osv_direct_source_reconcile.py | 337 ++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 scripts/ci/osv_direct_source_reconcile.py diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py new file mode 100644 index 000000000..441f98f20 --- /dev/null +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Reconcile OSV registry findings with exact immutable direct-source evidence.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +import tempfile +from typing import Any, Iterable +from urllib.parse import urlsplit + + +SEMVER_RE = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +DIRECT_HEADER_RE = re.compile( + r"(?m)^ (?P(?:@[^/\s]+/)?[^@\s]+)@(?Phttps://[^\s]+):[ \t]*$" +) +ENTRY_BOUNDARY_RE = re.compile(r"(?m)^ \S") +VERSION_LINE_RE = re.compile(r"(?m)^ version:[ \t]*['\"]?([^'\"\s]+)['\"]?[ \t]*$") +TARBALL_RE = re.compile(r"(?:^|[, {])[ \t]*tarball:[ \t]*([^, }]+)") +INTEGRITY_RE = re.compile(r"(?:^|[, {])[ \t]*integrity:[ \t]*(sha512-[A-Za-z0-9+/=]+)") +AFFECTED_RANGE_RE = re.compile(r"^[ \t]*<[ \t]*(\d+\.\d+\.\d+)[ \t]*$") +SHEETJS_URL_RE = re.compile( + r"^/xlsx-(?P\d+\.\d+\.\d+)/xlsx-(?P=version)\.tgz$" +) + + +@dataclass(frozen=True) +class DirectSource: + """Evidence parsed from one pnpm direct-tarball package record.""" + + package_name: str + version: str + source_url: str + integrity: str + valid: bool + reason: str + + +def parse_semver(value: str) -> tuple[int, int, int] | None: + """Parse a strict three-component SemVer core.""" + + match = SEMVER_RE.fullmatch(value) + if not match: + return None + return tuple(int(part) for part in match.groups()) # type: ignore[return-value] + + +def valid_sha512_integrity(value: str) -> bool: + """Return whether an integrity string contains one exact SHA-512 digest.""" + + if not value.startswith("sha512-"): + return False + try: + decoded = base64.b64decode(value.removeprefix("sha512-"), validate=True) + except (ValueError, binascii.Error): + return False + return len(decoded) == 64 + + +def validate_sheetjs_source( + package_name: str, + header_url: str, + tarball_url: str, + version: str, + integrity: str, +) -> tuple[bool, str]: + """Validate the exact official immutable SheetJS release identity.""" + + if package_name != "xlsx": + return False, "package is not governed by the SheetJS direct-source contract" + try: + parsed = urlsplit(header_url) + except ValueError: + return False, "direct source URL is malformed" + if ( + parsed.scheme != "https" + or parsed.hostname != "cdn.sheetjs.com" + or parsed.port is not None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + return False, "direct source is not the canonical SheetJS HTTPS origin" + path_match = SHEETJS_URL_RE.fullmatch(parsed.path) + if not path_match or path_match.group("version") != version: + return False, "package version does not match the immutable SheetJS URL" + if header_url != tarball_url: + return False, "pnpm package key and resolution tarball disagree" + if parse_semver(version) is None: + return False, "package version is not a strict SemVer core" + if not valid_sha512_integrity(integrity): + return False, "direct source lacks one valid SHA-512 integrity receipt" + return True, "exact official immutable SheetJS release" + + +def parse_direct_sources(lock_text: str) -> list[DirectSource]: + """Parse conservative direct HTTPS package records from a pnpm lockfile.""" + + sources: list[DirectSource] = [] + matches = list(DIRECT_HEADER_RE.finditer(lock_text)) + for match in matches: + start = match.end() + boundary = ENTRY_BOUNDARY_RE.search(lock_text, start) + end = boundary.start() if boundary else len(lock_text) + block = lock_text[start:end] + version_match = VERSION_LINE_RE.search(block) + tarball_match = TARBALL_RE.search(block) + integrity_match = INTEGRITY_RE.search(block) + version = version_match.group(1) if version_match else "" + tarball = tarball_match.group(1) if tarball_match else "" + integrity = integrity_match.group(1) if integrity_match else "" + valid, reason = validate_sheetjs_source( + match.group("name"), match.group("url"), tarball, version, integrity + ) + sources.append( + DirectSource( + package_name=match.group("name"), + version=version, + source_url=match.group("url"), + integrity=integrity, + valid=valid, + reason=reason, + ) + ) + return sources + + +def iter_packages(payload: dict[str, Any]) -> Iterable[dict[str, Any]]: + """Yield package findings from an OSV Scanner result document.""" + + results = payload.get("results") + if not isinstance(results, list): + raise ValueError("OSV results must contain a results array") + for result in results: + if not isinstance(result, dict): + raise ValueError("OSV result entries must be objects") + packages = result.get("packages") or [] + if not isinstance(packages, list): + raise ValueError("OSV result packages must be an array") + for package in packages: + if not isinstance(package, dict): + raise ValueError("OSV package entries must be objects") + yield package + + +def audit_entry( + *, + label: str, + source: DirectSource, + package_name: str, + package_version: str, + vulnerability: dict[str, Any], + affected_range: str | None, + status: str, + reason: str, +) -> dict[str, str]: + """Build one stable audit record without copying advisory prose.""" + + return { + "scan": label, + "status": status, + "reason": reason, + "package": package_name, + "version": package_version, + "vulnerability_id": str(vulnerability.get("id") or "unknown"), + "affected_range": affected_range or "unknown", + "source_url": source.source_url, + "integrity": source.integrity or "missing", + } + + +def reconcile_payload( + payload: dict[str, Any], lock_text: str, *, label: str +) -> tuple[dict[str, Any], list[dict[str, str]]]: + """Remove only findings disproven by exact source and affected-range evidence.""" + + direct_sources = parse_direct_sources(lock_text) + audit: list[dict[str, str]] = [] + for package in iter_packages(payload): + package_info = package.get("package") + vulnerabilities = package.get("vulnerabilities") or [] + if not isinstance(package_info, dict) or not isinstance(vulnerabilities, list): + raise ValueError("OSV package evidence is malformed") + package_name = str(package_info.get("name") or "") + package_version = str(package_info.get("version") or "") + candidates = [ + source + for source in direct_sources + if source.package_name == package_name + and (source.version == package_version or not source.version) + ] + if not candidates: + continue + source = candidates[0] + retained: list[dict[str, Any]] = [] + for vulnerability in vulnerabilities: + if not isinstance(vulnerability, dict): + raise ValueError("OSV vulnerability entries must be objects") + database_specific = vulnerability.get("database_specific") + affected_range = None + if isinstance(database_specific, dict): + raw_range = database_specific.get("last_known_affected_version_range") + if isinstance(raw_range, str): + affected_range = raw_range + if not source.valid: + retained.append(vulnerability) + audit.append( + audit_entry( + label=label, + source=source, + package_name=package_name, + package_version=package_version, + vulnerability=vulnerability, + affected_range=affected_range, + status="SCANNER_METADATA_CONFLICT", + reason=source.reason, + ) + ) + continue + range_match = AFFECTED_RANGE_RE.fullmatch(affected_range or "") + version_key = parse_semver(package_version) + upper_key = parse_semver(range_match.group(1)) if range_match else None + if version_key is None or upper_key is None: + retained.append(vulnerability) + audit.append( + audit_entry( + label=label, + source=source, + package_name=package_name, + package_version=package_version, + vulnerability=vulnerability, + affected_range=affected_range, + status="SCANNER_METADATA_CONFLICT", + reason="advisory lacks one machine-checkable exclusive affected upper bound", + ) + ) + continue + if version_key < upper_key: + retained.append(vulnerability) + status = "AFFECTED" + reason = "exact direct-source version remains inside the affected range" + else: + status = "RECONCILED" + reason = "exact immutable direct-source version is outside the affected range" + audit.append( + audit_entry( + label=label, + source=source, + package_name=package_name, + package_version=package_version, + vulnerability=vulnerability, + affected_range=affected_range, + status=status, + reason=reason, + ) + ) + package["vulnerabilities"] = retained + return payload, audit + + +def atomic_json_write(path: Path, value: object) -> None: + """Write JSON through a same-directory temporary regular file.""" + + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def load_json_object(path: Path) -> dict[str, Any]: + """Load one JSON object from a regular non-symlink file.""" + + if path.is_symlink() or not path.is_file(): + raise ValueError(f"required JSON input is not a regular file: {path}") + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"required JSON input is not an object: {path}") + return value + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--results", required=True, type=Path) + parser.add_argument("--lockfile", required=True, type=Path) + parser.add_argument("--audit", required=True, type=Path) + parser.add_argument("--label", default="scan") + return parser.parse_args() + + +def main() -> int: + """Reconcile one OSV document and publish append-only audit evidence.""" + + args = parse_args() + if args.lockfile.is_symlink() or not args.lockfile.is_file(): + raise ValueError("pnpm lock provenance input must be a regular file") + payload = load_json_object(args.results) + reconciled, new_audit = reconcile_payload( + payload, args.lockfile.read_text(encoding="utf-8"), label=args.label + ) + existing_audit: list[dict[str, str]] = [] + if args.audit.exists(): + if args.audit.is_symlink() or not args.audit.is_file(): + raise ValueError("audit output must be a regular file") + loaded_audit = json.loads(args.audit.read_text(encoding="utf-8")) + if not isinstance(loaded_audit, list): + raise ValueError("audit output must contain an array") + existing_audit = loaded_audit + atomic_json_write(args.results, reconciled) + atomic_json_write(args.audit, [*existing_audit, *new_audit]) + for entry in new_audit: + print( + "OSV provenance " + f"{entry['status']}: {entry['package']}@{entry['version']} " + f"{entry['vulnerability_id']} ({entry['reason']})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 00abb82ac05e4a45be685e78caf7ec50e0514438 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:20:38 -0700 Subject: [PATCH 04/50] docs(osv): explain direct-source provenance evidence --- .../doctoring/osv-direct-source-provenance.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/doctoring/osv-direct-source-provenance.md diff --git a/docs/doctoring/osv-direct-source-provenance.md b/docs/doctoring/osv-direct-source-provenance.md new file mode 100644 index 000000000..dcb3d6b83 --- /dev/null +++ b/docs/doctoring/osv-direct-source-provenance.md @@ -0,0 +1,45 @@ +# OSV direct-source provenance reconciliation + +## Decision + +The central Security Scan preserves OSV Scanner as the hard base/head +vulnerability gate. It adds a narrow evidence reconciliation step for pnpm +dependencies whose lock entry proves an exact immutable non-registry source. +The first governed source is the official SheetJS CE HTTPS release artifact. + +A registry advisory is removed from the reporter input only when all of these +facts agree: + +1. package name and strict three-component version; +2. the pnpm package key and `resolution.tarball` canonical HTTPS URL; +3. the official `cdn.sheetjs.com` origin and versioned release path; +4. one valid SHA-512 integrity receipt; and +5. an advisory-provided, machine-checkable exclusive affected upper bound that + the exact artifact version is outside. + +An affected version remains a finding. Missing integrity, an unknown host, +version disagreement, malformed JSON or an absent/ambiguous affected range is +retained and recorded as `SCANNER_METADATA_CONFLICT`. No advisory identifier, +package name, or severity is blanket-ignored. The audit artifact records every +retained or reconciled direct-source decision without copying untrusted +advisory prose. + +## Rollback and operations + +Rollback removes the reconciliation step and helper together, restoring raw +OSV reporter inputs. Operators should inspect `osv-provenance-audit.json` +alongside `old-results.json`, `new-results.json`, and `results.sarif`. A metadata +conflict is non-passing when the underlying OSV finding is new because the +finding remains in the reporter input. + +## References + +Google. (2026). *OSV-Scanner documentation*. Open Source Vulnerabilities. +https://google.github.io/osv-scanner/ + +OpenSSF. (2025). *Open source vulnerability format specification*. Open Source +Security Foundation. https://ossf.github.io/osv-schema/ + +pnpm. (2026). *Settings: Lockfile*. https://pnpm.io/settings#lockfile + +SheetJS LLC. (2026). *SheetJS Community Edition*. https://docs.sheetjs.com/ From 123d9d29464a621f53e5ffc79d2cdb37ea62650a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:21:11 -0700 Subject: [PATCH 05/50] ci(osv): preserve provenance before vulnerability verdict --- .github/workflows/security-scan.yml | 47 +++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c3b8fa5db..fd4f8bec4 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -104,6 +104,17 @@ jobs: --allow-no-lockfiles -r ./ + - name: Preserve base direct-source provenance + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/osv-base-provenance" + if [ -L pnpm-lock.yaml ]; then + echo "::error::Base pnpm-lock.yaml is a symlink; direct-source provenance is not authoritative." + exit 1 + fi + if [ -f pnpm-lock.yaml ]; then + cp -- pnpm-lock.yaml "$RUNNER_TEMP/osv-base-provenance/pnpm-lock.yaml" + fi - name: Checkout head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -143,6 +154,41 @@ jobs: --allow-no-lockfiles -r ./ + - name: Checkout exact central provenance policy + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ContextualWisdomLab/.github + ref: ${{ github.workflow_sha }} + path: .cwl-trusted-security-policy + persist-credentials: false + - name: Reconcile immutable direct-source provenance + run: | + set -euo pipefail + reconciler=".cwl-trusted-security-policy/scripts/ci/osv_direct_source_reconcile.py" + audit="osv-provenance-audit.json" + test -f "$reconciler" + test ! -L "$reconciler" + + base_lock="$RUNNER_TEMP/osv-base-provenance/pnpm-lock.yaml" + if [ -f "$base_lock" ]; then + python3 "$reconciler" \ + --results old-results.json \ + --lockfile "$base_lock" \ + --audit "$audit" \ + --label base + fi + + if [ -L pnpm-lock.yaml ]; then + echo "::error::Head pnpm-lock.yaml is a symlink; direct-source provenance is not authoritative." + exit 1 + fi + if [ -f pnpm-lock.yaml ]; then + python3 "$reconciler" \ + --results new-results.json \ + --lockfile pnpm-lock.yaml \ + --audit "$audit" \ + --label head + fi - name: Require OSV scan output run: | set -euo pipefail @@ -247,6 +293,7 @@ jobs: old-results.json new-results.json results.sarif + osv-provenance-audit.json if-no-files-found: ignore retention-days: 5 From 80fd3fdadcd93656b8b47e10a4df9c89d536783c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:22:03 -0700 Subject: [PATCH 06/50] ci(osv): bind policy checkout to governed source --- .github/workflows/security-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index fd4f8bec4..17bd8fdd1 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -158,7 +158,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ContextualWisdomLab/.github - ref: ${{ github.workflow_sha }} + ref: ${{ github.repository == 'ContextualWisdomLab/.github' && github.event.pull_request.head.sha || github.workflow_sha }} path: .cwl-trusted-security-policy persist-credentials: false - name: Reconcile immutable direct-source provenance From 03254d7f93665e7645f76222dd896827d4442903 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:22:24 -0700 Subject: [PATCH 07/50] test(osv): bind reconciliation ahead of reporter --- tests/test_osv_direct_source_reconcile.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index b69d2c47f..2957d917f 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -12,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts" / "ci" / "osv_direct_source_reconcile.py" +SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security-scan.yml" OFFICIAL_URL = "https://cdn.sheetjs.com/xlsx-{version}/xlsx-{version}.tgz" INTEGRITY = "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==" @@ -184,6 +185,24 @@ def test_missing_authoritative_affected_range_fails_closed(self) -> None: self.assertEqual(remaining_ids(reconciled), ["GHSA-unknown-range"]) self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None: + workflow = SECURITY_WORKFLOW.read_text(encoding="utf-8") + preserve = workflow.index("Preserve base direct-source provenance") + head_scan = workflow.index("Scan head with OSV") + policy = workflow.index("Checkout exact central provenance policy") + reconcile_step = workflow.index("Reconcile immutable direct-source provenance") + require_output = workflow.index("Require OSV scan output") + reporter = workflow.index("Report PR-introduced OSV findings") + + self.assertLess(preserve, head_scan) + self.assertLess(head_scan, policy) + self.assertLess(policy, reconcile_step) + self.assertLess(reconcile_step, require_output) + self.assertLess(require_output, reporter) + self.assertIn("github.workflow_sha", workflow) + self.assertIn("osv_direct_source_reconcile.py", workflow) + self.assertIn("osv-provenance-audit.json", workflow) + if __name__ == "__main__": unittest.main() From 2b7f71da9c700adefd64112a7c26ec95e7b18bf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:26:49 -0700 Subject: [PATCH 08/50] test(osv): reproduce live nested advisory shape --- tests/test_osv_direct_source_reconcile.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 2957d917f..e4c9b50c7 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -22,9 +22,24 @@ def vulnerability(vuln_id: str, affected_range: str | None) -> dict[str, object] record: dict[str, object] = {"id": vuln_id, "aliases": []} if affected_range is not None: - record["database_specific"] = { - "last_known_affected_version_range": affected_range - } + record["affected"] = [ + { + "database_specific": { + "last_known_affected_version_range": affected_range, + "source": ( + "https://github.com/github/advisory-database/blob/main/" + f"advisories/github-reviewed/2026/08/{vuln_id}/{vuln_id}.json" + ), + }, + "package": {"ecosystem": "npm", "name": "xlsx"}, + "ranges": [ + { + "events": [{"introduced": "0"}], + "type": "SEMVER", + } + ], + } + ] return record From 9b95378b77e7d0991cafb75d34557870f102be8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:26:59 -0700 Subject: [PATCH 09/50] fix(osv): read authoritative nested affected range --- scripts/ci/osv_direct_source_reconcile.py | 59 ++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 441f98f20..39b59cd02 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -150,6 +150,56 @@ def iter_packages(payload: dict[str, Any]) -> Iterable[dict[str, Any]]: yield package +def authoritative_affected_range( + vulnerability: dict[str, Any], package_name: str +) -> str | None: + """Return one GitHub-reviewed npm affected bound with live OSV shape.""" + + vulnerability_id = vulnerability.get("id") + affected = vulnerability.get("affected") + if not isinstance(vulnerability_id, str) or not isinstance(affected, list): + return None + candidates: list[str] = [] + for item in affected: + if not isinstance(item, dict): + continue + package = item.get("package") + database_specific = item.get("database_specific") + ranges = item.get("ranges") + if ( + not isinstance(package, dict) + or package.get("ecosystem") != "npm" + or package.get("name") != package_name + or not isinstance(database_specific, dict) + or not isinstance(ranges, list) + ): + continue + affected_range = database_specific.get("last_known_affected_version_range") + source = database_specific.get("source") + expected_source_fragment = f"/{vulnerability_id}/{vulnerability_id}.json" + if ( + not isinstance(affected_range, str) + or not isinstance(source, str) + or not source.startswith( + "https://github.com/github/advisory-database/blob/" + ) + or not source.endswith(expected_source_fragment) + ): + continue + semver_ranges = [ + range_item + for range_item in ranges + if isinstance(range_item, dict) and range_item.get("type") == "SEMVER" + ] + if len(semver_ranges) != 1: + continue + events = semver_ranges[0].get("events") + if events != [{"introduced": "0"}]: + continue + candidates.append(affected_range) + return candidates[0] if len(set(candidates)) == 1 and candidates else None + + def audit_entry( *, label: str, @@ -203,12 +253,9 @@ def reconcile_payload( for vulnerability in vulnerabilities: if not isinstance(vulnerability, dict): raise ValueError("OSV vulnerability entries must be objects") - database_specific = vulnerability.get("database_specific") - affected_range = None - if isinstance(database_specific, dict): - raw_range = database_specific.get("last_known_affected_version_range") - if isinstance(raw_range, str): - affected_range = raw_range + affected_range = authoritative_affected_range( + vulnerability, package_name + ) if not source.valid: retained.append(vulnerability) audit.append( From f2a94192cba86e661ed3c5fd22a8b6ffa44cd3f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:28:52 -0700 Subject: [PATCH 10/50] test(osv): reject package-key tarball disagreement --- tests/test_osv_direct_source_reconcile.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index e4c9b50c7..53caa1d3d 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -184,6 +184,10 @@ def test_unverifiable_direct_provenance_fails_closed(self) -> None: for lock_text in ( direct_lock("0.20.3", integrity=None), direct_lock("0.20.3", host="example.invalid"), + direct_lock("0.20.3").replace( + "tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "tarball: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz", + ), ): with self.subTest(lock_text=lock_text): payload = results( From 42845bb3c718d07f0d38aae0a96fd8db1c67c1b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:36:17 -0700 Subject: [PATCH 11/50] ci(osv): require full suite and branch coverage --- .../osv-direct-source-quality-ci.yml | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/workflows/osv-direct-source-quality-ci.yml b/.github/workflows/osv-direct-source-quality-ci.yml index c6461e79f..1407b2d29 100644 --- a/.github/workflows/osv-direct-source-quality-ci.yml +++ b/.github/workflows/osv-direct-source-quality-ci.yml @@ -2,6 +2,7 @@ name: OSV Direct Source Quality CI on: pull_request: + branches: [main] paths: - '.github/workflows/security-scan.yml' - '.github/workflows/osv-direct-source-quality-ci.yml' @@ -13,10 +14,14 @@ on: permissions: contents: read +concurrency: + group: osv-direct-source-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: provenance-contract: - runs-on: ubuntu-latest - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout exact source revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -26,10 +31,35 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: '3.13' - - name: Verify provenance reconciliation contracts + python-version: '3.14' + - name: Install exact hash-verified quality dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + PIP_NO_INPUT: '1' + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/osv-provenance-quality-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/osv-provenance-quality-requirements.txt" + - name: Verify full central suite and provenance coverage + shell: bash --noprofile --norc -e -o pipefail {0} run: | - set -euo pipefail - python3 -m unittest -v tests.test_osv_direct_source_reconcile - python3 -m py_compile scripts/ci/osv_direct_source_reconcile.py - git diff --check + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python -m coverage run --branch -m pytest --import-mode=importlib tests -q + python -m coverage report \ + --include='scripts/ci/osv_direct_source_reconcile.py' \ + --show-missing \ + --fail-under=100 + python -m compileall -q \ + scripts/ci/osv_direct_source_reconcile.py \ + tests/test_osv_direct_source_reconcile.py + git diff --exit-code From 9d33c409f66254a418b96ff0b01cfb4cedfa054f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:39:25 -0700 Subject: [PATCH 12/50] test(osv): cover every fail-closed provenance branch --- tests/test_osv_direct_source_reconcile.py | 196 +++++++++++++++++++++- 1 file changed, 188 insertions(+), 8 deletions(-) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 53caa1d3d..923ac034d 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -2,12 +2,15 @@ from __future__ import annotations +import importlib.util +import copy import json from pathlib import Path -import subprocess +import runpy import sys import tempfile import unittest +from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[1] @@ -16,6 +19,12 @@ OFFICIAL_URL = "https://cdn.sheetjs.com/xlsx-{version}/xlsx-{version}.tgz" INTEGRITY = "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==" +SPEC = importlib.util.spec_from_file_location("osv_direct_source_reconcile", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +OSV = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = OSV +SPEC.loader.exec_module(OSV) + def vulnerability(vuln_id: str, affected_range: str | None) -> dict[str, object]: """Return a minimal OSV vulnerability record.""" @@ -103,9 +112,10 @@ def reconcile(tmp_path: Path, payload: dict[str, object], lock_text: str) -> tup audit_path = tmp_path / "audit.json" result_path.write_text(json.dumps(payload), encoding="utf-8") lock_path.write_text(lock_text, encoding="utf-8") - completed = subprocess.run( + with mock.patch.object( + sys, + "argv", [ - sys.executable, str(SCRIPT), "--results", str(result_path), @@ -114,11 +124,9 @@ def reconcile(tmp_path: Path, payload: dict[str, object], lock_text: str) -> tup "--audit", str(audit_path), ], - check=False, - capture_output=True, - text=True, - ) - assert completed.returncode == 0, completed.stderr + ): + with unittest.TestCase().assertRaisesRegex(SystemExit, "0"): + runpy.run_path(str(SCRIPT), run_name="__main__") return ( json.loads(result_path.read_text(encoding="utf-8")), json.loads(audit_path.read_text(encoding="utf-8")), @@ -198,6 +206,178 @@ def test_unverifiable_direct_provenance_fails_closed(self) -> None: self.assertEqual(remaining_ids(reconciled), ["GHSA-unknown-source"]) self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> None: + self.assertEqual(OSV.parse_semver("0.20.3"), (0, 20, 3)) + self.assertIsNone(OSV.parse_semver("01.20.3")) + self.assertFalse(OSV.valid_sha512_integrity("sha256-deadbeef")) + self.assertFalse(OSV.valid_sha512_integrity("sha512-%%%")) + self.assertFalse(OSV.valid_sha512_integrity("sha512-YQ==")) + valid_url = OFFICIAL_URL.format(version="0.20.3") + cases = ( + ("other", valid_url, valid_url, "0.20.3", INTEGRITY), + ("xlsx", "https://cdn.sheetjs.com:bad/x.xlsx", "", "0.20.3", INTEGRITY), + ("xlsx", valid_url.replace("https://", "http://"), valid_url, "0.20.3", INTEGRITY), + ("xlsx", valid_url.replace("cdn.sheetjs.com", "example.invalid"), valid_url, "0.20.3", INTEGRITY), + ("xlsx", valid_url.replace("cdn.sheetjs.com", "cdn.sheetjs.com:443"), valid_url, "0.20.3", INTEGRITY), + ("xlsx", valid_url.replace("cdn.sheetjs.com", "user@cdn.sheetjs.com"), valid_url, "0.20.3", INTEGRITY), + ("xlsx", valid_url.replace("cdn.sheetjs.com", "user:pass@cdn.sheetjs.com"), valid_url, "0.20.3", INTEGRITY), + ("xlsx", valid_url + "?download=1", valid_url, "0.20.3", INTEGRITY), + ("xlsx", valid_url + "#fragment", valid_url, "0.20.3", INTEGRITY), + ("xlsx", "https://cdn.sheetjs.com/not-xlsx.tgz", "", "0.20.3", INTEGRITY), + ("xlsx", valid_url, valid_url, "0.20.2", INTEGRITY), + ("xlsx", valid_url, valid_url.replace("0.20.3", "0.20.2"), "0.20.3", INTEGRITY), + ("xlsx", valid_url, valid_url, "0.20.3", "missing"), + ) + for arguments in cases: + with self.subTest(arguments=arguments): + self.assertFalse(OSV.validate_sheetjs_source(*arguments)[0]) + self.assertTrue( + OSV.validate_sheetjs_source("xlsx", valid_url, valid_url, "0.20.3", INTEGRITY)[0] + ) + + def test_direct_source_parser_handles_boundaries_and_missing_fields(self) -> None: + valid_url = OFFICIAL_URL.format(version="0.20.3") + lock = ( + "packages:\n\n" + f" xlsx@{valid_url}:\n" + " resolution: {}\n" + " other@https://example.invalid/other.tgz:\n" + " resolution: {tarball: https://example.invalid/other.tgz}\n" + ) + sources = OSV.parse_direct_sources(lock) + self.assertEqual(len(sources), 2) + self.assertFalse(sources[0].valid) + self.assertFalse(sources[1].valid) + + def test_malformed_osv_container_evidence_fails_closed(self) -> None: + malformed = ( + {}, + {"results": ["bad"]}, + {"results": [{"packages": "bad"}]}, + {"results": [{"packages": ["bad"]}]}, + ) + for payload in malformed: + with self.subTest(payload=payload), self.assertRaises(ValueError): + list(OSV.iter_packages(payload)) + self.assertEqual(list(OSV.iter_packages({"results": [{"packages": []}]})), []) + + def test_authoritative_range_rejects_every_ambiguous_shape(self) -> None: + valid = vulnerability("GHSA-shape", "< 0.20.2") + self.assertEqual(OSV.authoritative_affected_range(valid, "xlsx"), "< 0.20.2") + malformed = [ + {}, + {"id": 1, "affected": []}, + {"id": "GHSA-shape", "affected": "bad"}, + {"id": "GHSA-shape", "affected": [None]}, + ] + item_mutations = ( + ("package", None), + ("package", {"ecosystem": "PyPI", "name": "xlsx"}), + ("package", {"ecosystem": "npm", "name": "other"}), + ("database_specific", None), + ("ranges", None), + ) + for key, value in item_mutations: + candidate = copy.deepcopy(valid) + candidate["affected"][0][key] = value + malformed.append(candidate) + database_mutations = ( + ("last_known_affected_version_range", None), + ("source", None), + ("source", "https://example.invalid/advisory.json"), + ("source", "https://github.com/github/advisory-database/blob/main/wrong.json"), + ) + for key, value in database_mutations: + candidate = copy.deepcopy(valid) + candidate["affected"][0]["database_specific"][key] = value + malformed.append(candidate) + for ranges in ( + [], + [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}]}], + [ + {"type": "SEMVER", "events": [{"introduced": "0"}]}, + {"type": "SEMVER", "events": [{"introduced": "0"}]}, + ], + [{"type": "SEMVER", "events": [{"introduced": "1.0.0"}]}], + [None], + ): + candidate = copy.deepcopy(valid) + candidate["affected"][0]["ranges"] = ranges + malformed.append(candidate) + conflicting = copy.deepcopy(valid) + second = copy.deepcopy(conflicting["affected"][0]) + second["database_specific"]["last_known_affected_version_range"] = "< 0.19.3" + conflicting["affected"].append(second) + malformed.append(conflicting) + duplicate = copy.deepcopy(valid) + duplicate["affected"].append(copy.deepcopy(duplicate["affected"][0])) + self.assertEqual(OSV.authoritative_affected_range(duplicate, "xlsx"), "< 0.20.2") + for candidate in malformed: + with self.subTest(candidate=candidate): + self.assertIsNone(OSV.authoritative_affected_range(candidate, "xlsx")) + + def test_reconcile_rejects_malformed_packages_and_vulnerabilities(self) -> None: + bad_packages = ( + {"results": [{"packages": [{"package": None, "vulnerabilities": []}]}]}, + {"results": [{"packages": [{"package": {}, "vulnerabilities": "bad"}]}]}, + ) + for payload in bad_packages: + with self.subTest(payload=payload), self.assertRaises(ValueError): + OSV.reconcile_payload(payload, direct_lock("0.20.3"), label="bad") + bad_vulnerability = results("0.20.3", []) + bad_vulnerability["results"][0]["packages"][0]["vulnerabilities"] = ["bad"] + with self.assertRaises(ValueError): + OSV.reconcile_payload(bad_vulnerability, direct_lock("0.20.3"), label="bad") + untouched, audit = OSV.reconcile_payload( + results("0.20.3", [vulnerability("GHSA-registry", "< 0.20.2")]), + registry_lock("0.20.3"), + label="registry", + ) + self.assertEqual(remaining_ids(untouched), ["GHSA-registry"]) + self.assertEqual(audit, []) + + def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + missing = root / "missing.json" + with self.assertRaises(ValueError): + OSV.load_json_object(missing) + array_path = root / "array.json" + array_path.write_text("[]", encoding="utf-8") + with self.assertRaises(ValueError): + OSV.load_json_object(array_path) + destination = root / "atomic.json" + with mock.patch.object(OSV.os, "replace", side_effect=OSError("replace failed")): + with self.assertRaises(OSError): + OSV.atomic_json_write(destination, {}) + + results_path = root / "results.json" + lock_path = root / "pnpm-lock.yaml" + audit_path = root / "audit.json" + results_path.write_text(json.dumps({"results": []}), encoding="utf-8") + lock_path.write_text("lockfileVersion: '9.0'\n", encoding="utf-8") + audit_path.write_text('[{"status":"prior"}]', encoding="utf-8") + argv = [ + str(SCRIPT), "--results", str(results_path), "--lockfile", str(lock_path), + "--audit", str(audit_path), "--label", "head", + ] + with mock.patch.object(sys, "argv", argv): + self.assertEqual(OSV.main(), 0) + self.assertEqual(json.loads(audit_path.read_text(encoding="utf-8")), [{"status": "prior"}]) + + audit_path.write_text("{}", encoding="utf-8") + with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): + OSV.main() + audit_path.unlink() + audit_target = root / "audit-target.json" + audit_target.write_text("[]", encoding="utf-8") + audit_path.symlink_to(audit_target) + with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): + OSV.main() + lock_path.unlink() + with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): + OSV.main() + def test_missing_authoritative_affected_range_fails_closed(self) -> None: payload = results("0.20.3", [vulnerability("GHSA-unknown-range", None)]) reconciled, audit = self.run_case(payload, direct_lock("0.20.3")) From 003b357fc364d88c2c3c9a1a519840b30755166e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:39:37 -0700 Subject: [PATCH 13/50] fix(osv): fail closed on malformed source ports --- scripts/ci/osv_direct_source_reconcile.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 39b59cd02..5e6b7259e 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -76,12 +76,13 @@ def validate_sheetjs_source( return False, "package is not governed by the SheetJS direct-source contract" try: parsed = urlsplit(header_url) + parsed_port = parsed.port except ValueError: return False, "direct source URL is malformed" if ( parsed.scheme != "https" or parsed.hostname != "cdn.sheetjs.com" - or parsed.port is not None + or parsed_port is not None or parsed.username is not None or parsed.password is not None or parsed.query @@ -93,8 +94,6 @@ def validate_sheetjs_source( return False, "package version does not match the immutable SheetJS URL" if header_url != tarball_url: return False, "pnpm package key and resolution tarball disagree" - if parse_semver(version) is None: - return False, "package version is not a strict SemVer core" if not valid_sha512_integrity(integrity): return False, "direct source lacks one valid SHA-512 integrity receipt" return True, "exact official immutable SheetJS release" From 78ae8a05b5c2caa9831e4eba07e4cc16e22a7178 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:40:33 -0700 Subject: [PATCH 14/50] test(osv): assert exact CLI success code --- tests/test_osv_direct_source_reconcile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 923ac034d..479f2483f 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -125,8 +125,9 @@ def reconcile(tmp_path: Path, payload: dict[str, object], lock_text: str) -> tup str(audit_path), ], ): - with unittest.TestCase().assertRaisesRegex(SystemExit, "0"): + with unittest.TestCase().assertRaises(SystemExit) as exit_context: runpy.run_path(str(SCRIPT), run_name="__main__") + assert exit_context.exception.code == 0 return ( json.loads(result_path.read_text(encoding="utf-8")), json.loads(audit_path.read_text(encoding="utf-8")), From 29af209ed2e0ecdf57e54a04d04969fb544061a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:46:47 -0700 Subject: [PATCH 15/50] fix(ci): keep provenance quality workflow PR-only --- .github/workflows/osv-direct-source-quality-ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/osv-direct-source-quality-ci.yml b/.github/workflows/osv-direct-source-quality-ci.yml index 1407b2d29..a4dab7690 100644 --- a/.github/workflows/osv-direct-source-quality-ci.yml +++ b/.github/workflows/osv-direct-source-quality-ci.yml @@ -9,8 +9,6 @@ on: - 'scripts/ci/osv_direct_source_reconcile.py' - 'tests/test_osv_direct_source_reconcile.py' - 'docs/doctoring/osv-direct-source-provenance.md' - workflow_dispatch: - permissions: contents: read From c573b35648fd424337ca2897095673a52c0be1d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:31:55 +0900 Subject: [PATCH 16/50] fix(osv): fail closed on ambiguous provenance --- .github/workflows/security-scan.yml | 2 +- scripts/ci/osv_direct_source_reconcile.py | 64 ++++++++++++++++++++++- tests/test_osv_direct_source_reconcile.py | 56 +++++++++++++++++++- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 17bd8fdd1..fd4f8bec4 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -158,7 +158,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ContextualWisdomLab/.github - ref: ${{ github.repository == 'ContextualWisdomLab/.github' && github.event.pull_request.head.sha || github.workflow_sha }} + ref: ${{ github.workflow_sha }} path: .cwl-trusted-security-policy persist-credentials: false - name: Reconcile immutable direct-source provenance diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 5e6b7259e..87cc17a35 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -25,6 +25,7 @@ TARBALL_RE = re.compile(r"(?:^|[, {])[ \t]*tarball:[ \t]*([^, }]+)") INTEGRITY_RE = re.compile(r"(?:^|[, {])[ \t]*integrity:[ \t]*(sha512-[A-Za-z0-9+/=]+)") AFFECTED_RANGE_RE = re.compile(r"^[ \t]*<[ \t]*(\d+\.\d+\.\d+)[ \t]*$") +SHEETJS_EXCEPTION_VERSION = "0.20.3" SHEETJS_URL_RE = re.compile( r"^/xlsx-(?P\d+\.\d+\.\d+)/xlsx-(?P=version)\.tgz$" ) @@ -245,7 +246,44 @@ def reconcile_payload( if source.package_name == package_name and (source.version == package_version or not source.version) ] - if not candidates: + if not candidates and package_name != "xlsx": + continue + if len(candidates) != 1: + source = candidates[0] if candidates else DirectSource( + package_name=package_name, + version=package_version, + source_url="", + integrity="", + valid=False, + reason="no unique direct-source provenance matches package and version" + if not candidates + else "multiple direct-source records match package and version", + ) + reason = ( + "no unique direct-source provenance matches package and version" + if not candidates + else "multiple direct-source records match package and version" + ) + retained = [] + for vulnerability in vulnerabilities: + if not isinstance(vulnerability, dict): + raise ValueError("OSV vulnerability entries must be objects") + retained.append(vulnerability) + audit.append( + audit_entry( + label=label, + source=source, + package_name=package_name, + package_version=package_version, + vulnerability=vulnerability, + affected_range=authoritative_affected_range( + vulnerability, package_name + ), + status="SCANNER_METADATA_CONFLICT", + reason=reason, + ) + ) + package["vulnerabilities"] = retained continue source = candidates[0] retained: list[dict[str, Any]] = [] @@ -288,6 +326,30 @@ def reconcile_payload( ) ) continue + if package_version != SHEETJS_EXCEPTION_VERSION: + retained.append(vulnerability) + if version_key < upper_key: + status = "AFFECTED" + reason = "exact direct-source version remains inside the affected range" + else: + status = "SCANNER_METADATA_CONFLICT" + reason = ( + "direct-source reconciliation is limited to immutable " + "SheetJS xlsx@0.20.3" + ) + audit.append( + audit_entry( + label=label, + source=source, + package_name=package_name, + package_version=package_version, + vulnerability=vulnerability, + affected_range=affected_range, + status=status, + reason=reason, + ) + ) + continue if version_key < upper_key: retained.append(vulnerability) status = "AFFECTED" diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 479f2483f..bb9447b5c 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -187,7 +187,17 @@ def test_registry_package_never_borrows_direct_source_exception(self) -> None: payload = results("0.18.5", [vulnerability("GHSA-registry", "< 0.19.3")]) reconciled, audit = self.run_case(payload, registry_lock("0.18.5")) self.assertEqual(remaining_ids(reconciled), ["GHSA-registry"]) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + + payload["results"][0]["packages"][0]["package"]["name"] = "other" + reconciled, audit = OSV.reconcile_payload( + payload, registry_lock("0.18.5"), label="other" + ) self.assertEqual(audit, []) + self.assertEqual( + reconciled["results"][0]["packages"][0]["vulnerabilities"], + [vulnerability("GHSA-registry", "< 0.19.3")], + ) def test_unverifiable_direct_provenance_fails_closed(self) -> None: for lock_text in ( @@ -207,6 +217,46 @@ def test_unverifiable_direct_provenance_fails_closed(self) -> None: self.assertEqual(remaining_ids(reconciled), ["GHSA-unknown-source"]) self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + def test_only_exact_sheetjs_exception_version_can_reconcile(self) -> None: + for version in ("0.20.2", "0.20.4", "1.0.0"): + with self.subTest(version=version): + payload = results( + version, [vulnerability("GHSA-version-control", "< 0.20.2")] + ) + reconciled, audit = self.run_case( + payload, direct_lock(version) + ) + self.assertEqual( + remaining_ids(reconciled), ["GHSA-version-control"] + ) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + + def test_conflicting_duplicate_direct_sources_fail_closed(self) -> None: + payload = results( + "0.20.3", [vulnerability("GHSA-duplicate-source", "< 0.20.2")] + ) + lock_text = direct_lock("0.20.3") + direct_lock( + "0.20.3", host="example.invalid" + ) + reconciled, audit = self.run_case(payload, lock_text) + self.assertEqual(remaining_ids(reconciled), ["GHSA-duplicate-source"]) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + self.assertIn("multiple direct-source", audit[0]["reason"]) + + payload["results"][0]["packages"][0]["vulnerabilities"] = ["bad"] + with self.assertRaises(ValueError): + OSV.reconcile_payload(payload, lock_text, label="bad") + + def test_exact_exception_version_inside_range_remains_affected(self) -> None: + payload = results( + "0.20.3", [vulnerability("GHSA-affected-exception", "< 0.20.4")] + ) + reconciled, audit = self.run_case(payload, direct_lock("0.20.3")) + self.assertEqual( + remaining_ids(reconciled), ["GHSA-affected-exception"] + ) + self.assertEqual(audit[0]["status"], "AFFECTED") + def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> None: self.assertEqual(OSV.parse_semver("0.20.3"), (0, 20, 3)) self.assertIsNone(OSV.parse_semver("01.20.3")) @@ -335,7 +385,7 @@ def test_reconcile_rejects_malformed_packages_and_vulnerabilities(self) -> None: label="registry", ) self.assertEqual(remaining_ids(untouched), ["GHSA-registry"]) - self.assertEqual(audit, []) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -400,6 +450,10 @@ def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None self.assertLess(reconcile_step, require_output) self.assertLess(require_output, reporter) self.assertIn("github.workflow_sha", workflow) + self.assertNotIn( + "github.repository == 'ContextualWisdomLab/.github' && github.event.pull_request.head.sha", + workflow, + ) self.assertIn("osv_direct_source_reconcile.py", workflow) self.assertIn("osv-provenance-audit.json", workflow) From e2c003171e65c631ac8f12143e04f810fdad0576 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:34:20 +0900 Subject: [PATCH 17/50] fix(osv): reject malformed UTF-8 inputs safely --- scripts/ci/osv_direct_source_reconcile.py | 20 +++++++++++++++++--- tests/test_osv_direct_source_reconcile.py | 8 ++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 87cc17a35..911978ad6 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -395,12 +395,26 @@ def load_json_object(path: Path) -> dict[str, Any]: if path.is_symlink() or not path.is_file(): raise ValueError(f"required JSON input is not a regular file: {path}") - value = json.loads(path.read_text(encoding="utf-8")) + value = json.loads(read_utf8_text(path, "required JSON input")) if not isinstance(value, dict): raise ValueError(f"required JSON input is not an object: {path}") return value +def read_utf8_text(path: Path, description: str) -> str: + """Read trusted text input and turn malformed UTF-8 into an explicit error. + + Lockfiles and scanner receipts are attacker-controlled repository inputs. + Rejecting malformed bytes at this boundary keeps the security scan + fail-closed without exposing an unhandled decoder traceback. + """ + + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise ValueError(f"{description} is not valid UTF-8: {path}") from error + + def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" @@ -420,13 +434,13 @@ def main() -> int: raise ValueError("pnpm lock provenance input must be a regular file") payload = load_json_object(args.results) reconciled, new_audit = reconcile_payload( - payload, args.lockfile.read_text(encoding="utf-8"), label=args.label + payload, read_utf8_text(args.lockfile, "pnpm lock provenance input"), label=args.label ) existing_audit: list[dict[str, str]] = [] if args.audit.exists(): if args.audit.is_symlink() or not args.audit.is_file(): raise ValueError("audit output must be a regular file") - loaded_audit = json.loads(args.audit.read_text(encoding="utf-8")) + loaded_audit = json.loads(read_utf8_text(args.audit, "audit input")) if not isinstance(loaded_audit, list): raise ValueError("audit output must contain an array") existing_audit = loaded_audit diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index bb9447b5c..862da39ce 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -416,6 +416,14 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: self.assertEqual(OSV.main(), 0) self.assertEqual(json.loads(audit_path.read_text(encoding="utf-8")), [{"status": "prior"}]) + lock_path.write_bytes(b"lockfileVersion: '9.0'\n\xc0\xc0") + with mock.patch.object(sys, "argv", argv), self.assertRaisesRegex( + ValueError, "pnpm lock provenance input is not valid UTF-8" + ): + OSV.main() + + lock_path.write_text("lockfileVersion: '9.0'\n", encoding="utf-8") + audit_path.write_text("{}", encoding="utf-8") with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): OSV.main() From d1da60569c079f59b211a2495cbe0fdb6a7a1d02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:57:39 +0900 Subject: [PATCH 18/50] fix(osv): exit cleanly on malformed text input --- scripts/ci/osv_direct_source_reconcile.py | 53 +++++++++++++---------- tests/test_osv_direct_source_reconcile.py | 21 +++++---- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 911978ad6..a361bfa11 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -11,6 +11,7 @@ import os from pathlib import Path import re +import sys import tempfile from typing import Any, Iterable from urllib.parse import urlsplit @@ -429,30 +430,36 @@ def parse_args() -> argparse.Namespace: def main() -> int: """Reconcile one OSV document and publish append-only audit evidence.""" - args = parse_args() - if args.lockfile.is_symlink() or not args.lockfile.is_file(): - raise ValueError("pnpm lock provenance input must be a regular file") - payload = load_json_object(args.results) - reconciled, new_audit = reconcile_payload( - payload, read_utf8_text(args.lockfile, "pnpm lock provenance input"), label=args.label - ) - existing_audit: list[dict[str, str]] = [] - if args.audit.exists(): - if args.audit.is_symlink() or not args.audit.is_file(): - raise ValueError("audit output must be a regular file") - loaded_audit = json.loads(read_utf8_text(args.audit, "audit input")) - if not isinstance(loaded_audit, list): - raise ValueError("audit output must contain an array") - existing_audit = loaded_audit - atomic_json_write(args.results, reconciled) - atomic_json_write(args.audit, [*existing_audit, *new_audit]) - for entry in new_audit: - print( - "OSV provenance " - f"{entry['status']}: {entry['package']}@{entry['version']} " - f"{entry['vulnerability_id']} ({entry['reason']})" + try: + args = parse_args() + if args.lockfile.is_symlink() or not args.lockfile.is_file(): + raise ValueError("pnpm lock provenance input must be a regular file") + payload = load_json_object(args.results) + reconciled, new_audit = reconcile_payload( + payload, + read_utf8_text(args.lockfile, "pnpm lock provenance input"), + label=args.label, ) - return 0 + existing_audit: list[dict[str, str]] = [] + if args.audit.exists(): + if args.audit.is_symlink() or not args.audit.is_file(): + raise ValueError("audit output must be a regular file") + loaded_audit = json.loads(read_utf8_text(args.audit, "audit input")) + if not isinstance(loaded_audit, list): + raise ValueError("audit output must contain an array") + existing_audit = loaded_audit + atomic_json_write(args.results, reconciled) + atomic_json_write(args.audit, [*existing_audit, *new_audit]) + for entry in new_audit: + print( + "OSV provenance " + f"{entry['status']}: {entry['package']}@{entry['version']} " + f"{entry['vulnerability_id']} ({entry['reason']})" + ) + return 0 + except ValueError as error: + print(f"::error::{error}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 862da39ce..cf96cd865 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -4,6 +4,7 @@ import importlib.util import copy +import io import json from pathlib import Path import runpy @@ -417,25 +418,27 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: self.assertEqual(json.loads(audit_path.read_text(encoding="utf-8")), [{"status": "prior"}]) lock_path.write_bytes(b"lockfileVersion: '9.0'\n\xc0\xc0") - with mock.patch.object(sys, "argv", argv), self.assertRaisesRegex( - ValueError, "pnpm lock provenance input is not valid UTF-8" + with ( + mock.patch.object(sys, "argv", argv), + mock.patch.object(OSV.sys, "stderr", new_callable=io.StringIO) as stderr, ): - OSV.main() + self.assertEqual(OSV.main(), 1) + self.assertIn("pnpm lock provenance input is not valid UTF-8", stderr.getvalue()) lock_path.write_text("lockfileVersion: '9.0'\n", encoding="utf-8") audit_path.write_text("{}", encoding="utf-8") - with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): - OSV.main() + with mock.patch.object(sys, "argv", argv): + self.assertEqual(OSV.main(), 1) audit_path.unlink() audit_target = root / "audit-target.json" audit_target.write_text("[]", encoding="utf-8") audit_path.symlink_to(audit_target) - with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): - OSV.main() + with mock.patch.object(sys, "argv", argv): + self.assertEqual(OSV.main(), 1) lock_path.unlink() - with mock.patch.object(sys, "argv", argv), self.assertRaises(ValueError): - OSV.main() + with mock.patch.object(sys, "argv", argv): + self.assertEqual(OSV.main(), 1) def test_missing_authoritative_affected_range_fails_closed(self) -> None: payload = results("0.20.3", [vulnerability("GHSA-unknown-range", None)]) From f285fd790ba12161ad6385a46d9e3e60371103b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:13:47 +0900 Subject: [PATCH 19/50] test: document OSV provenance cases --- tests/test_osv_direct_source_reconcile.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index cf96cd865..1bbd64092 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -154,6 +154,7 @@ def run_case( return reconcile(Path(directory), payload, lock_text) def test_official_immutable_xlsx_0203_drops_only_metadata_disproven_findings(self) -> None: + """Drop only advisories disproven by the exact immutable release evidence.""" payload = results( "0.20.3", [ @@ -171,6 +172,7 @@ def test_official_immutable_xlsx_0203_drops_only_metadata_disproven_findings(sel self.assertTrue(all(entry["integrity"] == INTEGRITY for entry in audit)) def test_official_but_affected_versions_remain_findings(self) -> None: + """Keep versions inside their authoritative affected range.""" for version, affected_range in ( ("0.18.5", "< 0.19.3"), ("0.19.2", "< 0.19.3"), @@ -185,6 +187,7 @@ def test_official_but_affected_versions_remain_findings(self) -> None: self.assertEqual(audit[0]["status"], "AFFECTED") def test_registry_package_never_borrows_direct_source_exception(self) -> None: + """Never apply a direct-source exception to a registry-backed package.""" payload = results("0.18.5", [vulnerability("GHSA-registry", "< 0.19.3")]) reconciled, audit = self.run_case(payload, registry_lock("0.18.5")) self.assertEqual(remaining_ids(reconciled), ["GHSA-registry"]) @@ -201,6 +204,7 @@ def test_registry_package_never_borrows_direct_source_exception(self) -> None: ) def test_unverifiable_direct_provenance_fails_closed(self) -> None: + """Retain findings when direct URL or integrity provenance is unverifiable.""" for lock_text in ( direct_lock("0.20.3", integrity=None), direct_lock("0.20.3", host="example.invalid"), @@ -219,6 +223,7 @@ def test_unverifiable_direct_provenance_fails_closed(self) -> None: self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") def test_only_exact_sheetjs_exception_version_can_reconcile(self) -> None: + """Restrict reconciliation to the one explicitly governed SheetJS version.""" for version in ("0.20.2", "0.20.4", "1.0.0"): with self.subTest(version=version): payload = results( @@ -233,6 +238,7 @@ def test_only_exact_sheetjs_exception_version_can_reconcile(self) -> None: self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") def test_conflicting_duplicate_direct_sources_fail_closed(self) -> None: + """Reject duplicate direct-source records and malformed vulnerability entries.""" payload = results( "0.20.3", [vulnerability("GHSA-duplicate-source", "< 0.20.2")] ) @@ -249,6 +255,7 @@ def test_conflicting_duplicate_direct_sources_fail_closed(self) -> None: OSV.reconcile_payload(payload, lock_text, label="bad") def test_exact_exception_version_inside_range_remains_affected(self) -> None: + """Retain the exception version when the advisory range still includes it.""" payload = results( "0.20.3", [vulnerability("GHSA-affected-exception", "< 0.20.4")] ) @@ -259,6 +266,7 @@ def test_exact_exception_version_inside_range_remains_affected(self) -> None: self.assertEqual(audit[0]["status"], "AFFECTED") def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> None: + """Exercise malformed SemVer, digest, URL, and package-source boundaries.""" self.assertEqual(OSV.parse_semver("0.20.3"), (0, 20, 3)) self.assertIsNone(OSV.parse_semver("01.20.3")) self.assertFalse(OSV.valid_sha512_integrity("sha256-deadbeef")) @@ -288,6 +296,7 @@ def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> No ) def test_direct_source_parser_handles_boundaries_and_missing_fields(self) -> None: + """Parse adjacent lockfile records without trusting incomplete resolutions.""" valid_url = OFFICIAL_URL.format(version="0.20.3") lock = ( "packages:\n\n" @@ -302,6 +311,7 @@ def test_direct_source_parser_handles_boundaries_and_missing_fields(self) -> Non self.assertFalse(sources[1].valid) def test_malformed_osv_container_evidence_fails_closed(self) -> None: + """Reject malformed OSV containers while accepting an empty result set.""" malformed = ( {}, {"results": ["bad"]}, @@ -314,6 +324,7 @@ def test_malformed_osv_container_evidence_fails_closed(self) -> None: self.assertEqual(list(OSV.iter_packages({"results": [{"packages": []}]})), []) def test_authoritative_range_rejects_every_ambiguous_shape(self) -> None: + """Reject advisory shapes that cannot prove one authoritative affected range.""" valid = vulnerability("GHSA-shape", "< 0.20.2") self.assertEqual(OSV.authoritative_affected_range(valid, "xlsx"), "< 0.20.2") malformed = [ @@ -369,6 +380,7 @@ def test_authoritative_range_rejects_every_ambiguous_shape(self) -> None: self.assertIsNone(OSV.authoritative_affected_range(candidate, "xlsx")) def test_reconcile_rejects_malformed_packages_and_vulnerabilities(self) -> None: + """Reject malformed package and vulnerability records before rewriting results.""" bad_packages = ( {"results": [{"packages": [{"package": None, "vulnerabilities": []}]}]}, {"results": [{"packages": [{"package": {}, "vulnerabilities": "bad"}]}]}, @@ -389,6 +401,7 @@ def test_reconcile_rejects_malformed_packages_and_vulnerabilities(self) -> None: self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: + """Exercise regular-file, UTF-8, audit, and atomic-write failure boundaries.""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) missing = root / "missing.json" @@ -441,12 +454,14 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: self.assertEqual(OSV.main(), 1) def test_missing_authoritative_affected_range_fails_closed(self) -> None: + """Retain findings when the advisory lacks a machine-checkable range.""" payload = results("0.20.3", [vulnerability("GHSA-unknown-range", None)]) reconciled, audit = self.run_case(payload, direct_lock("0.20.3")) self.assertEqual(remaining_ids(reconciled), ["GHSA-unknown-range"]) self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None: + """Require provenance reconciliation before the reusable scan publishes its verdict.""" workflow = SECURITY_WORKFLOW.read_text(encoding="utf-8") preserve = workflow.index("Preserve base direct-source provenance") head_scan = workflow.index("Scan head with OSV") From c6c2684753a0ada5d17e0a36c4ac4bd2a7766052 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:49:41 +0900 Subject: [PATCH 20/50] fix(security): read OSV inputs without symlink races --- scripts/ci/osv_direct_source_reconcile.py | 55 +++++++++++++++++------ tests/test_osv_direct_source_reconcile.py | 6 +++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index a361bfa11..d819a0f08 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -11,6 +11,7 @@ import os from pathlib import Path import re +import stat import sys import tempfile from typing import Any, Iterable @@ -394,26 +395,57 @@ def atomic_json_write(path: Path, value: object) -> None: def load_json_object(path: Path) -> dict[str, Any]: """Load one JSON object from a regular non-symlink file.""" - if path.is_symlink() or not path.is_file(): - raise ValueError(f"required JSON input is not a regular file: {path}") value = json.loads(read_utf8_text(path, "required JSON input")) if not isinstance(value, dict): raise ValueError(f"required JSON input is not an object: {path}") return value -def read_utf8_text(path: Path, description: str) -> str: - """Read trusted text input and turn malformed UTF-8 into an explicit error. +def _read_regular_utf8_text(path: Path, description: str) -> str: + """Read one regular file through a descriptor that cannot follow symlinks. Lockfiles and scanner receipts are attacker-controlled repository inputs. - Rejecting malformed bytes at this boundary keeps the security scan - fail-closed without exposing an unhandled decoder traceback. + Opening with ``O_NOFOLLOW`` and checking the descriptor's type closes the + check/use gap between path validation and the actual read. """ + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + raise ValueError("secure regular-file reads require O_NOFOLLOW support") + descriptor: int | None = None try: - return path.read_text(encoding="utf-8") + descriptor = os.open(os.fspath(path), os.O_RDONLY | no_follow) + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise ValueError(f"{description} is not a regular file: {path}") + with os.fdopen(descriptor, "r", encoding="utf-8") as handle: + descriptor = None + return handle.read() + except FileNotFoundError as error: + raise ValueError(f"{description} is not a regular file: {path}") from error except UnicodeDecodeError as error: raise ValueError(f"{description} is not valid UTF-8: {path}") from error + except OSError as error: + raise ValueError(f"{description} is not a regular file: {path}") from error + finally: + if descriptor is not None: + os.close(descriptor) + + +def read_utf8_text(path: Path, description: str) -> str: + """Read trusted text input and turn malformed UTF-8 into an explicit error.""" + + return _read_regular_utf8_text(path, description) + + +def read_optional_utf8_text(path: Path, description: str) -> str | None: + """Read an existing regular file, returning ``None`` only when absent.""" + + try: + return read_utf8_text(path, description) + except ValueError as error: + if isinstance(error.__cause__, FileNotFoundError): + return None + raise def parse_args() -> argparse.Namespace: @@ -432,8 +464,6 @@ def main() -> int: try: args = parse_args() - if args.lockfile.is_symlink() or not args.lockfile.is_file(): - raise ValueError("pnpm lock provenance input must be a regular file") payload = load_json_object(args.results) reconciled, new_audit = reconcile_payload( payload, @@ -441,10 +471,9 @@ def main() -> int: label=args.label, ) existing_audit: list[dict[str, str]] = [] - if args.audit.exists(): - if args.audit.is_symlink() or not args.audit.is_file(): - raise ValueError("audit output must be a regular file") - loaded_audit = json.loads(read_utf8_text(args.audit, "audit input")) + audit_text = read_optional_utf8_text(args.audit, "audit input") + if audit_text is not None: + loaded_audit = json.loads(audit_text) if not isinstance(loaded_audit, list): raise ValueError("audit output must contain an array") existing_audit = loaded_audit diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 1bbd64092..2df30dc9a 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -407,6 +407,12 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: missing = root / "missing.json" with self.assertRaises(ValueError): OSV.load_json_object(missing) + target = root / "target.json" + target.write_text("{}", encoding="utf-8") + linked = root / "linked.json" + linked.symlink_to(target) + with self.assertRaises(ValueError): + OSV.load_json_object(linked) array_path = root / "array.json" array_path.write_text("[]", encoding="utf-8") with self.assertRaises(ValueError): From fe27160893f6d4d9fa5c30de1df6156c566aea6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:56:49 +0900 Subject: [PATCH 21/50] test(security): cover secure OSV input branches --- tests/test_osv_direct_source_reconcile.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 2df30dc9a..c68ef4312 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -413,6 +413,13 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: linked.symlink_to(target) with self.assertRaises(ValueError): OSV.load_json_object(linked) + with mock.patch.object(OSV.os, "O_NOFOLLOW", None): + with self.assertRaises(ValueError): + OSV.read_utf8_text(target, "required JSON input") + directory = root / "directory" + directory.mkdir() + with self.assertRaises(ValueError): + OSV.read_utf8_text(directory, "required JSON input") array_path = root / "array.json" array_path.write_text("[]", encoding="utf-8") with self.assertRaises(ValueError): From 75d7f5ffd25649b5da84dd267f8b3458996e031f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:59:45 +0900 Subject: [PATCH 22/50] style(test): combine secure input contexts --- tests/test_osv_direct_source_reconcile.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index c68ef4312..cf98658c3 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -413,9 +413,8 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: linked.symlink_to(target) with self.assertRaises(ValueError): OSV.load_json_object(linked) - with mock.patch.object(OSV.os, "O_NOFOLLOW", None): - with self.assertRaises(ValueError): - OSV.read_utf8_text(target, "required JSON input") + with mock.patch.object(OSV.os, "O_NOFOLLOW", None), self.assertRaises(ValueError): + OSV.read_utf8_text(target, "required JSON input") directory = root / "directory" directory.mkdir() with self.assertRaises(ValueError): From 9f46401d548789e1b8a1855faa2a0548c879aaea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:16:19 +0900 Subject: [PATCH 23/50] fix(osv): accept verified empty scan documents --- .github/workflows/security-scan.yml | 26 +++++++++++++++++++ CHANGELOG.md | 3 +++ .../doctoring/osv-direct-source-provenance.md | 11 ++++++++ .../test_required_workflow_queue_contract.py | 21 +++++++++++++-- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index fd4f8bec4..a548282f5 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -93,6 +93,7 @@ jobs: echo "::warning::OSV base scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." - name: Retry base OSV without transitive resolution if: steps.osv_base.outcome == 'failure' + id: osv_base_retry continue-on-error: true timeout-minutes: 4 uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 @@ -143,6 +144,7 @@ jobs: echo "::warning::OSV head scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." - name: Retry head OSV without transitive resolution if: steps.osv_head.outcome == 'failure' + id: osv_head_retry continue-on-error: true timeout-minutes: 4 uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 @@ -154,6 +156,30 @@ jobs: --allow-no-lockfiles -r ./ + - name: Require successful base and head OSV scans + run: | + set -euo pipefail + base_success="${{ steps.osv_base.outcome == 'success' || steps.osv_base_retry.outcome == 'success' }}" + head_success="${{ steps.osv_head.outcome == 'success' || steps.osv_head_retry.outcome == 'success' }}" + test "$base_success" = true + test "$head_success" = true + - name: Normalize successful empty OSV result documents + if: >- + (steps.osv_base.outcome == 'success' || steps.osv_base_retry.outcome == 'success') && + (steps.osv_head.outcome == 'success' || steps.osv_head_retry.outcome == 'success') + shell: bash + run: | + set -euo pipefail + for result_file in old-results.json new-results.json; do + if [ -L "$result_file" ]; then + echo "::error::OSV result document is a symlink: $result_file" + exit 1 + fi + if [ ! -s "$result_file" ]; then + printf '%s\n' '{"results":[]}' >"$result_file" + echo "::notice::OSV completed successfully without findings output; normalized $result_file as an empty result document." + fi + done - name: Checkout exact central provenance policy uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e42afe76a..301ad68fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Normalized a successful OSV scan with no findings output to a valid empty + result document after both base and head scan outcomes are verified, while + keeping failed scans, failed retries, and symlinked result paths fail-closed. - Refused PR Review Merge Scheduler head mutations, `update-branch` and the last-push approval head restamp, whenever the resolved mutation credential is the workflow `GITHUB_TOKEN`. GitHub starts no workflow run for events created with that credential, so the moved head collected no current-head required checks and the PR stayed permanently `BLOCKED` with a `github-actions[bot]` merge commit that no later scheduler run could repair, because the branch was no longer behind. The scheduler now waits with `head_mutation_credential_upgrade` guidance naming `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, and the OpenCode app token exchange. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). diff --git a/docs/doctoring/osv-direct-source-provenance.md b/docs/doctoring/osv-direct-source-provenance.md index dcb3d6b83..d25d79a0a 100644 --- a/docs/doctoring/osv-direct-source-provenance.md +++ b/docs/doctoring/osv-direct-source-provenance.md @@ -24,6 +24,17 @@ package name, or severity is blanket-ignored. The audit artifact records every retained or reconciled direct-source decision without copying untrusted advisory prose. +## Empty result documents + +The pinned OSV action can complete successfully without creating its requested +JSON file when a repository has no findings or no supported lockfile. The +central workflow first requires both base and head scans to report success, +then writes the valid empty document `{"results":[]}` for any missing or empty +result file. A failed first scan and failed retry never enter this path, and +symlinked result paths remain a hard failure. This preserves the distinction +between a verified clean scan and an unavailable scan without weakening the +base/head reporter gate. + ## Rollback and operations Rollback removes the reconciliation step and helper together, restoring raw diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 535fd513a..bf6887773 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -904,6 +904,8 @@ def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_fai ) assert "id: osv_base" in workflow assert "id: osv_head" in workflow + assert "id: osv_base_retry" in workflow + assert "id: osv_head_retry" in workflow assert "steps.osv_base.outcome == 'failure'" in workflow assert "steps.osv_head.outcome == 'failure'" in workflow assert "Retry base OSV without transitive resolution" in workflow @@ -920,15 +922,30 @@ def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_fai "external transitive registry resolution is intentionally avoided" in workflow ) assert ( - "Retry base OSV without transitive resolution\n if: steps.osv_base.outcome == 'failure'\n continue-on-error: true" + "Retry base OSV without transitive resolution\n if: steps.osv_base.outcome == 'failure'\n id: osv_base_retry\n continue-on-error: true" in workflow ) assert ( - "Retry head OSV without transitive resolution\n if: steps.osv_head.outcome == 'failure'\n continue-on-error: true" + "Retry head OSV without transitive resolution\n if: steps.osv_head.outcome == 'failure'\n id: osv_head_retry\n continue-on-error: true" in workflow ) assert "--output=old-results.json" in workflow assert "--output=new-results.json" in workflow + assert "Require successful base and head OSV scans" in workflow + assert "Normalize successful empty OSV result documents" in workflow + assert "printf '%s\\n' '{\"results\":[]}' >\"$result_file\"" in workflow + assert "OSV completed successfully without findings output" in workflow + assert ( + "steps.osv_base.outcome == 'success' || steps.osv_base_retry.outcome == 'success'" + in workflow + ) + assert ( + "steps.osv_head.outcome == 'success' || steps.osv_head_retry.outcome == 'success'" + in workflow + ) + assert workflow.index("Require successful base and head OSV scans") < workflow.index( + "Normalize successful empty OSV result documents" + ) < workflow.index("Require OSV scan output") assert "Print OSV findings being compared" in workflow assert "OSV {label} scan produced {len(findings)} finding(s)" in workflow From 6a4e63a2c3fb7294ae93643e054fd8e96aad7479 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:22:04 +0900 Subject: [PATCH 24/50] docs: close coordinator docstring gate --- scripts/ci/organization_commercial_readiness_loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/organization_commercial_readiness_loop.py b/scripts/ci/organization_commercial_readiness_loop.py index c00cfa1e0..1ede18a00 100644 --- a/scripts/ci/organization_commercial_readiness_loop.py +++ b/scripts/ci/organization_commercial_readiness_loop.py @@ -239,6 +239,7 @@ class GitHubClient: """Use the GitHub CLI as an authenticated, bounded REST transport.""" def __init__(self, token: str, *, timeout_seconds: int = 60) -> None: + """Initialize the client with one non-empty GitHub credential.""" if not token: raise GitHubError("GH_TOKEN is required for organization coordination") self._token = token @@ -853,4 +854,4 @@ def main( if __name__ == "__main__": # pragma: no cover - exercised through main() - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From 0f2e4c9b4009f6a1eea8c4ac1ecfc3f7b9715c9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:40:29 +0900 Subject: [PATCH 25/50] chore(osv): satisfy reconciler lint contract --- scripts/ci/osv_direct_source_reconcile.py | 25 +++++++++++------------ tests/test_osv_direct_source_reconcile.py | 22 ++++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index d819a0f08..f90a02550 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Reconcile OSV registry findings with exact immutable direct-source evidence.""" from __future__ import annotations @@ -6,18 +5,18 @@ import argparse import base64 import binascii -from dataclasses import dataclass import json import os -from pathlib import Path import re import stat import sys import tempfile -from typing import Any, Iterable +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any from urllib.parse import urlsplit - SEMVER_RE = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") DIRECT_HEADER_RE = re.compile( r"(?m)^ (?P(?:@[^/\s]+/)?[^@\s]+)@(?Phttps://[^\s]+):[ \t]*$" @@ -139,16 +138,16 @@ def iter_packages(payload: dict[str, Any]) -> Iterable[dict[str, Any]]: results = payload.get("results") if not isinstance(results, list): - raise ValueError("OSV results must contain a results array") + raise TypeError("OSV results must contain a results array") for result in results: if not isinstance(result, dict): - raise ValueError("OSV result entries must be objects") + raise TypeError("OSV result entries must be objects") packages = result.get("packages") or [] if not isinstance(packages, list): - raise ValueError("OSV result packages must be an array") + raise TypeError("OSV result packages must be an array") for package in packages: if not isinstance(package, dict): - raise ValueError("OSV package entries must be objects") + raise TypeError("OSV package entries must be objects") yield package @@ -239,7 +238,7 @@ def reconcile_payload( package_info = package.get("package") vulnerabilities = package.get("vulnerabilities") or [] if not isinstance(package_info, dict) or not isinstance(vulnerabilities, list): - raise ValueError("OSV package evidence is malformed") + raise TypeError("OSV package evidence is malformed") package_name = str(package_info.get("name") or "") package_version = str(package_info.get("version") or "") candidates = [ @@ -269,7 +268,7 @@ def reconcile_payload( retained = [] for vulnerability in vulnerabilities: if not isinstance(vulnerability, dict): - raise ValueError("OSV vulnerability entries must be objects") + raise TypeError("OSV vulnerability entries must be objects") retained.append(vulnerability) audit.append( audit_entry( @@ -291,7 +290,7 @@ def reconcile_payload( retained: list[dict[str, Any]] = [] for vulnerability in vulnerabilities: if not isinstance(vulnerability, dict): - raise ValueError("OSV vulnerability entries must be objects") + raise TypeError("OSV vulnerability entries must be objects") affected_range = authoritative_affected_range( vulnerability, package_name ) @@ -397,7 +396,7 @@ def load_json_object(path: Path) -> dict[str, Any]: value = json.loads(read_utf8_text(path, "required JSON input")) if not isinstance(value, dict): - raise ValueError(f"required JSON input is not an object: {path}") + raise TypeError(f"required JSON input is not an object: {path}") return value diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index cf98658c3..e76ee4b72 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -2,18 +2,17 @@ from __future__ import annotations -import importlib.util import copy +import importlib.util import io import json -from pathlib import Path import runpy import sys import tempfile import unittest +from pathlib import Path from unittest import mock - REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts" / "ci" / "osv_direct_source_reconcile.py" SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security-scan.yml" @@ -251,7 +250,7 @@ def test_conflicting_duplicate_direct_sources_fail_closed(self) -> None: self.assertIn("multiple direct-source", audit[0]["reason"]) payload["results"][0]["packages"][0]["vulnerabilities"] = ["bad"] - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): OSV.reconcile_payload(payload, lock_text, label="bad") def test_exact_exception_version_inside_range_remains_affected(self) -> None: @@ -319,7 +318,7 @@ def test_malformed_osv_container_evidence_fails_closed(self) -> None: {"results": [{"packages": ["bad"]}]}, ) for payload in malformed: - with self.subTest(payload=payload), self.assertRaises(ValueError): + with self.subTest(payload=payload), self.assertRaises(TypeError): list(OSV.iter_packages(payload)) self.assertEqual(list(OSV.iter_packages({"results": [{"packages": []}]})), []) @@ -386,11 +385,11 @@ def test_reconcile_rejects_malformed_packages_and_vulnerabilities(self) -> None: {"results": [{"packages": [{"package": {}, "vulnerabilities": "bad"}]}]}, ) for payload in bad_packages: - with self.subTest(payload=payload), self.assertRaises(ValueError): + with self.subTest(payload=payload), self.assertRaises(TypeError): OSV.reconcile_payload(payload, direct_lock("0.20.3"), label="bad") bad_vulnerability = results("0.20.3", []) bad_vulnerability["results"][0]["packages"][0]["vulnerabilities"] = ["bad"] - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): OSV.reconcile_payload(bad_vulnerability, direct_lock("0.20.3"), label="bad") untouched, audit = OSV.reconcile_payload( results("0.20.3", [vulnerability("GHSA-registry", "< 0.20.2")]), @@ -421,12 +420,13 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: OSV.read_utf8_text(directory, "required JSON input") array_path = root / "array.json" array_path.write_text("[]", encoding="utf-8") - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): OSV.load_json_object(array_path) destination = root / "atomic.json" - with mock.patch.object(OSV.os, "replace", side_effect=OSError("replace failed")): - with self.assertRaises(OSError): - OSV.atomic_json_write(destination, {}) + with mock.patch.object( + OSV.os, "replace", side_effect=OSError("replace failed") + ), self.assertRaises(OSError): + OSV.atomic_json_write(destination, {}) results_path = root / "results.json" lock_path = root / "pnpm-lock.yaml" From 6e93fd0b65c159c7b168d83579e5b8282096480e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:43:47 +0900 Subject: [PATCH 26/50] fix(osv): parse pnpm package provenance safely --- scripts/ci/osv_direct_source_reconcile.py | 82 ++++++++++++++--------- tests/test_osv_direct_source_reconcile.py | 30 +++++++++ 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index f90a02550..8b6f11a3b 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -21,11 +21,15 @@ DIRECT_HEADER_RE = re.compile( r"(?m)^ (?P(?:@[^/\s]+/)?[^@\s]+)@(?Phttps://[^\s]+):[ \t]*$" ) +PACKAGES_SECTION_RE = re.compile(r"(?m)^packages:[ \t]*\n") +TOP_LEVEL_SECTION_RE = re.compile(r"(?m)^[^ \t\r\n#][^:\r\n]*:[^\r\n]*$") ENTRY_BOUNDARY_RE = re.compile(r"(?m)^ \S") VERSION_LINE_RE = re.compile(r"(?m)^ version:[ \t]*['\"]?([^'\"\s]+)['\"]?[ \t]*$") TARBALL_RE = re.compile(r"(?:^|[, {])[ \t]*tarball:[ \t]*([^, }]+)") INTEGRITY_RE = re.compile(r"(?:^|[, {])[ \t]*integrity:[ \t]*(sha512-[A-Za-z0-9+/=]+)") -AFFECTED_RANGE_RE = re.compile(r"^[ \t]*<[ \t]*(\d+\.\d+\.\d+)[ \t]*$") +AFFECTED_RANGE_RE = re.compile( + r"^[ \t]*(?P<=|<)[ \t]*(?P\d+\.\d+\.\d+)[ \t]*$" +) SHEETJS_EXCEPTION_VERSION = "0.20.3" SHEETJS_URL_RE = re.compile( r"^/xlsx-(?P\d+\.\d+\.\d+)/xlsx-(?P=version)\.tgz$" @@ -102,34 +106,45 @@ def validate_sheetjs_source( def parse_direct_sources(lock_text: str) -> list[DirectSource]: - """Parse conservative direct HTTPS package records from a pnpm lockfile.""" + """Parse direct HTTPS package records from pnpm's ``packages`` map. + + pnpm v9 repeats direct-tarball keys in ``snapshots``. Snapshot entries + describe dependency edges and do not carry resolution provenance, so + parsing only the ``packages`` map prevents a false duplicate-source + conflict while retaining conflicting package metadata as a finding. + """ sources: list[DirectSource] = [] - matches = list(DIRECT_HEADER_RE.finditer(lock_text)) - for match in matches: - start = match.end() - boundary = ENTRY_BOUNDARY_RE.search(lock_text, start) - end = boundary.start() if boundary else len(lock_text) - block = lock_text[start:end] - version_match = VERSION_LINE_RE.search(block) - tarball_match = TARBALL_RE.search(block) - integrity_match = INTEGRITY_RE.search(block) - version = version_match.group(1) if version_match else "" - tarball = tarball_match.group(1) if tarball_match else "" - integrity = integrity_match.group(1) if integrity_match else "" - valid, reason = validate_sheetjs_source( - match.group("name"), match.group("url"), tarball, version, integrity - ) - sources.append( - DirectSource( - package_name=match.group("name"), - version=version, - source_url=match.group("url"), - integrity=integrity, - valid=valid, - reason=reason, + for packages_section in PACKAGES_SECTION_RE.finditer(lock_text): + section_start = packages_section.end() + next_section = TOP_LEVEL_SECTION_RE.search(lock_text, section_start) + section_end = next_section.start() if next_section else len(lock_text) + package_text = lock_text[section_start:section_end] + matches = list(DIRECT_HEADER_RE.finditer(package_text)) + for match in matches: + start = match.end() + boundary = ENTRY_BOUNDARY_RE.search(package_text, start) + end = boundary.start() if boundary else len(package_text) + block = package_text[start:end] + version_match = VERSION_LINE_RE.search(block) + tarball_match = TARBALL_RE.search(block) + integrity_match = INTEGRITY_RE.search(block) + version = version_match.group(1) if version_match else "" + tarball = tarball_match.group(1) if tarball_match else "" + integrity = integrity_match.group(1) if integrity_match else "" + valid, reason = validate_sheetjs_source( + match.group("name"), match.group("url"), tarball, version, integrity + ) + sources.append( + DirectSource( + package_name=match.group("name"), + version=version, + source_url=match.group("url"), + integrity=integrity, + valid=valid, + reason=reason, + ) ) - ) return sources @@ -311,7 +326,9 @@ def reconcile_payload( continue range_match = AFFECTED_RANGE_RE.fullmatch(affected_range or "") version_key = parse_semver(package_version) - upper_key = parse_semver(range_match.group(1)) if range_match else None + upper_key = ( + parse_semver(range_match.group("version")) if range_match else None + ) if version_key is None or upper_key is None: retained.append(vulnerability) audit.append( @@ -323,13 +340,18 @@ def reconcile_payload( vulnerability=vulnerability, affected_range=affected_range, status="SCANNER_METADATA_CONFLICT", - reason="advisory lacks one machine-checkable exclusive affected upper bound", + reason="advisory lacks one machine-checkable affected upper bound", ) ) continue + inside_affected_range = ( + version_key <= upper_key + if range_match.group("operator") == "<=" + else version_key < upper_key + ) if package_version != SHEETJS_EXCEPTION_VERSION: retained.append(vulnerability) - if version_key < upper_key: + if inside_affected_range: status = "AFFECTED" reason = "exact direct-source version remains inside the affected range" else: @@ -351,7 +373,7 @@ def reconcile_payload( ) ) continue - if version_key < upper_key: + if inside_affected_range: retained.append(vulnerability) status = "AFFECTED" reason = "exact direct-source version remains inside the affected range" diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index e76ee4b72..a9c2dd81f 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -264,6 +264,20 @@ def test_exact_exception_version_inside_range_remains_affected(self) -> None: ) self.assertEqual(audit[0]["status"], "AFFECTED") + def test_inclusive_affected_bound_is_respected(self) -> None: + """Treat an OSV ``<=`` upper bound as affected at the boundary.""" + for affected_range, expected_ids, expected_status in ( + ("<= 0.20.3", ["GHSA-inclusive"], "AFFECTED"), + ("<= 0.20.2", [], "RECONCILED"), + ): + with self.subTest(affected_range=affected_range): + payload = results( + "0.20.3", [vulnerability("GHSA-inclusive", affected_range)] + ) + reconciled, audit = self.run_case(payload, direct_lock("0.20.3")) + self.assertEqual(remaining_ids(reconciled), expected_ids) + self.assertEqual(audit[0]["status"], expected_status) + def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> None: """Exercise malformed SemVer, digest, URL, and package-source boundaries.""" self.assertEqual(OSV.parse_semver("0.20.3"), (0, 20, 3)) @@ -309,6 +323,22 @@ def test_direct_source_parser_handles_boundaries_and_missing_fields(self) -> Non self.assertFalse(sources[0].valid) self.assertFalse(sources[1].valid) + def test_direct_source_parser_ignores_pnpm_snapshot_duplicates(self) -> None: + """Read one package provenance record when pnpm repeats it in snapshots.""" + valid_url = OFFICIAL_URL.format(version="0.20.3") + lock = direct_lock("0.20.3") + ( + "\nsnapshots:\n\n" + f" xlsx@{valid_url}:\n" + " dependencies: {}\n" + ) + sources = OSV.parse_direct_sources(lock) + self.assertEqual(len(sources), 1) + self.assertTrue(sources[0].valid) + payload = results("0.20.3", [vulnerability("GHSA-snapshot", "< 0.20.2")]) + reconciled, audit = OSV.reconcile_payload(payload, lock, label="snapshot") + self.assertEqual(remaining_ids(reconciled), []) + self.assertEqual(audit[0]["status"], "RECONCILED") + def test_malformed_osv_container_evidence_fails_closed(self) -> None: """Reject malformed OSV containers while accepting an empty result set.""" malformed = ( From 3a619b50b999a8409c226dea13e23d37c114488b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:17:04 +0900 Subject: [PATCH 27/50] fix(security): pin pip-audit pip below vulnerability --- CHANGELOG.md | 2 ++ requirements-pip-audit-ci-hashes.txt | 6 +++--- requirements-pip-audit-ci.txt | 1 + tests/test_pip_audit_lock_security.py | 23 +++++++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 tests/test_pip_audit_lock_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 301ad68fb..0c0909b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Pinned the hash-locked pip-audit toolchain to pip 26.2.1, avoiding + PYSEC-2026-3721 / CVE-2026-13346 in pip 26.1.2. - Normalized a successful OSV scan with no findings output to a valid empty result document after both base and head scan outcomes are verified, while keeping failed scans, failed retries, and symlinked result paths fail-closed. diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49..0ae099d8f 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ diff --git a/requirements-pip-audit-ci.txt b/requirements-pip-audit-ci.txt index 684087ba5..96e08e14f 100644 --- a/requirements-pip-audit-ci.txt +++ b/requirements-pip-audit-ci.txt @@ -1 +1,2 @@ pip-audit==2.10.1 +pip==26.2.1 diff --git a/tests/test_pip_audit_lock_security.py b/tests/test_pip_audit_lock_security.py new file mode 100644 index 000000000..f4edfad62 --- /dev/null +++ b/tests/test_pip_audit_lock_security.py @@ -0,0 +1,23 @@ +"""Regression contract for the hash-locked pip-audit toolchain.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def test_pip_audit_toolchain_uses_fixed_pip_release() -> None: + """Keep the OSV-repaired pip release explicit in source and lock files.""" + source = (REPOSITORY_ROOT / "requirements-pip-audit-ci.txt").read_text( + encoding="utf-8" + ) + lock = (REPOSITORY_ROOT / "requirements-pip-audit-ci-hashes.txt").read_text( + encoding="utf-8" + ) + + assert "pip==26.2.1" in source + assert "pip==26.1.2" not in source + assert "pip==26.2.1" in lock + assert "pip==26.1.2" not in lock + assert "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e" in lock + assert "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f" in lock From ad4674439c60950eb4486802cc8b59186ff23dd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:29:10 -0700 Subject: [PATCH 28/50] fix(osv): distinguish findings from scanner failure Treat an exit-1 OSV run with complete vulnerability JSON as authoritative finding evidence so base/head comparison can proceed. Missing, malformed, empty-on-failure, symlinked, and stale consumer-supplied result documents remain non-passing and retry once. Bind direct-source reconciliation to the exact root pnpm lockfile source so another registry lockfile cannot borrow immutable tarball provenance. Preserve the base result across the head checkout. Drop the duplicate pip-audit lock commit from this OSV-owned branch; #1198 remains the canonical writer for that dependency update. --- .github/workflows/security-scan.yml | 221 +++++++++++++++--- CHANGELOG.md | 2 - requirements-pip-audit-ci-hashes.txt | 6 +- requirements-pip-audit-ci.txt | 1 - scripts/ci/osv_direct_source_reconcile.py | 91 +++++++- tests/test_osv_direct_source_reconcile.py | 73 +++++- tests/test_pip_audit_lock_security.py | 23 -- .../test_required_workflow_queue_contract.py | 169 ++++++++++++-- 8 files changed, 503 insertions(+), 83 deletions(-) delete mode 100644 tests/test_pip_audit_lock_security.py diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index a548282f5..60b2aa439 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -73,6 +73,81 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 persist-credentials: false + - name: Install trusted OSV result evidence classifier + shell: bash + run: | + set -euo pipefail + cat >"$RUNNER_TEMP/classify-osv-result.py" <<'PY' + import json + import os + from pathlib import Path + + result = Path(os.environ["RESULT_FILE"]) + outcome = os.environ.get("SCAN_OUTCOME", "") + complete = False + message = "" + try: + if result.is_symlink() or (result.exists() and not result.is_file()): + raise ValueError("result document must be a regular file") + if not result.exists() or result.stat().st_size == 0: + if outcome != "success": + raise ValueError("failed without an OSV result document") + result.write_text('{"results":[]}\n', encoding="utf-8") + complete = True + message = "completed successfully without findings output" + else: + document = json.loads(result.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("result document must be an object") + results = document.get("results") + if not isinstance(results, list): + raise ValueError("results must be a list") + finding_count = 0 + for scan_result in results: + if not isinstance(scan_result, dict): + raise ValueError("each result must be an object") + packages = scan_result.get("packages", []) + if not isinstance(packages, list): + raise ValueError("packages must be a list") + for package in packages: + if not isinstance(package, dict): + raise ValueError("each package must be an object") + vulnerabilities = package.get("vulnerabilities", []) + if not isinstance(vulnerabilities, list): + raise ValueError("vulnerabilities must be a list") + if not all(isinstance(item, dict) for item in vulnerabilities): + raise ValueError("each vulnerability must be an object") + finding_count += len(vulnerabilities) + if outcome == "success": + complete = True + message = f"completed successfully with {finding_count} finding(s)" + elif outcome == "failure" and finding_count > 0: + complete = True + message = ( + "failure with authoritative vulnerability evidence " + f"({finding_count} finding(s)) is a completed scan, not infrastructure failure" + ) + else: + raise ValueError("failed without authoritative vulnerability evidence") + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: + message = str(error) + + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"complete={'true' if complete else 'false'}\n") + level = "notice" if complete else "warning" + print(f"::{level}::OSV result evidence: {message}") + PY + chmod 500 "$RUNNER_TEMP/classify-osv-result.py" + - name: Prepare base OSV output boundary + shell: python3 {0} + run: | + from pathlib import Path + + result = Path("old-results.json") + if result.exists() or result.is_symlink(): + if result.is_dir() and not result.is_symlink(): + raise SystemExit("old-results.json is a directory; refusing an untrusted scan output boundary") + result.unlink() - name: Scan base with OSV id: osv_base continue-on-error: true @@ -87,12 +162,35 @@ jobs: --allow-no-lockfiles -r ./ + - name: Classify base OSV result evidence + id: osv_base_evidence + if: always() + shell: python3 {0} + env: + RESULT_FILE: old-results.json + SCAN_OUTCOME: ${{ steps.osv_base.outcome }} + run: | + import os + import runpy + + runpy.run_path(os.path.join(os.environ["RUNNER_TEMP"], "classify-osv-result.py")) - name: Explain base OSV resolver fallback - if: steps.osv_base.outcome == 'failure' + if: steps.osv_base_evidence.outputs.complete != 'true' run: | - echo "::warning::OSV base scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." + echo "::warning::OSV base scan did not produce authoritative result evidence; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." + - name: Clear incomplete base OSV output before retry + if: steps.osv_base_evidence.outputs.complete != 'true' + shell: python3 {0} + run: | + from pathlib import Path + + result = Path("old-results.json") + if result.exists() or result.is_symlink(): + if result.is_dir() and not result.is_symlink(): + raise SystemExit("old-results.json is a directory; refusing an untrusted retry boundary") + result.unlink() - name: Retry base OSV without transitive resolution - if: steps.osv_base.outcome == 'failure' + if: steps.osv_base_evidence.outputs.complete != 'true' id: osv_base_retry continue-on-error: true timeout-minutes: 4 @@ -105,10 +203,27 @@ jobs: --allow-no-lockfiles -r ./ - - name: Preserve base direct-source provenance + - name: Classify retried base OSV result evidence + id: osv_base_retry_evidence + if: steps.osv_base_evidence.outputs.complete != 'true' + shell: python3 {0} + env: + RESULT_FILE: old-results.json + SCAN_OUTCOME: ${{ steps.osv_base_retry.outcome }} + run: | + import os + import runpy + + runpy.run_path(os.path.join(os.environ["RUNNER_TEMP"], "classify-osv-result.py")) + - name: Preserve base OSV evidence and direct-source provenance run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/osv-base-provenance" + if [ -L old-results.json ] || [ ! -f old-results.json ]; then + echo "::error::Base OSV result document is not an authoritative regular file." + exit 1 + fi + cp -- old-results.json "$RUNNER_TEMP/osv-base-provenance/old-results.json" if [ -L pnpm-lock.yaml ]; then echo "::error::Base pnpm-lock.yaml is a symlink; direct-source provenance is not authoritative." exit 1 @@ -124,6 +239,16 @@ jobs: fetch-depth: 0 clean: false persist-credentials: false + - name: Prepare head OSV output boundary + shell: python3 {0} + run: | + from pathlib import Path + + result = Path("new-results.json") + if result.exists() or result.is_symlink(): + if result.is_dir() and not result.is_symlink(): + raise SystemExit("new-results.json is a directory; refusing an untrusted scan output boundary") + result.unlink() - name: Scan head with OSV id: osv_head continue-on-error: true @@ -138,12 +263,35 @@ jobs: --allow-no-lockfiles -r ./ + - name: Classify head OSV result evidence + id: osv_head_evidence + if: always() + shell: python3 {0} + env: + RESULT_FILE: new-results.json + SCAN_OUTCOME: ${{ steps.osv_head.outcome }} + run: | + import os + import runpy + + runpy.run_path(os.path.join(os.environ["RUNNER_TEMP"], "classify-osv-result.py")) - name: Explain head OSV resolver fallback - if: steps.osv_head.outcome == 'failure' + if: steps.osv_head_evidence.outputs.complete != 'true' run: | - echo "::warning::OSV head scan failed or timed out before reporter output was trusted; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." + echo "::warning::OSV head scan did not produce authoritative result evidence; retrying the --no-resolve direct manifest/lockfile scan. Direct manifest and lockfile vulnerability evidence remains enforced while external transitive registry resolution is intentionally avoided." + - name: Clear incomplete head OSV output before retry + if: steps.osv_head_evidence.outputs.complete != 'true' + shell: python3 {0} + run: | + from pathlib import Path + + result = Path("new-results.json") + if result.exists() or result.is_symlink(): + if result.is_dir() and not result.is_symlink(): + raise SystemExit("new-results.json is a directory; refusing an untrusted retry boundary") + result.unlink() - name: Retry head OSV without transitive resolution - if: steps.osv_head.outcome == 'failure' + if: steps.osv_head_evidence.outputs.complete != 'true' id: osv_head_retry continue-on-error: true timeout-minutes: 4 @@ -156,30 +304,45 @@ jobs: --allow-no-lockfiles -r ./ - - name: Require successful base and head OSV scans + - name: Classify retried head OSV result evidence + id: osv_head_retry_evidence + if: steps.osv_head_evidence.outputs.complete != 'true' + shell: python3 {0} + env: + RESULT_FILE: new-results.json + SCAN_OUTCOME: ${{ steps.osv_head_retry.outcome }} run: | - set -euo pipefail - base_success="${{ steps.osv_base.outcome == 'success' || steps.osv_base_retry.outcome == 'success' }}" - head_success="${{ steps.osv_head.outcome == 'success' || steps.osv_head_retry.outcome == 'success' }}" - test "$base_success" = true - test "$head_success" = true - - name: Normalize successful empty OSV result documents - if: >- - (steps.osv_base.outcome == 'success' || steps.osv_base_retry.outcome == 'success') && - (steps.osv_head.outcome == 'success' || steps.osv_head_retry.outcome == 'success') - shell: bash + import os + import runpy + + runpy.run_path(os.path.join(os.environ["RUNNER_TEMP"], "classify-osv-result.py")) + - name: Restore authoritative base OSV evidence + shell: python3 {0} + env: + RUNNER_TEMP: ${{ runner.temp }} + run: | + import os + import shutil + from pathlib import Path + + preserved = Path(os.environ["RUNNER_TEMP"]) / "osv-base-provenance" / "old-results.json" + destination = Path("old-results.json") + if preserved.is_symlink() or not preserved.is_file(): + raise SystemExit("preserved base OSV result document is not an authoritative regular file") + if destination.exists() or destination.is_symlink(): + if destination.is_dir() and not destination.is_symlink(): + raise SystemExit("old-results.json is a directory; refusing an untrusted restore boundary") + destination.unlink() + shutil.copyfile(preserved, destination) + - name: Require authoritative base and head OSV evidence run: | set -euo pipefail - for result_file in old-results.json new-results.json; do - if [ -L "$result_file" ]; then - echo "::error::OSV result document is a symlink: $result_file" - exit 1 - fi - if [ ! -s "$result_file" ]; then - printf '%s\n' '{"results":[]}' >"$result_file" - echo "::notice::OSV completed successfully without findings output; normalized $result_file as an empty result document." - fi - done + base_complete="${{ steps.osv_base_evidence.outputs.complete == 'true' || steps.osv_base_retry_evidence.outputs.complete == 'true' }}" + head_complete="${{ steps.osv_head_evidence.outputs.complete == 'true' || steps.osv_head_retry_evidence.outputs.complete == 'true' }}" + if [ "$base_complete" != true ] || [ "$head_complete" != true ]; then + echo "::error::OSV base/head scan did not produce authoritative complete result evidence." + exit 1 + fi - name: Checkout exact central provenance policy uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -200,6 +363,7 @@ jobs: python3 "$reconciler" \ --results old-results.json \ --lockfile "$base_lock" \ + --source-path pnpm-lock.yaml \ --audit "$audit" \ --label base fi @@ -212,6 +376,7 @@ jobs: python3 "$reconciler" \ --results new-results.json \ --lockfile pnpm-lock.yaml \ + --source-path pnpm-lock.yaml \ --audit "$audit" \ --label head fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0909b24..301ad68fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,6 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Pinned the hash-locked pip-audit toolchain to pip 26.2.1, avoiding - PYSEC-2026-3721 / CVE-2026-13346 in pip 26.1.2. - Normalized a successful OSV scan with no findings output to a valid empty result document after both base and head scan outcomes are verified, while keeping failed scans, failed retries, and symlinked result paths fail-closed. diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index 0ae099d8f..ade197a49 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.2.1 \ - --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ - --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f +pip==26.1.2 \ + --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ + --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ diff --git a/requirements-pip-audit-ci.txt b/requirements-pip-audit-ci.txt index 96e08e14f..684087ba5 100644 --- a/requirements-pip-audit-ci.txt +++ b/requirements-pip-audit-ci.txt @@ -1,2 +1 @@ pip-audit==2.10.1 -pip==26.2.1 diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 8b6f11a3b..7ea023999 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -13,7 +13,7 @@ import tempfile from collections.abc import Iterable from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any from urllib.parse import urlsplit @@ -148,8 +148,10 @@ def parse_direct_sources(lock_text: str) -> list[DirectSource]: return sources -def iter_packages(payload: dict[str, Any]) -> Iterable[dict[str, Any]]: - """Yield package findings from an OSV Scanner result document.""" +def iter_result_packages( + payload: dict[str, Any], +) -> Iterable[tuple[str | None, dict[str, Any]]]: + """Yield each scanner source path with its package findings.""" results = payload.get("results") if not isinstance(results, list): @@ -157,13 +159,52 @@ def iter_packages(payload: dict[str, Any]) -> Iterable[dict[str, Any]]: for result in results: if not isinstance(result, dict): raise TypeError("OSV result entries must be objects") + source = result.get("source") + observed_source_path = ( + source.get("path") + if isinstance(source, dict) and isinstance(source.get("path"), str) + else None + ) packages = result.get("packages") or [] if not isinstance(packages, list): raise TypeError("OSV result packages must be an array") for package in packages: if not isinstance(package, dict): raise TypeError("OSV package entries must be objects") - yield package + yield observed_source_path, package + + +def iter_packages(payload: dict[str, Any]) -> Iterable[dict[str, Any]]: + """Yield package findings from an OSV Scanner result document.""" + + for _, package in iter_result_packages(payload): + yield package + + +def validate_source_path(source_path: str) -> str: + """Return one normalized repository-relative governed lockfile path.""" + + candidate = PurePosixPath(source_path) + if ( + not source_path + or candidate.is_absolute() + or "\\" in source_path + or any(part in {"", ".", ".."} for part in candidate.parts) + ): + raise ValueError("governed OSV source path must be one normalized relative path") + return candidate.as_posix() + + +def source_matches_governed_lockfile( + observed_source_path: str | None, governed_source_path: str +) -> bool: + """Bind a scanner result to the exact governed workspace lockfile.""" + + governed = validate_source_path(governed_source_path) + return observed_source_path in { + governed, + f"/github/workspace/{governed}", + } def authoritative_affected_range( @@ -243,19 +284,55 @@ def audit_entry( def reconcile_payload( - payload: dict[str, Any], lock_text: str, *, label: str + payload: dict[str, Any], + lock_text: str, + *, + label: str, + source_path: str = "pnpm-lock.yaml", ) -> tuple[dict[str, Any], list[dict[str, str]]]: """Remove only findings disproven by exact source and affected-range evidence.""" direct_sources = parse_direct_sources(lock_text) audit: list[dict[str, str]] = [] - for package in iter_packages(payload): + governed_source_path = validate_source_path(source_path) + for observed_source_path, package in iter_result_packages(payload): package_info = package.get("package") vulnerabilities = package.get("vulnerabilities") or [] if not isinstance(package_info, dict) or not isinstance(vulnerabilities, list): raise TypeError("OSV package evidence is malformed") package_name = str(package_info.get("name") or "") package_version = str(package_info.get("version") or "") + if not source_matches_governed_lockfile( + observed_source_path, governed_source_path + ): + if package_name != "xlsx": + continue + source = DirectSource( + package_name=package_name, + version=package_version, + source_url="", + integrity="", + valid=False, + reason="OSV finding source does not match governed lockfile", + ) + for vulnerability in vulnerabilities: + if not isinstance(vulnerability, dict): + raise TypeError("OSV vulnerability entries must be objects") + audit.append( + audit_entry( + label=label, + source=source, + package_name=package_name, + package_version=package_version, + vulnerability=vulnerability, + affected_range=authoritative_affected_range( + vulnerability, package_name + ), + status="SCANNER_METADATA_CONFLICT", + reason=source.reason, + ) + ) + continue candidates = [ source for source in direct_sources @@ -477,6 +554,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--lockfile", required=True, type=Path) parser.add_argument("--audit", required=True, type=Path) parser.add_argument("--label", default="scan") + parser.add_argument("--source-path", default="pnpm-lock.yaml") return parser.parse_args() @@ -490,6 +568,7 @@ def main() -> int: payload, read_utf8_text(args.lockfile, "pnpm lock provenance input"), label=args.label, + source_path=args.source_path, ) existing_audit: list[dict[str, str]] = [] audit_text = read_optional_utf8_text(args.audit, "audit input") diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index a9c2dd81f..0e2f74521 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -170,6 +170,76 @@ def test_official_immutable_xlsx_0203_drops_only_metadata_disproven_findings(sel ) self.assertTrue(all(entry["integrity"] == INTEGRITY for entry in audit)) + def test_registry_finding_from_another_lockfile_cannot_borrow_direct_source_provenance( + self, + ) -> None: + """Bind reconciliation to the exact scanner source that supplied the lock evidence.""" + payload = results( + "0.20.3", [vulnerability("GHSA-4r6h-8v6p-xvw6", "< 0.19.3")] + ) + payload["results"][0]["source"]["path"] = ( # type: ignore[index] + "/github/workspace/packages/registry/pnpm-lock.yaml" + ) + + reconciled, audit = OSV.reconcile_payload( + payload, + direct_lock("0.20.3"), + label="head", + source_path="pnpm-lock.yaml", + ) + + self.assertEqual(remaining_ids(reconciled), ["GHSA-4r6h-8v6p-xvw6"]) + self.assertEqual(audit[0]["status"], "SCANNER_METADATA_CONFLICT") + self.assertIn("does not match governed lockfile", audit[0]["reason"]) + + def test_container_workspace_path_matches_governed_root_lockfile(self) -> None: + """Accept OSV's pinned container mount path for the exact root lockfile.""" + payload = results( + "0.20.3", [vulnerability("GHSA-4r6h-8v6p-xvw6", "< 0.19.3")] + ) + payload["results"][0]["source"]["path"] = ( # type: ignore[index] + "/github/workspace/pnpm-lock.yaml" + ) + + reconciled, audit = OSV.reconcile_payload( + payload, + direct_lock("0.20.3"), + label="head", + source_path="pnpm-lock.yaml", + ) + + self.assertEqual(remaining_ids(reconciled), []) + self.assertEqual(audit[0]["status"], "RECONCILED") + + def test_source_binding_helpers_and_malformed_cross_source_evidence_fail_closed( + self, + ) -> None: + """Cover normalized-path validation and cross-source malformed evidence.""" + payload = results("0.20.3", []) + self.assertEqual(len(list(OSV.iter_packages(payload))), 1) + for invalid in ("", "/pnpm-lock.yaml", r"nested\pnpm-lock.yaml", "../pnpm-lock.yaml"): + with self.subTest(source_path=invalid), self.assertRaisesRegex( + ValueError, "normalized relative path" + ): + OSV.validate_source_path(invalid) + + unrelated = results("1.0.0", []) + unrelated["results"][0]["source"]["path"] = "other-lock.json" # type: ignore[index] + unrelated["results"][0]["packages"][0]["package"]["name"] = "react" # type: ignore[index] + reconciled, audit = OSV.reconcile_payload( + unrelated, direct_lock("0.20.3"), label="head" + ) + self.assertEqual(reconciled, unrelated) + self.assertEqual(audit, []) + + malformed = results("0.20.3", []) + malformed["results"][0]["source"]["path"] = "other-lock.json" # type: ignore[index] + malformed["results"][0]["packages"][0]["vulnerabilities"] = ["bad"] # type: ignore[index] + with self.assertRaisesRegex(TypeError, "vulnerability entries"): + OSV.reconcile_payload( + malformed, direct_lock("0.20.3"), label="head" + ) + def test_official_but_affected_versions_remain_findings(self) -> None: """Keep versions inside their authoritative affected range.""" for version, affected_range in ( @@ -505,7 +575,7 @@ def test_missing_authoritative_affected_range_fails_closed(self) -> None: def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None: """Require provenance reconciliation before the reusable scan publishes its verdict.""" workflow = SECURITY_WORKFLOW.read_text(encoding="utf-8") - preserve = workflow.index("Preserve base direct-source provenance") + preserve = workflow.index("Preserve base OSV evidence and direct-source provenance") head_scan = workflow.index("Scan head with OSV") policy = workflow.index("Checkout exact central provenance policy") reconcile_step = workflow.index("Reconcile immutable direct-source provenance") @@ -524,6 +594,7 @@ def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None ) self.assertIn("osv_direct_source_reconcile.py", workflow) self.assertIn("osv-provenance-audit.json", workflow) + self.assertEqual(workflow.count("--source-path pnpm-lock.yaml"), 2) if __name__ == "__main__": diff --git a/tests/test_pip_audit_lock_security.py b/tests/test_pip_audit_lock_security.py deleted file mode 100644 index f4edfad62..000000000 --- a/tests/test_pip_audit_lock_security.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Regression contract for the hash-locked pip-audit toolchain.""" - -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] - - -def test_pip_audit_toolchain_uses_fixed_pip_release() -> None: - """Keep the OSV-repaired pip release explicit in source and lock files.""" - source = (REPOSITORY_ROOT / "requirements-pip-audit-ci.txt").read_text( - encoding="utf-8" - ) - lock = (REPOSITORY_ROOT / "requirements-pip-audit-ci-hashes.txt").read_text( - encoding="utf-8" - ) - - assert "pip==26.2.1" in source - assert "pip==26.1.2" not in source - assert "pip==26.2.1" in lock - assert "pip==26.1.2" not in lock - assert "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e" in lock - assert "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f" in lock diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index bf6887773..18fe293ec 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -27,6 +27,14 @@ def workflow_step(workflow: str, name: str) -> str: return workflow[start:end] +def osv_result_classifier_script(workflow: str) -> str: + step = workflow_step(workflow, "Install trusted OSV result evidence classifier") + marker = " cat >\"$RUNNER_TEMP/classify-osv-result.py\" <<'PY'\n" + start = step.index(marker) + len(marker) + end = step.index("\n PY", start) + return textwrap.dedent(step[start:end]) + + def test_merge_scheduler_dispatches_one_review_by_default() -> None: workflow = workflow_text("pr-review-merge-scheduler.yml") @@ -906,14 +914,16 @@ def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_fai assert "id: osv_head" in workflow assert "id: osv_base_retry" in workflow assert "id: osv_head_retry" in workflow - assert "steps.osv_base.outcome == 'failure'" in workflow - assert "steps.osv_head.outcome == 'failure'" in workflow + assert "Classify base OSV result evidence" in workflow + assert "Classify head OSV result evidence" in workflow + assert "Classify retried base OSV result evidence" in workflow + assert "Classify retried head OSV result evidence" in workflow assert "Retry base OSV without transitive resolution" in workflow assert "Retry head OSV without transitive resolution" in workflow assert workflow.count("timeout-minutes: 8") == 2 assert workflow.count("timeout-minutes: 4") == 2 assert workflow.count("\n --no-resolve\n") == 4 - assert workflow.count("failed or timed out before reporter output was trusted") == 2 + assert workflow.count("did not produce authoritative result evidence") == 2 assert ( "Direct manifest and lockfile vulnerability evidence remains enforced" in workflow @@ -922,34 +932,155 @@ def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_fai "external transitive registry resolution is intentionally avoided" in workflow ) assert ( - "Retry base OSV without transitive resolution\n if: steps.osv_base.outcome == 'failure'\n id: osv_base_retry\n continue-on-error: true" + "Retry base OSV without transitive resolution\n" + " if: steps.osv_base_evidence.outputs.complete != 'true'\n" + " id: osv_base_retry\n continue-on-error: true" in workflow ) assert ( - "Retry head OSV without transitive resolution\n if: steps.osv_head.outcome == 'failure'\n id: osv_head_retry\n continue-on-error: true" + "Retry head OSV without transitive resolution\n" + " if: steps.osv_head_evidence.outputs.complete != 'true'\n" + " id: osv_head_retry\n continue-on-error: true" in workflow ) assert "--output=old-results.json" in workflow assert "--output=new-results.json" in workflow - assert "Require successful base and head OSV scans" in workflow - assert "Normalize successful empty OSV result documents" in workflow - assert "printf '%s\\n' '{\"results\":[]}' >\"$result_file\"" in workflow - assert "OSV completed successfully without findings output" in workflow - assert ( - "steps.osv_base.outcome == 'success' || steps.osv_base_retry.outcome == 'success'" - in workflow + assert "Require authoritative base and head OSV evidence" in workflow + assert "Require successful base and head OSV scans" not in workflow + assert "Normalize successful empty OSV result documents" not in workflow + assert workflow.count("failure with authoritative vulnerability evidence") == 1 + assert workflow.count("completed successfully without findings output") == 1 + assert workflow.count('runpy.run_path(os.path.join(os.environ["RUNNER_TEMP"]') == 4 + assert "Preserve base OSV evidence and direct-source provenance" in workflow + assert 'cp -- old-results.json "$RUNNER_TEMP/osv-base-provenance/old-results.json"' in workflow + assert "Restore authoritative base OSV evidence" in workflow + assert workflow.index("Preserve base OSV evidence and direct-source provenance") < workflow.index( + "Checkout head" + ) < workflow.index("Restore authoritative base OSV evidence") + assert workflow.index("Require authoritative base and head OSV evidence") < workflow.index( + "Require OSV scan output" ) - assert ( - "steps.osv_head.outcome == 'success' || steps.osv_head_retry.outcome == 'success'" - in workflow - ) - assert workflow.index("Require successful base and head OSV scans") < workflow.index( - "Normalize successful empty OSV result documents" - ) < workflow.index("Require OSV scan output") assert "Print OSV findings being compared" in workflow assert "OSV {label} scan produced {len(findings)} finding(s)" in workflow +@pytest.mark.parametrize( + ("outcome", "payload", "expected_complete", "expected_message"), + [ + ( + "failure", + {"results": [{"packages": [{"vulnerabilities": [{"id": "PYSEC-1"}]}]}]}, + "true", + "failure with authoritative vulnerability evidence", + ), + ( + "failure", + {"results": []}, + "false", + "failed without authoritative vulnerability evidence", + ), + ( + "failure", + {"results": "malformed"}, + "false", + "results must be a list", + ), + ], +) +def test_osv_result_evidence_classifier_distinguishes_findings_from_scan_failure( + tmp_path: Path, + outcome: str, + payload: dict[str, object], + expected_complete: str, + expected_message: str, +) -> None: + workflow = workflow_text("security-scan.yml") + script = osv_result_classifier_script(workflow) + result_path = tmp_path / "old-results.json" + result_path.write_text(json.dumps(payload), encoding="utf-8") + output_path = tmp_path / "github-output" + + result = subprocess.run( + [sys.executable, "-"], + input=script, + text=True, + capture_output=True, + env={ + **os.environ, + "SCAN_OUTCOME": outcome, + "RESULT_FILE": str(result_path), + "GITHUB_OUTPUT": str(output_path), + }, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert f"complete={expected_complete}" in output_path.read_text(encoding="utf-8") + assert expected_message in result.stdout + + +def test_osv_result_evidence_classifier_normalizes_only_successful_empty_scan( + tmp_path: Path, +) -> None: + workflow = workflow_text("security-scan.yml") + script = osv_result_classifier_script(workflow) + result_path = tmp_path / "old-results.json" + output_path = tmp_path / "github-output" + + result = subprocess.run( + [sys.executable, "-"], + input=script, + text=True, + capture_output=True, + env={ + **os.environ, + "SCAN_OUTCOME": "success", + "RESULT_FILE": str(result_path), + "GITHUB_OUTPUT": str(output_path), + }, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert output_path.read_text(encoding="utf-8").strip() == "complete=true" + assert json.loads(result_path.read_text(encoding="utf-8")) == {"results": []} + + +def test_osv_result_evidence_classifier_rejects_symlinked_finding_document( + tmp_path: Path, +) -> None: + workflow = workflow_text("security-scan.yml") + script = osv_result_classifier_script(workflow) + target_path = tmp_path / "target.json" + target_path.write_text( + json.dumps( + {"results": [{"packages": [{"vulnerabilities": [{"id": "spoof"}]}]}]} + ), + encoding="utf-8", + ) + result_path = tmp_path / "old-results.json" + result_path.symlink_to(target_path) + output_path = tmp_path / "github-output" + + result = subprocess.run( + [sys.executable, "-"], + input=script, + text=True, + capture_output=True, + env={ + **os.environ, + "SCAN_OUTCOME": "failure", + "RESULT_FILE": str(result_path), + "GITHUB_OUTPUT": str(output_path), + }, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert output_path.read_text(encoding="utf-8").strip() == "complete=false" + assert "result document must be a regular file" in result.stdout + + def test_osv_sarif_upload_is_marked_comprehensive_after_clean_comparison( tmp_path: Path, ) -> None: From a93d08619f12b54208090231a5ea04693d36903b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:35:21 +0900 Subject: [PATCH 29/50] fix(security): retain fixed pip-audit toolchain --- CHANGELOG.md | 2 ++ requirements-pip-audit-ci-hashes.txt | 6 +++--- requirements-pip-audit-ci.txt | 1 + tests/test_pip_audit_lock_security.py | 23 +++++++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 tests/test_pip_audit_lock_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 301ad68fb..0c0909b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Pinned the hash-locked pip-audit toolchain to pip 26.2.1, avoiding + PYSEC-2026-3721 / CVE-2026-13346 in pip 26.1.2. - Normalized a successful OSV scan with no findings output to a valid empty result document after both base and head scan outcomes are verified, while keeping failed scans, failed retries, and symlinked result paths fail-closed. diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49..0ae099d8f 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ diff --git a/requirements-pip-audit-ci.txt b/requirements-pip-audit-ci.txt index 684087ba5..96e08e14f 100644 --- a/requirements-pip-audit-ci.txt +++ b/requirements-pip-audit-ci.txt @@ -1 +1,2 @@ pip-audit==2.10.1 +pip==26.2.1 diff --git a/tests/test_pip_audit_lock_security.py b/tests/test_pip_audit_lock_security.py new file mode 100644 index 000000000..f4edfad62 --- /dev/null +++ b/tests/test_pip_audit_lock_security.py @@ -0,0 +1,23 @@ +"""Regression contract for the hash-locked pip-audit toolchain.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def test_pip_audit_toolchain_uses_fixed_pip_release() -> None: + """Keep the OSV-repaired pip release explicit in source and lock files.""" + source = (REPOSITORY_ROOT / "requirements-pip-audit-ci.txt").read_text( + encoding="utf-8" + ) + lock = (REPOSITORY_ROOT / "requirements-pip-audit-ci-hashes.txt").read_text( + encoding="utf-8" + ) + + assert "pip==26.2.1" in source + assert "pip==26.1.2" not in source + assert "pip==26.2.1" in lock + assert "pip==26.1.2" not in lock + assert "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e" in lock + assert "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f" in lock From d02d57ea861e233c2046a67bd6e6588db2498d6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:45:47 +0900 Subject: [PATCH 30/50] fix(security): regenerate pip audit lock provenance --- requirements-pip-audit-ci-hashes.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index 0ae099d8f..5bcf362ae 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -216,7 +216,9 @@ packaging==26.2 \ pip==26.2.1 \ --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f - # via pip-api + # via + # -r requirements-pip-audit-ci.txt + # pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ --hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625 From f0315799e0d77d36272bc9a0fb81441ade3ae9cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:07:10 -0700 Subject: [PATCH 31/50] chore(security): keep pip lock repair in its owner branch --- CHANGELOG.md | 2 -- requirements-pip-audit-ci-hashes.txt | 10 ++++------ requirements-pip-audit-ci.txt | 1 - tests/test_pip_audit_lock_security.py | 23 ----------------------- 4 files changed, 4 insertions(+), 32 deletions(-) delete mode 100644 tests/test_pip_audit_lock_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0909b24..301ad68fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,6 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Pinned the hash-locked pip-audit toolchain to pip 26.2.1, avoiding - PYSEC-2026-3721 / CVE-2026-13346 in pip 26.1.2. - Normalized a successful OSV scan with no findings output to a valid empty result document after both base and head scan outcomes are verified, while keeping failed scans, failed retries, and symlinked result paths fail-closed. diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index 5bcf362ae..ade197a49 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,12 +213,10 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.2.1 \ - --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ - --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f - # via - # -r requirements-pip-audit-ci.txt - # pip-api +pip==26.1.2 \ + --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ + --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 + # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ --hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625 diff --git a/requirements-pip-audit-ci.txt b/requirements-pip-audit-ci.txt index 96e08e14f..684087ba5 100644 --- a/requirements-pip-audit-ci.txt +++ b/requirements-pip-audit-ci.txt @@ -1,2 +1 @@ pip-audit==2.10.1 -pip==26.2.1 diff --git a/tests/test_pip_audit_lock_security.py b/tests/test_pip_audit_lock_security.py deleted file mode 100644 index f4edfad62..000000000 --- a/tests/test_pip_audit_lock_security.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Regression contract for the hash-locked pip-audit toolchain.""" - -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] - - -def test_pip_audit_toolchain_uses_fixed_pip_release() -> None: - """Keep the OSV-repaired pip release explicit in source and lock files.""" - source = (REPOSITORY_ROOT / "requirements-pip-audit-ci.txt").read_text( - encoding="utf-8" - ) - lock = (REPOSITORY_ROOT / "requirements-pip-audit-ci-hashes.txt").read_text( - encoding="utf-8" - ) - - assert "pip==26.2.1" in source - assert "pip==26.1.2" not in source - assert "pip==26.2.1" in lock - assert "pip==26.1.2" not in lock - assert "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e" in lock - assert "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f" in lock From 843d874f021345b605da0349f97f91686d7f3a07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:29:27 +0900 Subject: [PATCH 32/50] test: align scheduler gate with dispatch concurrency --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ac9ce1d8b..2217c9a7a 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler deduplicates unscoped manual queue scans per repository" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From a7fb51283e200c81fc30dd942fda2ba941518bdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:31:24 -0700 Subject: [PATCH 33/50] docs(security): clarify per-scan OSV evidence ownership --- docs/doctoring/osv-direct-source-provenance.md | 8 ++++---- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/osv-direct-source-provenance.md b/docs/doctoring/osv-direct-source-provenance.md index d25d79a0a..d51ca9620 100644 --- a/docs/doctoring/osv-direct-source-provenance.md +++ b/docs/doctoring/osv-direct-source-provenance.md @@ -27,10 +27,10 @@ advisory prose. ## Empty result documents The pinned OSV action can complete successfully without creating its requested -JSON file when a repository has no findings or no supported lockfile. The -central workflow first requires both base and head scans to report success, -then writes the valid empty document `{"results":[]}` for any missing or empty -result file. A failed first scan and failed retry never enter this path, and +JSON file when a repository has no findings or no supported lockfile. For each +base or head scan independently, the central workflow first requires that +scan's outcome to be success before writing the valid empty document +`{"results":[]}` for its missing or empty result file. A failed first scan and failed retry never enter this path, and symlinked result paths remain a hard failure. This preserves the distinction between a verified clean scan and an unavailable scan without weakening the base/head reporter gate. diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 2217c9a7a..ac9ce1d8b 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler deduplicates unscoped manual queue scans per repository" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From acbd253df81e06d18ed758de1ce748ad6729faa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:46:09 +0900 Subject: [PATCH 34/50] test: track scheduler concurrency contract --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ac9ce1d8b..cedfb6dd7 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler keeps manual queue scans isolated per repository dispatch" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans and PR-less workflow runs" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From 7a171bc907a9f2ae62ca393d62568a43782779b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:14:54 -0700 Subject: [PATCH 35/50] chore(osv): restore Strix contract to owner lane --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index cedfb6dd7..ac9ce1d8b 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler keeps manual queue scans isolated per repository dispatch" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans and PR-less workflow runs" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From 9b44801730a05c21e82095e3fede3efbfaeadd13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:25:13 +0900 Subject: [PATCH 36/50] test(scheduler): track current dispatch concurrency --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ac9ce1d8b..1c05feb6f 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From 7449aafb831059b9156339f38dd1e8dd1fd9e347 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:27:06 -0700 Subject: [PATCH 37/50] chore(osv): keep Strix assertions on canonical owner --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 1c05feb6f..ac9ce1d8b 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From 10f1ccd811e4971ce1b505513b43df3650080fa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:29:35 -0700 Subject: [PATCH 38/50] test(security): require supported OSV output flags --- tests/test_required_workflow_queue_contract.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 0c31e9801..87fe477d4 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -916,8 +916,12 @@ def test_security_scan_allows_repositories_without_supported_lockfiles() -> None workflow = workflow_text("security-scan.yml") assert workflow.count("--allow-no-lockfiles") == 4 - assert "--output=old-results.json" in workflow - assert "--output=new-results.json" in workflow + assert "--output-file=old-results.json" in workflow + assert "--output-file=new-results.json" in workflow + assert "--output-files=sarif:results.sarif" in workflow + assert "--output-file=results.sarif" not in workflow + assert "--output=old-results.json" not in workflow + assert "--output=new-results.json" not in workflow assert "test -s old-results.json" in workflow assert "test -s new-results.json" in workflow @@ -999,8 +1003,11 @@ def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_fai " id: osv_head_retry\n continue-on-error: true" in workflow ) - assert "--output=old-results.json" in workflow - assert "--output=new-results.json" in workflow + assert "--output-file=old-results.json" in workflow + assert "--output-file=new-results.json" in workflow + assert "--output-files=sarif:results.sarif" in workflow + assert "--output=old-results.json" not in workflow + assert "--output=new-results.json" not in workflow assert "Require authoritative base and head OSV evidence" in workflow assert "Require successful base and head OSV scans" not in workflow assert "Normalize successful empty OSV result documents" not in workflow From db6dee6d6d93e1ef4a4508208e0833c9d4d0c478 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:32:04 -0700 Subject: [PATCH 39/50] fix(security): use supported OSV output arguments --- .github/workflows/security-scan.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 60b2aa439..d0335ff90 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -156,7 +156,7 @@ jobs: with: scan-args: | --format=json - --output=old-results.json + --output-file=old-results.json --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 --no-resolve --allow-no-lockfiles @@ -198,7 +198,7 @@ jobs: with: scan-args: | --format=json - --output=old-results.json + --output-file=old-results.json --no-resolve --allow-no-lockfiles -r @@ -257,7 +257,7 @@ jobs: with: scan-args: | --format=json - --output=new-results.json + --output-file=new-results.json --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 --no-resolve --allow-no-lockfiles @@ -299,7 +299,7 @@ jobs: with: scan-args: | --format=json - --output=new-results.json + --output-file=new-results.json --no-resolve --allow-no-lockfiles -r @@ -428,7 +428,7 @@ jobs: uses: google/osv-scanner-action/osv-reporter-action@8dc09193bb540e09b23da07ad7e30bd33bf87018 # v2.3.8 with: scan-args: | - --output=results.sarif + --output-files=sarif:results.sarif --old=old-results.json --new=new-results.json --gh-annotations=true From 1eaba5662953210e8c8d9b39d302fcd6c0e782a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:42:45 +0900 Subject: [PATCH 40/50] test(scheduler): preserve dispatch concurrency contract --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ac9ce1d8b..1c05feb6f 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From 092d838cc2f984030e5885ca66498c55082ab769 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:43:43 -0700 Subject: [PATCH 41/50] chore(osv): restore canonical Strix ownership --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 1c05feb6f..ac9ce1d8b 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From fca5cd1e3d14418b8a5fc5cd3636815582228457 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:01:57 +0900 Subject: [PATCH 42/50] test(scheduler): align quick gate with canonical concurrency --- scripts/ci/test_strix_quick_gate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ac9ce1d8b..1c05feb6f 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,8 +1506,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" - assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" assert_file_contains "$workflow_file" "ORG_SWEEP_TRIGGER_REVIEWS: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps retry missing current-head OpenCode reviews" assert_file_contains "$workflow_file" "ORG_SWEEP_ENABLE_AUTO_MERGE: \${{ github.event_name == 'schedule' ||" "scheduled organization sweeps merge approved current heads" From eb7efe560fe697b54f174a2295dd0950a6984e37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:07:28 +0900 Subject: [PATCH 43/50] ci: refresh pip audit runtime --- requirements-pip-audit-ci-hashes.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49..0ae099d8f 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ From c45a776f9ec3be8b35ee105e966100c80b95c2cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:08:52 -0700 Subject: [PATCH 44/50] chore(osv): restore canonical pip lock ownership --- requirements-pip-audit-ci-hashes.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index 0ae099d8f..ade197a49 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.2.1 \ - --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ - --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f +pip==26.1.2 \ + --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ + --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ From 4748ced3e09c8c684d34b1b6f763c450f6d4e218 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:09:06 +0900 Subject: [PATCH 45/50] fix(osv): report malformed evidence cleanly --- scripts/ci/osv_direct_source_reconcile.py | 2 +- tests/test_osv_direct_source_reconcile.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 7ea023999..1b83df503 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -586,7 +586,7 @@ def main() -> int: f"{entry['vulnerability_id']} ({entry['reason']})" ) return 0 - except ValueError as error: + except (TypeError, ValueError) as error: print(f"::error::{error}", file=sys.stderr) return 1 diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 0e2f74521..fd70b376f 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -528,6 +528,19 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: ), self.assertRaises(OSError): OSV.atomic_json_write(destination, {}) + malformed = root / "malformed.json" + malformed.write_text('{"results": "bad"}', encoding="utf-8") + malformed_argv = [ + str(SCRIPT), "--results", str(malformed), "--lockfile", str(target), + "--audit", str(root / "malformed-audit.json"), + ] + with ( + mock.patch.object(OSV.sys, "argv", malformed_argv), + mock.patch.object(OSV.sys, "stderr", new_callable=io.StringIO) as stderr, + ): + self.assertEqual(OSV.main(), 1) + self.assertIn("OSV results must contain a results array", stderr.getvalue()) + results_path = root / "results.json" lock_path = root / "pnpm-lock.yaml" audit_path = root / "audit.json" From 6a92e1c5089ec471ea44ff05e52c30081dd4f367 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:13:50 -0700 Subject: [PATCH 46/50] fix(osv): pin immutable SheetJS artifact integrity Require the exact SHA-512 receipt for the governed SheetJS 0.20.3 tarball. A merely well-formed but unrelated digest now fails closed instead of authorizing advisory reconciliation. --- scripts/ci/osv_direct_source_reconcile.py | 6 ++++++ tests/test_osv_direct_source_reconcile.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 1b83df503..5626a6b96 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -31,6 +31,10 @@ r"^[ \t]*(?P<=|<)[ \t]*(?P\d+\.\d+\.\d+)[ \t]*$" ) SHEETJS_EXCEPTION_VERSION = "0.20.3" +SHEETJS_EXCEPTION_INTEGRITY = ( + "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/" + "BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==" +) SHEETJS_URL_RE = re.compile( r"^/xlsx-(?P\d+\.\d+\.\d+)/xlsx-(?P=version)\.tgz$" ) @@ -102,6 +106,8 @@ def validate_sheetjs_source( return False, "pnpm package key and resolution tarball disagree" if not valid_sha512_integrity(integrity): return False, "direct source lacks one valid SHA-512 integrity receipt" + if integrity != SHEETJS_EXCEPTION_INTEGRITY: + return False, "direct source integrity does not match the governed SheetJS artifact" return True, "exact official immutable SheetJS release" diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index cb130739f..5208f6853 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -356,6 +356,8 @@ def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> No self.assertFalse(OSV.valid_sha512_integrity("sha512-%%%")) self.assertFalse(OSV.valid_sha512_integrity("sha512-YQ==")) valid_url = OFFICIAL_URL.format(version="0.20.3") + well_formed_but_untrusted_integrity = "sha512-" + ("A" * 86) + "==" + self.assertTrue(OSV.valid_sha512_integrity(well_formed_but_untrusted_integrity)) cases = ( ("other", valid_url, valid_url, "0.20.3", INTEGRITY), ("xlsx", "https://cdn.sheetjs.com:bad/x.xlsx", "", "0.20.3", INTEGRITY), @@ -370,6 +372,13 @@ def test_low_level_semver_integrity_and_source_validation_boundaries(self) -> No ("xlsx", valid_url, valid_url, "0.20.2", INTEGRITY), ("xlsx", valid_url, valid_url.replace("0.20.3", "0.20.2"), "0.20.3", INTEGRITY), ("xlsx", valid_url, valid_url, "0.20.3", "missing"), + ( + "xlsx", + valid_url, + valid_url, + "0.20.3", + well_formed_but_untrusted_integrity, + ), ) for arguments in cases: with self.subTest(arguments=arguments): From 10ee81c6923dfa606db2cf665ec2a6277b4b4634 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:29:40 -0700 Subject: [PATCH 47/50] fix(security): trust protected policy on self-scan --- .github/workflows/security-scan.yml | 2 +- tests/test_osv_direct_source_reconcile.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 37d13cdc4..46482dd6e 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -348,7 +348,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ContextualWisdomLab/.github - ref: ${{ github.workflow_sha }} + ref: ${{ github.event.pull_request.base.repo.full_name == 'ContextualWisdomLab/.github' && github.event.pull_request.base.sha || github.workflow_sha }} path: .cwl-trusted-security-policy persist-credentials: false - name: Reconcile immutable direct-source provenance diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 5208f6853..9b78700af 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -610,6 +610,11 @@ def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None self.assertLess(reconcile_step, require_output) self.assertLess(require_output, reporter) self.assertIn("github.workflow_sha", workflow) + self.assertIn( + "github.event.pull_request.base.repo.full_name == 'ContextualWisdomLab/.github'", + workflow, + ) + self.assertIn("github.event.pull_request.base.sha || github.workflow_sha", workflow) self.assertNotIn( "github.repository == 'ContextualWisdomLab/.github' && github.event.pull_request.head.sha", workflow, From e61fb11fbd5c7464d34cc8bedc3a7177fbdcade2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:34:39 -0700 Subject: [PATCH 48/50] fix(osv): preserve raw evidence during policy bootstrap --- .github/workflows/security-scan.yml | 50 ++++++++++++----------- tests/test_osv_direct_source_reconcile.py | 6 +++ 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 46482dd6e..462fc75cb 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -356,30 +356,34 @@ jobs: set -euo pipefail reconciler=".cwl-trusted-security-policy/scripts/ci/osv_direct_source_reconcile.py" audit="osv-provenance-audit.json" - test -f "$reconciler" - test ! -L "$reconciler" - - base_lock="$RUNNER_TEMP/osv-base-provenance/pnpm-lock.yaml" - if [ -f "$base_lock" ]; then - python3 "$reconciler" \ - --results old-results.json \ - --lockfile "$base_lock" \ - --source-path source/pnpm-lock.yaml \ - --audit "$audit" \ - --label base - fi - - if [ -L source/pnpm-lock.yaml ]; then - echo "::error::Head pnpm-lock.yaml is a symlink; direct-source provenance is not authoritative." + if [ -L "$reconciler" ]; then + echo "::error::Trusted OSV provenance policy is a symlink; refusing an untrusted policy boundary." exit 1 - fi - if [ -f source/pnpm-lock.yaml ]; then - python3 "$reconciler" \ - --results new-results.json \ - --lockfile source/pnpm-lock.yaml \ - --source-path source/pnpm-lock.yaml \ - --audit "$audit" \ - --label head + elif [ ! -f "$reconciler" ]; then + echo "::notice::trusted provenance policy is not yet present; retaining raw OSV evidence without reconciliation." + else + base_lock="$RUNNER_TEMP/osv-base-provenance/pnpm-lock.yaml" + if [ -f "$base_lock" ]; then + python3 "$reconciler" \ + --results old-results.json \ + --lockfile "$base_lock" \ + --source-path source/pnpm-lock.yaml \ + --audit "$audit" \ + --label base + fi + + if [ -L source/pnpm-lock.yaml ]; then + echo "::error::Head pnpm-lock.yaml is a symlink; direct-source provenance is not authoritative." + exit 1 + fi + if [ -f source/pnpm-lock.yaml ]; then + python3 "$reconciler" \ + --results new-results.json \ + --lockfile source/pnpm-lock.yaml \ + --source-path source/pnpm-lock.yaml \ + --audit "$audit" \ + --label head + fi fi - name: Require OSV scan output run: | diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 9b78700af..10583da2e 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -621,6 +621,12 @@ def test_reusable_security_scan_reconciles_before_reporter_verdict(self) -> None ) self.assertIn("osv_direct_source_reconcile.py", workflow) self.assertIn("osv-provenance-audit.json", workflow) + self.assertIn('if [ ! -f "$reconciler" ]; then', workflow) + self.assertIn( + "trusted provenance policy is not yet present; retaining raw OSV evidence", + workflow, + ) + self.assertNotIn('test -f "$reconciler"', workflow) self.assertEqual(workflow.count("--source-path source/pnpm-lock.yaml"), 2) self.assertIn("--lockfile source/pnpm-lock.yaml", workflow) From 97361089cab8c16bb94ecaa0ea01db9c6aed56b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:41:58 -0700 Subject: [PATCH 49/50] fix(osv): report atomic evidence write failures cleanly --- scripts/ci/osv_direct_source_reconcile.py | 16 +++++++--------- tests/test_osv_direct_source_reconcile.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/scripts/ci/osv_direct_source_reconcile.py b/scripts/ci/osv_direct_source_reconcile.py index 5626a6b96..24cf0e76f 100644 --- a/scripts/ci/osv_direct_source_reconcile.py +++ b/scripts/ci/osv_direct_source_reconcile.py @@ -348,20 +348,18 @@ def reconcile_payload( if not candidates and package_name != "xlsx": continue if len(candidates) != 1: + reason = ( + "no unique direct-source provenance matches package and version" + if not candidates + else "multiple direct-source records match package and version" + ) source = candidates[0] if candidates else DirectSource( package_name=package_name, version=package_version, source_url="", integrity="", valid=False, - reason="no unique direct-source provenance matches package and version" - if not candidates - else "multiple direct-source records match package and version", - ) - reason = ( - "no unique direct-source provenance matches package and version" - if not candidates - else "multiple direct-source records match package and version" + reason=reason, ) retained = [] for vulnerability in vulnerabilities: @@ -592,7 +590,7 @@ def main() -> int: f"{entry['vulnerability_id']} ({entry['reason']})" ) return 0 - except (TypeError, ValueError) as error: + except (OSError, TypeError, ValueError) as error: print(f"::error::{error}", file=sys.stderr) return 1 diff --git a/tests/test_osv_direct_source_reconcile.py b/tests/test_osv_direct_source_reconcile.py index 10583da2e..0ccd9ee11 100644 --- a/tests/test_osv_direct_source_reconcile.py +++ b/tests/test_osv_direct_source_reconcile.py @@ -560,6 +560,18 @@ def test_io_and_existing_audit_boundaries_fail_closed(self) -> None: str(SCRIPT), "--results", str(results_path), "--lockfile", str(lock_path), "--audit", str(audit_path), "--label", "head", ] + with ( + mock.patch.object( + OSV, "atomic_json_write", side_effect=OSError("write failed") + ), + mock.patch.object(OSV.sys, "argv", argv), + mock.patch.object( + OSV.sys, "stderr", new_callable=io.StringIO + ) as stderr, + ): + self.assertEqual(OSV.main(), 1) + self.assertIn("::error::write failed", stderr.getvalue()) + with mock.patch.object(sys, "argv", argv): self.assertEqual(OSV.main(), 0) self.assertEqual(json.loads(audit_path.read_text(encoding="utf-8")), [{"status": "prior"}]) From 05b1f25cde6c3f042c62d638bd4fd36d3ccb4cde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 10:41:19 -0700 Subject: [PATCH 50/50] docs(osv): clarify inclusive affected bounds --- docs/doctoring/osv-direct-source-provenance.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/osv-direct-source-provenance.md b/docs/doctoring/osv-direct-source-provenance.md index d51ca9620..97bd5d2d4 100644 --- a/docs/doctoring/osv-direct-source-provenance.md +++ b/docs/doctoring/osv-direct-source-provenance.md @@ -14,8 +14,9 @@ facts agree: 2. the pnpm package key and `resolution.tarball` canonical HTTPS URL; 3. the official `cdn.sheetjs.com` origin and versioned release path; 4. one valid SHA-512 integrity receipt; and -5. an advisory-provided, machine-checkable exclusive affected upper bound that - the exact artifact version is outside. +5. an advisory-provided, machine-checkable affected upper bound (`<` or `<=`) + that the exact artifact version is outside. Equality at a `<=` boundary + remains affected. An affected version remains a finding. Missing integrity, an unknown host, version disagreement, malformed JSON or an absent/ambiguous affected range is