diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index 9d2c2e965..af0b2d1a8 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -232,25 +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 - echo "::group::pip-audit -r ${req}" - pip-audit --strict --desc=on -r "${req}" || status=1 - echo "::endgroup::" - 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/.github/workflows/strix.yml b/.github/workflows/strix.yml index 03ec23257..330039a42 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -543,7 +543,13 @@ jobs: # private install umask before creating the credential-bearing Strix # entry point; the runtime gate still rejects any later relaxation. umask 022 - python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-strix-ci-hashes.txt + # The compiled lock is the complete closed set. --no-deps is required + # so pip does not re-apply a stale Requires-Dist bound (strix-agent + # still declares cryptography<49) after a CVE-fixed cryptography pin + # was resolved at compile time. pull_request_target runs this file + # from the base branch, so this flag must land on main before a + # lock that needs it can install. + python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --no-deps -r requirements-strix-ci-hashes.txt strix_executable="$(command -v strix || true)" if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abdea338..3455c325d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed - 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). +- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive, a mixed hashed-plus-unhashed file, a filename-only wheel path, a `-r` include, or a hash-shaped pip option no longer receives `--disable-pip`. Resolver-config lines such as `--index-url` beside exact SHA-256 pins still use `--disable-pip`. Invalid UTF-8, symlink/special-file inputs, and directory-symlink parents fail before any audit command, and workflow log paths are escaped. A pylock-shaped symlink or directory is not a project manifest, so the continue-to-next-candidate branch no longer depends on filesystem glob order. +- Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - 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. 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..72655e782 --- /dev/null +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -0,0 +1,88 @@ +# Strix hashed-lock install and pip-audit without pip re-resolution + +검토 기준일: **2026-08-13** + +## 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`` include, or a pip option +carrying hash-shaped text also stays on the resolver path. A complete lock +that only adds resolver config such as ``--index-url`` still uses +``--disable-pip``. An unhashed compile input is skipped only when its +regular, non-symlink sibling is itself a valid complete lock. Invalid UTF-8, +symlink/special-file inputs, and paths whose intermediate parent is a +directory symlink fail before any audit command. A pylock-shaped symlink +or directory is not a project manifest. 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 + +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..ddfcfc4b2 --- /dev/null +++ b/scripts/ci/pip_audit_requirements.py @@ -0,0 +1,340 @@ +"""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}") +_RESOLVER_CONFIG_TOKENS = frozenset( + { + "--require-hashes", + "--index-url", + "--extra-index-url", + "-i", + "--trusted-host", + "--find-links", + "-f", + "--no-index", + "--pre", + "--prefer-binary", + "--only-binary", + "--no-binary", + } +) + + +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" + ) + _require_no_symlink_ancestors(path) + + +def _ancestor_in_scope( + ancestor: pathlib.Path, stop: pathlib.Path | None +) -> bool: + """Return whether *ancestor* is a repository parent that must be inspected.""" + + if stop is None: + return len(ancestor.parts) > 2 + if ancestor == stop: + return False + try: + ancestor.relative_to(stop) + except ValueError: + return False + return True + + +def _require_no_symlink_ancestors( + path: pathlib.Path, *, stop: pathlib.Path | None = None +) -> None: + """Reject a path whose intermediate parent is a directory symlink.""" + + for ancestor in path.parents: + if not _ancestor_in_scope(ancestor, stop): + continue + try: + metadata = ancestor.lstat() + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + if stat.S_ISLNK(metadata.st_mode): + raise AuditConfigurationError( + "requirements input must be a regular non-symlink file" + ) + + +def _is_resolver_config_line(line: str) -> bool: + """Return whether a logical line is pip resolver config, not a package pin.""" + + token = re.split(r"[\s=]", line, maxsplit=1)[0] + return token in _RESOLVER_CONFIG_TOKENS + + +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_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-config lines such as ``--index-url`` are not + package pins. Every substantive 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_resolver_config_line(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" + ) + _require_no_symlink_ancestors(path, stop=root) + 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..fa39a214b --- /dev/null +++ b/tests/test_pip_audit_requirements.py @@ -0,0 +1,431 @@ +"""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" +) +STRIX_WORKFLOW = ( + pathlib.Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "strix.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 workflows must not keep the resolver-only install or audit loop.""" + + security = WORKFLOW.read_text(encoding="utf-8") + strix = STRIX_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 + assert "--require-hashes --no-deps -r requirements-strix-ci-hashes.txt" in strix + assert ( + "python3 -m pip install --disable-pip-version-check --no-cache-dir " + "--require-hashes -r requirements-strix-ci-hashes.txt" + not in strix + ) + + +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..d553611da --- /dev/null +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -0,0 +1,309 @@ +"""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_index_url_with_hashed_packages_stays_on_disable_pip( + tmp_path: pathlib.Path, +) -> None: + """Resolver config is not a package line and must not recreate ResolutionImpossible.""" + + module = load_module() + requirements = tmp_path / "requirements-index.txt" + requirements.write_text( + "--index-url https://example.invalid/simple\n" + "strix-agent==1.5.3 --hash=sha256:" + ("a" * 64) + "\n" + "cryptography==50.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is True + assert "--disable-pip" in (module.audit_command(requirements) or []) + + +def test_requirement_include_cannot_earn_disable_pip(tmp_path: pathlib.Path) -> None: + """A ``-r`` include is not resolver config and must keep pip's resolver.""" + + module = load_module() + requirements = tmp_path / "requirements-include.txt" + requirements.write_text( + "-r other-hashes.txt\n" + "demo==1.0.0 --hash=sha256:" + ("a" * 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_nested_regular_lock_is_still_discovered(tmp_path: pathlib.Path) -> None: + """An intermediate regular directory must not block hashed-lock discovery.""" + + module = load_module() + nested = tmp_path / "pkg" + nested.mkdir() + lock = nested / "requirements-nested.txt" + _write_valid_lock(lock) + + assert [path.name for path in module.discover_requirement_files(tmp_path)] == [ + "requirements-nested.txt" + ] + assert "--disable-pip" in (module.audit_command(lock) or []) + + +def test_ancestor_outside_the_audit_root_is_not_inspected( + tmp_path: pathlib.Path, +) -> None: + """Parents above the audit root are host paths, not repository input.""" + + module = load_module() + stop = tmp_path / "stop" + stop.mkdir() + other = tmp_path / "other" + other.mkdir() + presented = other / "requirements-outside.txt" + _write_valid_lock(presented) + + module._require_no_symlink_ancestors(presented, stop=stop) + + +def test_unstatable_intermediate_parent_fails_before_audit( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """A parent that cannot be lstat'd cannot enter the audited set.""" + + module = load_module() + nested = tmp_path / "pkg" + nested.mkdir() + presented = nested / "requirements-nested.txt" + _write_valid_lock(presented) + original = pathlib.Path.lstat + + def boom(self: pathlib.Path) -> Any: + if self.name == "pkg": + raise OSError("gone") + return original(self) + + monkeypatch.setattr(pathlib.Path, "lstat", boom) + error_type = getattr(module, "AuditConfigurationError", RuntimeError) + with pytest.raises(error_type, match="could not be inspected"): + module._require_no_symlink_ancestors(presented, stop=tmp_path) + + +def test_intermediate_symlink_parent_is_rejected_before_audit( + tmp_path: pathlib.Path, +) -> None: + """A directory symlink in the presented path cannot redirect the install set.""" + + module = load_module() + real = tmp_path / "real" + (real / "sub").mkdir(parents=True) + _write_valid_lock(real / "sub" / "requirements-hidden.txt") + link = tmp_path / "link" + link.symlink_to(real) + presented = link / "sub" / "requirements-hidden.txt" + error_type = getattr(module, "AuditConfigurationError", RuntimeError) + + with pytest.raises(error_type, match="regular non-symlink file"): + module.is_hashed_lock(presented) + with pytest.raises(error_type, match="regular non-symlink file"): + module.audit_command(presented) + + +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_non_regular_pylock_name_is_not_a_project_manifest( + tmp_path: pathlib.Path, +) -> None: + """A pylock-shaped symlink or directory cannot earn the project-manifest audit.""" + + module = load_module() + (tmp_path / "pylock.alias.toml").symlink_to(tmp_path / "missing.toml") + (tmp_path / "pylock.dir.toml").mkdir() + + assert module.should_audit_project_manifest(tmp_path) is False + + +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