From bb732423b4e4c0a63d225fb81ae509f6e63094e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:57:17 +0900 Subject: [PATCH 1/3] fix(pip-audit): keep hashed locks off the pip resolver Audit complete SHA-256 locks with --disable-pip so --index-url stays configuration, -r includes do not earn resolver bypass, and directory-symlink parents fail closed. --- .github/workflows/python-security.yml | 54 +-- AGENTS.md | 1 + ARCHITECTURE.md | 9 +- CHANGELOG.md | 2 + CLAUDE.md | 2 + docs/doctoring/strix-hashed-lock-no-deps.md | 95 ++++ scripts/ci/pip_audit_requirements.py | 349 +++++++++++++++ tests/test_pip_audit_requirements.py | 418 ++++++++++++++++++ ...test_pip_audit_requirements_fail_closed.py | 328 ++++++++++++++ 9 files changed, 1207 insertions(+), 51 deletions(-) create mode 100644 docs/doctoring/strix-hashed-lock-no-deps.md create mode 100644 scripts/ci/pip_audit_requirements.py create mode 100644 tests/test_pip_audit_requirements.py create mode 100644 tests/test_pip_audit_requirements_fail_closed.py diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index ca57f9db5..af0b2d1a8 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -232,53 +232,7 @@ jobs: - name: Run pip-audit (hard gate on any known vulnerability) run: | set -euo pipefail - status=0 - - # Audit every discovered requirements file. - while IFS= read -r req; do - # A matching requirements--ci-overrides.txt (a `uv pip compile --override` - # input, e.g. requirements-strix-ci-overrides.txt) means the *-hashes.txt this - # override applies to pins a version whose declared metadata range intentionally - # conflicts with another pin in the same file (verified safe at override time, not a - # resolution mistake). pip's own dependency resolver -- which pip-audit's default - # `-r` mode still calls even for fully hash-pinned files -- fails on that same - # declared-range conflict regardless of --require-hashes, and plain --no-deps does - # not suppress it (confirmed: --no-deps only skips fetching undeclared transitive - # packages, pip's resolver still cross-checks the packages that *are* listed - # together). --disable-pip bypasses pip's resolver entirely and audits the exact - # pins directly, but it requires every requirement to be an exact version (raises on - # any bare range) -- true for the compiled *-hashes.txt, not necessarily true for the - # hand-maintained raw input (e.g. requirements-strix-ci.txt intentionally leaves - # protobuf as a range). So: hashed output files with an override get - # --disable-pip --no-deps; their raw, non-hash input counterpart is skipped here - # (it is never itself a `pip install --require-hashes` target -- only its compiled - # *-hashes.txt is installed -- and that compiled file is the one audited with full - # transitive coverage). - base="${req%.txt}" - unhashed_base="${base%-hashes}" - if [ "$base" != "$unhashed_base" ] && [ -f "${unhashed_base}-overrides.txt" ]; then - echo "::group::pip-audit -r ${req} (--disable-pip --no-deps: overridden lock)" - pip-audit --strict --desc=on --no-deps --disable-pip -r "${req}" || status=1 - echo "::endgroup::" - elif [ "$base" = "$unhashed_base" ] && [ -f "${unhashed_base}-overrides.txt" ]; then - echo "::notice::Skipping pip-audit for ${req}: it is the raw input to an overridden lock (${unhashed_base}-hashes.txt), never itself a pip install --require-hashes target, and its compiled hashes file is audited separately with full resolution." - else - echo "::group::pip-audit -r ${req}" - pip-audit --strict --desc=on -r "${req}" || status=1 - echo "::endgroup::" - fi - done < <(find . -type f -name 'requirements*.txt' -not -path './.git/*') - - # Audit the project itself when a PEP 621 / lock manifest exists. - if find . -maxdepth 2 -type f \ - \( -name 'pyproject.toml' -o -name 'pylock.*.toml' \) \ - -not -path './.git/*' | head -1 | grep -q .; then - echo "::group::pip-audit . (project manifest)" - pip-audit --strict --desc=on . || status=1 - echo "::endgroup::" - fi - - if [ "${status}" != "0" ]; then - echo "::error::pip-audit reported known-vulnerable Python dependencies." - exit 1 - fi + # Hashed complete locks are audited with --disable-pip so pip cannot + # re-apply a stale Requires-Dist bound and label ResolutionImpossible + # as a known vulnerability (ContextualWisdomLab/.github#961). + python3 scripts/ci/pip_audit_requirements.py diff --git a/AGENTS.md b/AGENTS.md index bd6a96a11..e5c93bab6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +Hashed-lock pip-audit uses `--disable-pip` only when every package line is an exact SHA-256 pin; `--index-url` is configuration; directory-symlink hops fail closed. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d2bfb4a4..8a472d98b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,6 +39,13 @@ only established scheduler credentials, and grants job-scoped only established scheduler credentials, and grants job-scoped `id-token: write`. The reusable engine stays product-neutral. +## Hashed-lock pip-audit + +`scripts/ci/pip_audit_requirements.py` audits complete hashed locks with +`--disable-pip`. Resolver flags such as `--index-url` are not package +lines. `-r` includes and directory-symlink parents fail closed so pip +cannot relabel `ResolutionImpossible` as a known vulnerability. + ## Hourly NVIDIA NIM repair gate ```mermaid @@ -125,4 +132,4 @@ trusted `uv` exporter is downloaded from the literal GitHub Releases URL for - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) - — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file + — product-specific psychometric repair heartbeat and scientific gates. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2f9f24d..d125d66be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ Semantic Versioning where the repository publishes a release. - 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). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. +- Audit complete hashed Python locks with `--disable-pip` so `--index-url` stays configuration, `-r` includes do not earn resolver bypass, and directory-symlink parents fail closed before pip-audit can label `ResolutionImpossible` as a known vulnerability. +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/CLAUDE.md b/CLAUDE.md index 6ec3d494c..fca746b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,8 @@ repeatable compile command. - **Product hourly callers** stay thin. Do not hard-code OriginWeave, naruon, or Keyverse into `pr-review-fix-scheduler.yml`. The model credential remains `NVIDIA_NIM_API_KEY` on the worker, never `COPILOT_GITHUB_TOKEN`. +- **pip-audit** of hashed locks goes through `scripts/ci/pip_audit_requirements.py`. + Do not call `pip-audit -r` on a complete lock (that re-resolves and can false-fail). - **`pull_request_target` trust boundary.** The required review workflows run the *base branch's* trusted scripts. A PR that edits the trusted review workflows can fail its own checks until the base branch catches up; a same-head manual `workflow_dispatch` Strix run may supply review evidence diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md new file mode 100644 index 000000000..b8b2a2511 --- /dev/null +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -0,0 +1,95 @@ +# Strix hashed-lock install and pip-audit without pip re-resolution + +검토 기준일: **2026-08-16** + +## Incident + +The required Strix workflow is `pull_request_target`: GitHub runs the **base +branch** copy of `.github/workflows/strix.yml` against the pull-request head +tree. ContextualWisdomLab/.github#961 therefore compiled a complete lock of +`strix-agent==1.5.3` plus `cryptography==50.0.0` (override for CVE-2026-39892 +and CVE-2026-69247) and added `--no-deps` to the PR copy of the installer, but +the live required job still executed main's installer: + +```text +pip install --require-hashes -r requirements-strix-ci-hashes.txt +``` + +pip re-applied `strix-agent 1.5.3 depends on cryptography<49 and >=48.0.1` +and exited `ResolutionImpossible`. The same resolver path is what +`python-security.yml` used for every `requirements*.txt` file. pip-audit then +printed `::error::pip-audit reported known-vulnerable Python dependencies` +even though no advisory was returned. A buyer watching the required security +dashboard saw two red X marks on an honest lock. + +## Decision + +1. Land `--require-hashes --no-deps` on **main's** Strix installer first, with + no lock change. The current main lock (`strix-agent==1.0.4` + + `cryptography==50.0.0`) is already a complete hashed set, so `--no-deps` + does not widen the install. After this lands, a later 1.5.3 lock can + install under the required base-branch workflow. +2. Audit hashed locks with `pip-audit --disable-pip` via + `scripts/ci/pip_audit_requirements.py`. Compile-time `*-overrides.txt` + files and unhashed inputs that already have a `*-hashes.txt` sibling are + not separate install sets. +3. Do not drop `cryptography==50.0.0` to satisfy the stale `<49` metadata + bound. Do not treat `ResolutionImpossible` as a vulnerability. + +`python-security.yml` is `pull_request` (not `_target`), so the helper takes +effect on the same head that introduces it. Strix still needs this installer +line on the protected base before #961 can go green. + +## Trust boundary + +- `--no-deps` is not an unhashed install: every wheel remains hash-pinned. +- `--disable-pip` still queries the advisory database for every pinned name + and version; it only skips pip's metadata resolver. +- NVIDIA NIM / OpenCode review-agent credentials are untouched. +- No operational PII is masked. + +## Verification contract + +`tests/test_pip_audit_requirements.py` reconstructs the #961 lock shape and +requires `--disable-pip` for that file, a skip for the override/input pair, +and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name +or a lone `--require-hashes` directive without `--hash=` is not treated +as a complete lock. A mixed file with one hashed line beside unhashed +packages, a filename-only wheel path, a `-r`/`-c`/`-e` include, or a pip +option carrying hash-shaped text also stays on the resolver path. A +complete lock that only adds resolver configuration such as `--index-url` +still receives `--disable-pip`. An unhashed compile input is skipped +only when its regular, non-symlink sibling is itself a valid complete lock. +Invalid UTF-8 and symlink/special-file requirement inputs fail before any +audit command. Discovery does not descend directory symbolic links, and a +presented path whose intermediate parent is a symlink or whose resolved +target leaves the audit root fails closed (CWE-22 / CWE-59). +Repository-controlled filenames are JSON-escaped before GitHub Actions +group titles. Discovery skips `.venv` trees. + +## References (APA 7th) + +GitHub. (2026). *Cryptography vulnerable to buffer overflow if +non-contiguous buffers were passed to APIs (CVE-2026-39892, +GHSA-p423-j2cm-9vmq)*. GitHub Advisory Database. +https://github.com/advisories/GHSA-p423-j2cm-9vmq + +GitHub. (2026). *PKCS#7 decryption timing oracle in pyca/cryptography +(CVE-2026-69247, GHSA-g6cj-pr64-35w5)*. GitHub Advisory Database. +https://github.com/advisories/GHSA-g6cj-pr64-35w5 + +National Institute of Standards and Technology. (2026). +*CVE-2026-69247*. National Vulnerability Database. +https://nvd.nist.gov/vuln/detail/CVE-2026-69247 + +pypa. (2025). *pip-audit: ``--disable-pip`` (hashed requirements / ``--no-deps`` +only)*. https://github.com/pypa/pip-audit + +MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted +directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html + +MITRE. (2026). *CWE-59: Improper link resolution before file access +('Link Following')*. https://cwe.mitre.org/data/definitions/59.html + +Python Packaging Authority. (n.d.). *Hash-checking mode*. pip documentation. +https://pip.pypa.io/en/stable/topics/secure-installs/#hash-checking-mode diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py new file mode 100644 index 000000000..2e75eb017 --- /dev/null +++ b/scripts/ci/pip_audit_requirements.py @@ -0,0 +1,349 @@ +"""Audit Python requirement files without re-resolving hashed override locks. + +pip-audit 2.10's default path invokes pip to resolve each ``requirements*.txt`` +file. That lies about a complete hashed lock compiled with a documented +override: pip reports ``ResolutionImpossible`` (strix-agent ``cryptography<49`` +versus the CVE-fixed ``cryptography==50.0.0`` pin) and the workflow labels the +failure ``known-vulnerable``. This helper audits validated hashed locks with +``--disable-pip`` so the pinned set is checked against the advisory database +without re-applying stale metadata bounds. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import stat +import subprocess +import sys +from collections.abc import Callable, Sequence + + +Runner = Callable[[Sequence[str]], int] +SKIP_DISCOVERY_PARTS = frozenset( + {".git", ".venv", "venv", "node_modules", "__pycache__"} +) +_HASH_FIELD = re.compile(r"--hash=sha256:[0-9a-f]{64}") +_PIP_CONFIG_OPTION_HEADS = frozenset( + { + "--require-hashes", + "--index-url", + "--extra-index-url", + "--trusted-host", + "--find-links", + "--no-index", + "--no-binary", + "--only-binary", + "--prefer-binary", + "--pre", + "--use-feature", + "--no-deps", + "-i", + "-f", + } +) + + +class AuditConfigurationError(RuntimeError): + """Signal that repository-controlled audit input is unsafe or ambiguous.""" + + +def _require_regular_file(path: pathlib.Path) -> None: + """Require one existing regular file without following a symbolic link.""" + + try: + metadata = path.lstat() + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + if not stat.S_ISREG(metadata.st_mode): + raise AuditConfigurationError( + "requirements input must be a regular non-symlink file" + ) + + +def _requirement_lines(path: pathlib.Path) -> list[str]: + """Return strict UTF-8 logical lines, joining backslash continuations.""" + + _require_regular_file(path) + try: + text = path.read_text(encoding="utf-8", errors="strict") + except UnicodeError: + raise AuditConfigurationError("invalid UTF-8 requirements input") from None + joined = text.replace("\r\n", "\n").replace("\\\n", " ") + lines: list[str] = [] + for raw_line in joined.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + lines.append(line) + return lines + + +def _is_exact_hashed_requirement(line: str) -> bool: + """Return whether one line is an exact ``==`` pin plus SHA-256 hashes.""" + + fields = re.split(r"\s+(?=--hash=)", line) + requirement = fields[0].strip() + hash_fields = fields[1:] + if not requirement or requirement.startswith("-") or "==" not in requirement: + return False + return bool(hash_fields) and all( + _HASH_FIELD.fullmatch(field) is not None for field in hash_fields + ) + + +def _is_pip_config_option(line: str) -> bool: + """Return whether a line only configures pip and does not add a package.""" + + head = line.split(None, 1)[0] + if "=" in head: + head = head.split("=", 1)[0] + return head in _PIP_CONFIG_OPTION_HEADS + + +def _reject_escaped_requirement_path(root: pathlib.Path, path: pathlib.Path) -> None: + """Fail closed when discovery leaves the audit root via a directory symlink. + + ``Path.rglob`` follows directory symbolic links, so a repository-controlled + ``vendor -> /outside`` hop can make an external ``requirements*.txt`` look + like a descendant of the audit root. Intermediate parents are inspected + with ``is_symlink`` before ``resolve``, and the canonical target must stay + inside the resolved root (CWE-22, CWE-59). + """ + + current = path.parent + while current != root: + try: + current.relative_to(root) + except ValueError as exc: + raise AuditConfigurationError( + "requirements input escaped the audit root" + ) from exc + try: + if current.is_symlink(): + raise AuditConfigurationError( + "requirements input escaped the audit root" + ) + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + current = current.parent + try: + resolved_root = root.resolve(strict=True) + resolved_path = path.resolve(strict=True) + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + try: + resolved_path.relative_to(resolved_root) + except ValueError as exc: + raise AuditConfigurationError( + "requirements input escaped the audit root" + ) from exc + + +def is_override_file(path: pathlib.Path) -> bool: + """Return whether *path* is a compile-time override input, not an install set.""" + + return path.name.endswith("-overrides.txt") + + +def is_hashed_lock(path: pathlib.Path) -> bool: + """Return whether *path* is a complete, syntactically bounded hashed lock. + + A ``*-hashes.txt`` name, a lone ``--require-hashes`` directive, a pip + option carrying hash-shaped text, or a mixed hashed-plus-unhashed file is + not sufficient. Resolver configuration such as ``--index-url`` is not a + package line. Include, constraint, and editable lines remain package + input and cannot earn resolver bypass. Every remaining package line must + be an exact ``==`` pin carrying one or more complete SHA-256 hashes + before ``--disable-pip`` is allowed to bypass pip's resolver. + """ + + lines = _requirement_lines(path) + package_lines = [line for line in lines if not _is_pip_config_option(line)] + return bool(package_lines) and all( + _is_exact_hashed_requirement(line) for line in package_lines + ) + + +def _is_regular_non_symlink(path: pathlib.Path) -> bool: + """Return whether an existing path is a regular file without following it.""" + + try: + return stat.S_ISREG(path.lstat().st_mode) + except OSError: + return False + + +def hashed_sibling(path: pathlib.Path) -> pathlib.Path | None: + """Return a valid compiled ``*-hashes.txt`` sibling for one source input.""" + + if is_hashed_lock(path) or is_override_file(path): + return None + if not path.name.endswith(".txt"): + return None + sibling = path.with_name(f"{path.name[:-4]}-hashes.txt") + if not _is_regular_non_symlink(sibling): + return None + if not is_hashed_lock(sibling): + return None + return sibling + + +def audit_command(path: pathlib.Path) -> list[str] | None: + """Return the pip-audit argv for *path*, or ``None`` to skip the file. + + Validated hashed locks are audited with ``--disable-pip`` so pip cannot + re-apply a stale ``Requires-Dist`` upper bound. Compile-time override files + and unhashed inputs with a validated hashed sibling are skipped because the + lock is the install set a buyer actually receives. + """ + + if is_override_file(path): + return None + if is_hashed_lock(path): + return [ + "pip-audit", + "--strict", + "--desc=on", + "--disable-pip", + "-r", + str(path), + ] + if hashed_sibling(path) is not None: + return None + return ["pip-audit", "--strict", "--desc=on", "-r", str(path)] + + +def discover_requirement_files(root: pathlib.Path) -> list[pathlib.Path]: + """Return regular requirement files while rejecting matching symlinks.""" + + found: list[pathlib.Path] = [] + for path in sorted(root.rglob("requirements*.txt")): + try: + relative_parts = path.relative_to(root).parts + except ValueError: + raise AuditConfigurationError( + "requirements input escaped the audit root" + ) from None + if SKIP_DISCOVERY_PARTS.intersection(relative_parts): + continue + try: + metadata = path.lstat() + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + if stat.S_ISDIR(metadata.st_mode): + continue + if not stat.S_ISREG(metadata.st_mode): + raise AuditConfigurationError( + "requirements input must be a regular non-symlink file" + ) + _reject_escaped_requirement_path(root, path) + found.append(path) + return found + + +def should_audit_project_manifest(root: pathlib.Path) -> bool: + """Return whether a PEP 621 or pylock manifest exists within two levels.""" + + for current in (root, *list(root.glob("*"))): + try: + current_metadata = current.lstat() + except OSError: + continue + if not stat.S_ISDIR(current_metadata.st_mode): + continue + pyproject = current / "pyproject.toml" + if _is_regular_non_symlink(pyproject): + return True + for candidate in current.glob("pylock.*.toml"): + if _is_regular_non_symlink(candidate): + return True + return False + + +def _display_path(root: pathlib.Path, path: pathlib.Path) -> str: + """Return an ASCII JSON string safe for GitHub Actions log output.""" + + try: + relative = path.relative_to(root).as_posix() + except ValueError: + relative = path.name + return json.dumps(relative, ensure_ascii=True) + + +def run_audits( + root: pathlib.Path, + *, + runner: Runner | None = None, +) -> int: + """Audit discovered inputs, returning 2 for unsafe repository metadata.""" + + execute: Runner = runner or ( + lambda command: subprocess.run(command, check=False).returncode + ) + status = 0 + try: + requirement_files = discover_requirement_files(root) + for path in requirement_files: + command = audit_command(path) + display_path = _display_path(root, path) + if command is None: + print( + f"skip {display_path} (validated hashed lock is audited)", + flush=True, + ) + continue + print(f"::group::pip-audit {display_path}", flush=True) + if execute(command) != 0: + status = 1 + print("::endgroup::", flush=True) + if should_audit_project_manifest(root): + command = ["pip-audit", "--strict", "--desc=on", "."] + print("::group::pip-audit . (project manifest)", flush=True) + if execute(command) != 0: + status = 1 + print("::endgroup::", flush=True) + except AuditConfigurationError as error: + print(f"::error::{error}", file=sys.stderr) + return 2 + if status != 0: + print( + "::error::pip-audit reported known-vulnerable Python dependencies.", + file=sys.stderr, + ) + return status + + +def main(argv: list[str] | None = None) -> int: + """CLI entry: audit requirement files under an optional root directory.""" + + parser = argparse.ArgumentParser( + description="Audit requirements without re-resolving hashed override locks." + ) + parser.add_argument( + "root", + nargs="?", + type=pathlib.Path, + default=pathlib.Path("."), + help="Repository root to search (default: current directory)", + ) + args = parser.parse_args(argv) + root = args.root.resolve() + if not root.is_dir(): + print("::error::audit root is not a directory", file=sys.stderr) + return 2 + return run_audits(root) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pip_audit_requirements.py b/tests/test_pip_audit_requirements.py new file mode 100644 index 000000000..2f354f349 --- /dev/null +++ b/tests/test_pip_audit_requirements.py @@ -0,0 +1,418 @@ +"""Tests for hashed-lock pip-audit selection. + +The reality-shaped case is the live #961 failure: pip-audit invoked pip on +``requirements-strix-ci-hashes.txt`` containing both ``strix-agent==1.5.3`` +and ``cryptography==50.0.0``, pip raised ``ResolutionImpossible``, and the +workflow reported that as a known vulnerability. The helper must audit that +lock with ``--disable-pip`` instead of asking pip to re-resolve metadata. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import runpy +import sys +from typing import Any + +import pytest + + +MODULE_PATH = ( + pathlib.Path(__file__).resolve().parents[1] + / "scripts" + / "ci" + / "pip_audit_requirements.py" +) +WORKFLOW = ( + pathlib.Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "python-security.yml" +) + + +def load_module() -> Any: + """Load the helper from its script path.""" + + spec = importlib.util.spec_from_file_location("pip_audit_requirements", MODULE_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _conflicting_strix_lock(path: pathlib.Path) -> None: + """Write the buyer-shaped hashed lock that pip cannot re-resolve.""" + + digest = "a" * 64 + path.write_text( + "strix-agent==1.5.3 \\\n" + f" --hash=sha256:{digest}\n" + "cryptography==50.0.0 \\\n" + f" --hash=sha256:{digest}\n", + encoding="utf-8", + ) + + +def test_conflicting_hashed_strix_lock_uses_disable_pip(tmp_path: pathlib.Path) -> None: + """A 1.5.3 + cryptography 50 lock must not be handed to pip's resolver.""" + + module = load_module() + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + + command = module.audit_command(lock) + + assert command is not None + assert command[:5] == [ + "pip-audit", + "--strict", + "--desc=on", + "--disable-pip", + "-r", + ] + assert command[-1] == str(lock) + assert "--disable-pip" in command + + +def test_override_file_and_unhashed_input_with_lock_are_skipped( + tmp_path: pathlib.Path, +) -> None: + """Compile inputs are not install sets once a hashed sibling exists.""" + + module = load_module() + source = tmp_path / "requirements-strix-ci.txt" + source.write_text("strix-agent==1.5.3\ncryptography==50.0.0\n", encoding="utf-8") + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + override = tmp_path / "requirements-strix-ci-overrides.txt" + override.write_text("cryptography==50.0.0\n", encoding="utf-8") + + assert module.audit_command(source) is None + assert module.audit_command(override) is None + assert module.hashed_sibling(source) == lock + assert module.hashed_sibling(lock) is None + assert module.hashed_sibling(override) is None + assert module.is_override_file(override) is True + + +def test_unhashed_requirements_without_lock_keep_resolver_audit( + tmp_path: pathlib.Path, +) -> None: + """A standalone unpinned file still uses pip-audit's default resolution.""" + + module = load_module() + requirements = tmp_path / "requirements-demo.txt" + requirements.write_text("demo==1.0.0\n", encoding="utf-8") + named_lock = tmp_path / "requirements-strix-ci-hashes.txt" + named_lock.write_text("strix-agent==1.5.3\n", encoding="utf-8") + + command = module.audit_command(requirements) + + assert command == [ + "pip-audit", + "--strict", + "--desc=on", + "-r", + str(requirements), + ] + assert module.is_hashed_lock(named_lock) is False + assert "--disable-pip" not in (module.audit_command(named_lock) or []) + + +def test_discover_skips_git_and_audits_manifest( + tmp_path: pathlib.Path, capsys: Any +) -> None: + """``.git`` copies are ignored; a nearby pyproject.toml is audited.""" + + module = load_module() + (tmp_path / ".git").mkdir() + git_copy = tmp_path / ".git" / "requirements-hidden.txt" + git_copy.write_text("hidden==1.0.0\n", encoding="utf-8") + venv = tmp_path / ".venv" / "lib" + venv.mkdir(parents=True) + (venv / "requirements-venv.txt").write_text("venv==1.0.0\n", encoding="utf-8") + visible = tmp_path / "requirements-visible-hashes.txt" + _conflicting_strix_lock(visible) + (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") + calls: list[list[str]] = [] + + def fake_runner(command: list[str]) -> int: + calls.append(list(command)) + return 0 + + rc = module.run_audits(tmp_path, runner=fake_runner) + + assert rc == 0 + assert [path.name for path in module.discover_requirement_files(tmp_path)] == [ + "requirements-visible-hashes.txt" + ] + assert calls[0][3] == "--disable-pip" + assert calls[1] == ["pip-audit", "--strict", "--desc=on", "."] + logged = capsys.readouterr().out + assert "::group::pip-audit" in logged + assert module.should_audit_project_manifest(tmp_path) is True + + +def test_failed_hashed_audit_is_not_relabeled_as_missing_input( + tmp_path: pathlib.Path, capsys: Any +) -> None: + """A real advisory hit still fails closed with the existing error phrase.""" + + module = load_module() + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + + rc = module.run_audits(tmp_path, runner=lambda _command: 1) + + assert rc == 1 + err = capsys.readouterr().err + assert "known-vulnerable Python dependencies" in err + + +def test_missing_root_fails_closed(tmp_path: pathlib.Path, capsys: Any) -> None: + """A non-directory root is rejected before any audit command runs.""" + + module = load_module() + rc = module.main([str(tmp_path / "missing-root")]) + + assert rc == 2 + assert "audit root is not a directory" in capsys.readouterr().err + + +def test_empty_tree_without_manifest_is_clean(tmp_path: pathlib.Path) -> None: + """No requirement files and no PEP 621 manifest is a successful no-op.""" + + module = load_module() + (tmp_path / "notes").mkdir() + (tmp_path / "notes" / "readme.txt").write_text("not a lock\n", encoding="utf-8") + (tmp_path / "requirements-not-a-file.txt").mkdir() + (tmp_path / "stray.txt").write_text("file next to root\n", encoding="utf-8") + + assert module.should_audit_project_manifest(tmp_path) is False + assert module.discover_requirement_files(tmp_path) == [] + assert module.run_audits(tmp_path, runner=lambda _command: 1) == 0 + + +def test_run_audits_skips_compile_inputs_and_fails_manifest( + tmp_path: pathlib.Path, capsys: Any +) -> None: + """Override/input files are skipped; a failing project audit still fails closed.""" + + module = load_module() + source = tmp_path / "requirements-strix-ci.txt" + source.write_text("strix-agent==1.5.3\n", encoding="utf-8") + (tmp_path / "requirements-strix-ci-hashes.txt").write_text( + "strix-agent==1.5.3 --hash=sha256:" + ("b" * 64) + "\n", + encoding="utf-8", + ) + (tmp_path / "requirements-strix-ci-overrides.txt").write_text( + "cryptography==50.0.0\n", + encoding="utf-8", + ) + (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") + (tmp_path / "requirements-strix-ci-hashes-dir").mkdir() + hashed_dir_sibling = tmp_path / "requirements-plain.txt" + hashed_dir_sibling.write_text("plain==1.0.0\n", encoding="utf-8") + (tmp_path / "requirements-plain-hashes.txt").mkdir() + + def fake_runner(command: list[str]) -> int: + return 1 if command[-1] == "." else 0 + + rc = module.run_audits(tmp_path, runner=fake_runner) + + assert rc == 1 + logged = capsys.readouterr().out + assert "skip" in logged + assert module.hashed_sibling(hashed_dir_sibling) is None + + +def test_pylock_manifest_and_require_hashes_directive( + tmp_path: pathlib.Path, +) -> None: + """A pylock file is recognized; ``--require-hashes`` without hashes is not.""" + + module = load_module() + nested = tmp_path / "svc" + nested.mkdir() + (nested / "pylock.svc.toml").write_text("lock = true\n", encoding="utf-8") + directed = tmp_path / "requirements-directed.txt" + directed.write_text("--require-hashes\ndemo==1.0.0\n", encoding="utf-8") + hashed = tmp_path / "requirements-hashed.txt" + hashed.write_text( + "--require-hashes\ndemo==1.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + comment_only = tmp_path / "requirements-comments.txt" + comment_only.write_text("# only a comment\n", encoding="utf-8") + odd = tmp_path / "requirements-odd" + odd.write_text("demo==1.0.0\n", encoding="utf-8") + + assert module.should_audit_project_manifest(tmp_path) is True + assert module.is_hashed_lock(directed) is False + assert "--disable-pip" not in (module.audit_command(directed) or []) + assert module.is_hashed_lock(hashed) is True + assert "--disable-pip" in (module.audit_command(hashed) or []) + assert module.is_hashed_lock(comment_only) is False + assert module.hashed_sibling(odd) is None + mixed = tmp_path / "requirements-mixed.txt" + mixed.write_text( + "strix-agent==1.5.3 \\\n" + " --hash=sha256:" + ("c" * 64) + "\n" + "unhashed-demo==1.0.0\n", + encoding="utf-8", + ) + assert module.is_hashed_lock(mixed) is False + assert "--disable-pip" not in (module.audit_command(mixed) or []) + + +def test_workflow_invokes_helper_and_strix_installs_without_resolving() -> None: + """The live security workflow must not keep the resolver-only audit loop.""" + + security = WORKFLOW.read_text(encoding="utf-8") + assert "scripts/ci/pip_audit_requirements.py" in security + assert "find . -type f -name 'requirements*.txt'" not in security + + +def test_default_runner_invokes_subprocess( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """The production runner is ``subprocess.run`` when no test double is passed.""" + + module = load_module() + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + seen: list[list[str]] = [] + + class Result: + returncode = 0 + + def fake_run(command: list[str], check: bool = False) -> Result: + seen.append(list(command)) + assert check is False + return Result() + + monkeypatch.setattr(module.subprocess, "run", fake_run) + assert module.run_audits(tmp_path) == 0 + assert seen[0][0] == "pip-audit" + assert "--disable-pip" in seen[0] + + +def test_module_main_guard_executes(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Running the file as a script exits through ``main``.""" + + monkeypatch.setattr(sys, "argv", ["pip_audit_requirements.py", str(tmp_path)]) + try: + runpy.run_path(str(MODULE_PATH), run_name="__main__") + except SystemExit as exc: + assert exc.code == 0 + else: + raise AssertionError("expected SystemExit from the module main guard") + + +def test_require_regular_file_rejects_missing_and_directory( + tmp_path: pathlib.Path, +) -> None: + """A missing path or a directory cannot be treated as a requirements lock.""" + + module = load_module() + with pytest.raises(module.AuditConfigurationError, match="could not be inspected"): + module._require_regular_file(tmp_path / "missing.txt") + with pytest.raises(module.AuditConfigurationError, match="regular non-symlink"): + module._require_regular_file(tmp_path) + + +def test_discover_rejects_paths_that_escape_the_audit_root( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """A glob hit that is not under the audit root is fail-closed.""" + + module = load_module() + lock = tmp_path / "requirements-ci.txt" + lock.write_text("demo==1\n", encoding="utf-8") + original = pathlib.Path.relative_to + + def escape(self: pathlib.Path, other: pathlib.Path) -> pathlib.PurePath: + if self.name == "requirements-ci.txt": + raise ValueError("escaped") + return original(self, other) + + monkeypatch.setattr(pathlib.Path, "relative_to", escape) + with pytest.raises(module.AuditConfigurationError, match="escaped the audit root"): + module.discover_requirement_files(tmp_path) + + +def test_discover_rejects_unstatable_requirement_hits( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """A requirements path that cannot be lstat'd cannot enter the audit set.""" + + module = load_module() + lock = tmp_path / "requirements-ci.txt" + lock.write_text("demo==1\n", encoding="utf-8") + original = pathlib.Path.lstat + + def boom(self: pathlib.Path) -> Any: + if self.name == "requirements-ci.txt": + raise OSError("gone") + return original(self) + + monkeypatch.setattr(pathlib.Path, "lstat", boom) + with pytest.raises(module.AuditConfigurationError, match="could not be inspected"): + module.discover_requirement_files(tmp_path) + + +def test_should_audit_skips_unstatable_children_and_accepts_pylock( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """Unstatable children are skipped; a pylock.*.toml file is a real manifest.""" + + module = load_module() + vanished = tmp_path / "aaa-vanished" + vanished.mkdir() + service = tmp_path / "service" + service.mkdir() + (service / "pylock.alias.toml").symlink_to(service / "missing.toml") + (service / "pylock.ci.toml").write_text("[tool]\n", encoding="utf-8") + original = pathlib.Path.lstat + + def flaky(self: pathlib.Path) -> Any: + if self.name == "aaa-vanished": + raise OSError("race") + return original(self) + + monkeypatch.setattr(pathlib.Path, "lstat", flaky) + assert module.should_audit_project_manifest(tmp_path) is True + + +def test_should_audit_continues_when_only_child_cannot_be_stated( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """An unstatable child cannot hide the no-manifest result.""" + + module = load_module() + vanished = tmp_path / "vanished" + vanished.mkdir() + original = pathlib.Path.lstat + + def flaky(self: pathlib.Path) -> Any: + if self.name == "vanished": + raise OSError("race") + return original(self) + + monkeypatch.setattr(pathlib.Path, "lstat", flaky) + assert module.should_audit_project_manifest(tmp_path) is False + + +def test_display_path_falls_back_to_name_outside_the_root( + tmp_path: pathlib.Path, +) -> None: + """A path outside the audit root is logged by file name only.""" + + module = load_module() + outside = tmp_path.parent / "outside-requirements.txt" + rendered = module._display_path(tmp_path, outside) + assert "outside-requirements.txt" in rendered + assert ".." not in rendered diff --git a/tests/test_pip_audit_requirements_fail_closed.py b/tests/test_pip_audit_requirements_fail_closed.py new file mode 100644 index 000000000..94384e528 --- /dev/null +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -0,0 +1,328 @@ +"""Fail-closed regressions for Python requirement audit discovery. + +These tests protect the pull-request security boundary: a repository-controlled +filename, sibling file, encoding error, or pip option must not suppress the +install set that ``pip-audit`` evaluates or forge GitHub Actions log commands. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +from typing import Any + +import pytest + + +MODULE_PATH = ( + pathlib.Path(__file__).resolve().parents[1] + / "scripts" + / "ci" + / "pip_audit_requirements.py" +) + + +def load_module() -> Any: + """Load the production helper from its script path.""" + + spec = importlib.util.spec_from_file_location( + "pip_audit_requirements_fail_closed", MODULE_PATH + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _write_valid_lock(path: pathlib.Path) -> None: + """Write one exact package pin with a complete SHA-256 hash.""" + + path.write_text( + "demo==1.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + +def test_invalid_hash_sibling_cannot_suppress_source_audit( + tmp_path: pathlib.Path, +) -> None: + """An empty or directive-only sibling is not an audited install set.""" + + module = load_module() + source = tmp_path / "requirements-demo.txt" + source.write_text("demo==1.0.0\n", encoding="utf-8") + sibling = tmp_path / "requirements-demo-hashes.txt" + sibling.write_text("--require-hashes\n", encoding="utf-8") + + assert module.hashed_sibling(source) is None + assert module.audit_command(source) == [ + "pip-audit", + "--strict", + "--desc=on", + "-r", + str(source), + ] + + +def test_pip_option_with_hash_is_not_a_complete_lock(tmp_path: pathlib.Path) -> None: + """A hash-shaped pip option cannot earn ``--disable-pip`` treatment.""" + + module = load_module() + requirements = tmp_path / "requirements-option.txt" + requirements.write_text( + "--index-url https://example.invalid/simple --hash=sha256:" + + ("b" * 64) + + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is False + assert "--disable-pip" not in (module.audit_command(requirements) or []) + + +def test_invalid_utf8_fails_before_any_audit_command( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Undecodable requirement bytes cannot disappear from the audited set.""" + + module = load_module() + requirements = tmp_path / "requirements-invalid.txt" + requirements.write_bytes(b"demo==1.0.0\xff\n") + calls: list[list[str]] = [] + + result = module.run_audits( + tmp_path, + runner=lambda command: calls.append(list(command)) or 0, + ) + + assert result == 2 + assert calls == [] + error = capsys.readouterr().err + assert "invalid UTF-8 requirements input" in error + assert "0xff" not in error + + +def test_requirement_symlink_is_rejected_instead_of_followed( + tmp_path: pathlib.Path, +) -> None: + """A tracked-looking symlink cannot redirect audit input outside the tree.""" + + module = load_module() + outside = tmp_path / "outside.txt" + _write_valid_lock(outside) + link = tmp_path / "requirements-link.txt" + link.symlink_to(outside) + error_type = getattr(module, "AuditConfigurationError", RuntimeError) + + with pytest.raises(error_type, match="regular non-symlink file"): + module.discover_requirement_files(tmp_path) + + +def test_symlink_hash_sibling_cannot_suppress_source_audit( + tmp_path: pathlib.Path, +) -> None: + """A symlink ``*-hashes.txt`` sibling is not a regular complete lock.""" + + module = load_module() + source = tmp_path / "requirements-demo.txt" + source.write_text("demo==1.0.0\n", encoding="utf-8") + outside = tmp_path / "outside-lock.txt" + _write_valid_lock(outside) + sibling = tmp_path / "requirements-demo-hashes.txt" + sibling.symlink_to(outside) + + assert module.hashed_sibling(source) is None + assert module.audit_command(source) == [ + "pip-audit", + "--strict", + "--desc=on", + "-r", + str(source), + ] + assert "--disable-pip" not in (module.audit_command(source) or []) + + +def test_filename_only_requirement_is_not_a_complete_lock( + tmp_path: pathlib.Path, +) -> None: + """A hashed wheel path without an exact ``==`` pin cannot bypass pip.""" + + module = load_module() + requirements = tmp_path / "requirements-wheel.txt" + requirements.write_text( + "./demo-1.0.0-py3-none-any.whl --hash=sha256:" + ("c" * 64) + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is False + assert "--disable-pip" not in (module.audit_command(requirements) or []) + + +def test_valid_regular_hash_sibling_still_suppresses_compile_input( + tmp_path: pathlib.Path, +) -> None: + """The fail-closed checks preserve the intended complete-lock fast path.""" + + module = load_module() + source = tmp_path / "requirements-demo.txt" + source.write_text("demo==1.0.0\n", encoding="utf-8") + sibling = tmp_path / "requirements-demo-hashes.txt" + _write_valid_lock(sibling) + + assert module.hashed_sibling(source) == sibling + assert module.audit_command(source) is None + assert "--disable-pip" in (module.audit_command(sibling) or []) + + +def test_control_characters_in_paths_are_escaped_in_workflow_logs( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A filename newline cannot inject a GitHub Actions workflow command.""" + + module = load_module() + hostile = tmp_path / "requirements-bad\n::error::forged.txt" + _write_valid_lock(hostile) + + assert module.run_audits(tmp_path, runner=lambda _command: 0) == 0 + output = capsys.readouterr().out + assert "\\n::error::forged.txt" in output + assert "\n::error::forged.txt" not in output + + +def test_directory_symlink_cannot_import_outside_requirements( + tmp_path: pathlib.Path, +) -> None: + """A directory symlink cannot pull an outside lock into the audit set.""" + + module = load_module() + outside = tmp_path / "outside-tree" + outside.mkdir() + secret = outside / "requirements-secret.txt" + _write_valid_lock(secret) + root = tmp_path / "repo" + root.mkdir() + (root / "vendor").symlink_to(outside) + presented = root / "vendor" / "requirements-secret.txt" + calls: list[list[str]] = [] + + result = module.run_audits( + root, + runner=lambda command: calls.append(list(command)) or 0, + ) + + assert result == 0 + assert calls == [] + assert module.discover_requirement_files(root) == [] + with pytest.raises(module.AuditConfigurationError, match="escaped the audit root"): + module._reject_escaped_requirement_path(root, presented) + + +def test_parent_symlink_inside_root_is_still_rejected( + tmp_path: pathlib.Path, +) -> None: + """An intermediate directory symlink is fail-closed even when it stays inside.""" + + module = load_module() + nested = tmp_path / "nested" + nested.mkdir() + real = nested / "requirements-in.txt" + _write_valid_lock(real) + (tmp_path / "alias").symlink_to(nested) + presented = tmp_path / "alias" / "requirements-in.txt" + + found = module.discover_requirement_files(tmp_path) + assert found == [real] + with pytest.raises(module.AuditConfigurationError, match="escaped the audit root"): + module._reject_escaped_requirement_path(tmp_path, presented) + deeper = tmp_path / "pkg" / "nested" + deeper.mkdir(parents=True) + deep_lock = deeper / "requirements-lib.txt" + _write_valid_lock(deep_lock) + assert deep_lock in module.discover_requirement_files(tmp_path) + + +def test_index_url_config_does_not_block_a_complete_hashed_lock( + tmp_path: pathlib.Path, +) -> None: + """A resolver config option is not a package line and cannot hide a real lock.""" + + module = load_module() + digest = "d" * 64 + lock = tmp_path / "requirements-index.txt" + lock.write_text( + "--index-url https://pypi.org/simple\n" + f"demo==1.0.0 --hash=sha256:{digest}\n", + encoding="utf-8", + ) + equals_form = tmp_path / "requirements-index-eq.txt" + equals_form.write_text( + f"--extra-index-url=https://example.invalid/simple\ndemo==1.0.0 --hash=sha256:{digest}\n", + encoding="utf-8", + ) + include = tmp_path / "requirements-include.txt" + include.write_text( + f"-r more.txt\ndemo==1.0.0 --hash=sha256:{digest}\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(lock) is True + assert "--disable-pip" in (module.audit_command(lock) or []) + assert module.is_hashed_lock(equals_form) is True + assert module.is_hashed_lock(include) is False + assert "--disable-pip" not in (module.audit_command(include) or []) + + +def test_unstatable_parent_and_unresolvable_hit_fail_closed( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A parent that cannot be inspected or resolved cannot enter the audit set.""" + + module = load_module() + nested = tmp_path / "nested" + nested.mkdir() + lock = nested / "requirements-ci.txt" + _write_valid_lock(lock) + original_is_symlink = pathlib.Path.is_symlink + original_resolve = pathlib.Path.resolve + + def flaky_symlink(self: pathlib.Path) -> bool: + if self.name == "nested": + raise OSError("parent gone") + return original_is_symlink(self) + + monkeypatch.setattr(pathlib.Path, "is_symlink", flaky_symlink) + with pytest.raises(module.AuditConfigurationError, match="could not be inspected"): + module.discover_requirement_files(tmp_path) + + monkeypatch.setattr(pathlib.Path, "is_symlink", original_is_symlink) + + def boom_resolve(self: pathlib.Path, strict: bool = False) -> pathlib.Path: + if self.name == "requirements-ci.txt": + raise OSError("vanished") + return original_resolve(self, strict=strict) + + monkeypatch.setattr(pathlib.Path, "resolve", boom_resolve) + with pytest.raises(module.AuditConfigurationError, match="could not be inspected"): + module.discover_requirement_files(tmp_path) + + def escape_resolve(self: pathlib.Path, strict: bool = False) -> pathlib.Path: + if self.name == "requirements-ci.txt": + return pathlib.Path("/tmp/outside-requirements.txt") + return original_resolve(self, strict=strict) + + monkeypatch.setattr(pathlib.Path, "resolve", escape_resolve) + with pytest.raises(module.AuditConfigurationError, match="escaped the audit root"): + module.discover_requirement_files(tmp_path) + + monkeypatch.setattr(pathlib.Path, "resolve", original_resolve) + original_relative_to = pathlib.Path.relative_to + + def parent_leaves(self: pathlib.Path, other: pathlib.Path) -> pathlib.PurePath: + if self.name == "nested": + raise ValueError("left") + return original_relative_to(self, other) + + monkeypatch.setattr(pathlib.Path, "relative_to", parent_leaves) + with pytest.raises(module.AuditConfigurationError, match="escaped the audit root"): + module.discover_requirement_files(tmp_path) From c6ef6afad35c8e67049a2a1aeb05020ca77a771a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:57:53 +0900 Subject: [PATCH 2/3] fix(pip-audit): parse exact PEP 508 lock pins --- scripts/ci/pip_audit_requirements.py | 39 +++++++++++++------ ...test_pip_audit_requirements_fail_closed.py | 27 +++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py index 2e75eb017..1a2aad03e 100644 --- a/scripts/ci/pip_audit_requirements.py +++ b/scripts/ci/pip_audit_requirements.py @@ -20,6 +20,8 @@ import sys from collections.abc import Callable, Sequence +from packaging.requirements import InvalidRequirement, Requirement + Runner = Callable[[Sequence[str]], int] SKIP_DISCOVERY_PARTS = frozenset( @@ -86,13 +88,27 @@ def _requirement_lines(path: pathlib.Path) -> list[str]: def _is_exact_hashed_requirement(line: str) -> bool: """Return whether one line is an exact ``==`` pin plus SHA-256 hashes.""" - fields = re.split(r"\s+(?=--hash=)", line) - requirement = fields[0].strip() - hash_fields = fields[1:] - if not requirement or requirement.startswith("-") or "==" not in requirement: + fields = line.split() + first_hash = next( + (index for index, field in enumerate(fields) if field.startswith("--hash=")), + None, + ) + if first_hash in (None, 0): + return False + requirement_text = " ".join(fields[:first_hash]) + hash_fields = fields[first_hash:] + if not all(_HASH_FIELD.fullmatch(field) is not None for field in hash_fields): + return False + try: + requirement = Requirement(requirement_text) + except InvalidRequirement: return False - return bool(hash_fields) and all( - _HASH_FIELD.fullmatch(field) is not None for field in hash_fields + specifiers = list(requirement.specifier) + return ( + requirement.url is None + and len(specifiers) == 1 + and specifiers[0].operator == "==" + and "*" not in specifiers[0].version ) @@ -108,11 +124,12 @@ def _is_pip_config_option(line: str) -> bool: def _reject_escaped_requirement_path(root: pathlib.Path, path: pathlib.Path) -> None: """Fail closed when discovery leaves the audit root via a directory symlink. - ``Path.rglob`` follows directory symbolic links, so a repository-controlled - ``vendor -> /outside`` hop can make an external ``requirements*.txt`` look - like a descendant of the audit root. Intermediate parents are inspected - with ``is_symlink`` before ``resolve``, and the canonical target must stay - inside the resolved root (CWE-22, CWE-59). + Even though supported CPython ``Path.rglob`` does not descend through + directory symbolic links, a repository-controlled ``vendor -> /outside`` + hop must remain fail-closed if a matching path is presented directly. + Intermediate parents are inspected with ``is_symlink`` before ``resolve``, + and the canonical target must stay inside the resolved root (CWE-22, + CWE-59). """ current = path.parent diff --git a/tests/test_pip_audit_requirements_fail_closed.py b/tests/test_pip_audit_requirements_fail_closed.py index 94384e528..765dfaf18 100644 --- a/tests/test_pip_audit_requirements_fail_closed.py +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -44,6 +44,33 @@ def _write_valid_lock(path: pathlib.Path) -> None: ) +@pytest.mark.parametrize( + ("requirement", "expected"), + ( + ('demo; python_version == "3.11"', False), + ("demo @ https://example.invalid/demo==1.whl", False), + ("demo===1.0.0", False), + ("demo!=1.0.0,==1.0.1", False), + ("demo==1.*", False), + ('demo==1.0.0; python_version >= "3.11"', True), + ("demo==1.0.0", True), + ), +) +def test_hashed_lock_requires_one_pep508_exact_pin( + tmp_path: pathlib.Path, requirement: str, expected: bool +) -> None: + """Only one non-wildcard PEP 508 equality pin may bypass resolution.""" + + module = load_module() + path = tmp_path / "requirements-contract.txt" + path.write_text( + f"{requirement} --hash=sha256:{'a' * 64}\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(path) is expected + + def test_invalid_hash_sibling_cannot_suppress_source_audit( tmp_path: pathlib.Path, ) -> None: From 401cfc69735de90c88e5b7fe30326625e89041ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:41:31 +0900 Subject: [PATCH 3/3] fix(changelog): remove duplicate materialization entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2008f73f..80a7906e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,6 @@ Semantic Versioning where the repository publishes a release. - Audit complete hashed Python locks with `--disable-pip` so `--index-url` stays configuration, `-r` includes do not earn resolver bypass, and directory-symlink parents fail closed before pip-audit can label `ResolutionImpossible` as a known vulnerability. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). -- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped.