diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index e69b71c8..5e62f346 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -22,6 +22,17 @@ Independent security modules #511 and core #719/#718 and existing signing gates ## Authorized #747 dependency stack (2026-10-10) +### Dev-first 0.55.11 security completion (2026-10-25) + +The owner requires the missing reviewed security updates to enter a PR to +`dev` before the main release. The additive +[`security-dependencies-0-55-11-dev`](changes/security-dependencies-0-55-11-dev/proposal.md) +change tracks this follow-up under issue #780. Preserve the delivered #784 +change, original evidence and current dev runtime/three-audit protections. +PR #785 remains an incomplete draft release prefix. Native bootstrap and +CI-only signatures must be resolved through their legitimate gates. No merge +or publication is authorized. + ### Official dependency hotfix and temporary MCP exception (2026-10-21) [PR #782 / issue #780](changes/isolate-semgrep-security-runtime/proposal.md) retains diff --git a/openspec/changes/security-dependencies-0-55-11-dev/.openspec.yaml b/openspec/changes/security-dependencies-0-55-11-dev/.openspec.yaml new file mode 100644 index 00000000..7ccf7c36 --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/security-dependencies-0-55-11-dev/CHANGE_VALIDATION.md b/openspec/changes/security-dependencies-0-55-11-dev/CHANGE_VALIDATION.md new file mode 100644 index 00000000..58dd4598 --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/CHANGE_VALIDATION.md @@ -0,0 +1,32 @@ +# Current validation + +The owner explicitly requires the missing security updates to enter `dev` +before a main release. This new change covers only that additive follow-up. +The delivered `isolate-semgrep-security-runtime` change and all original proof +identities remain unchanged. No old exact-head grant transfers. + +At dev `9b5ad1e78430cc0b061cb16de42f73de96f4f312`, the new spec validates with +`openspec validate security-dependencies-0-55-11-dev --strict`. Targeted Ruff +0.15.20 checks pass. The network-enabled 15-case local failing-before run +contains 15 intended assertion failures, zero errors and zero skips, with all +recorded inputs unchanged. An earlier run's three installation failures remain +setup evidence only, despite its matching pytest failure count. +These observations are not full-project quality or native CI acceptance. + +Only spec/tests and authentic local proof have been prepared. The seven +production inputs remain byte-identical to the dev base. The normal Hatch +environment is initialized. Independent static and local behavioral reviews +accepted the test prefix. Normal hooks passed for its first local snapshot. +After the quality correction, actual Code Review reports zero findings and +20 selected preservation controls pass. CI signing is still being prepared. No signed dev PR, native RED, production change, passing +final wave or actual forge-ready state is claimed. + +Live issue #780 is open, status Todo, parent #747, with security/dependency +labels and project assignment. Open blockers #783 and #523 remain recorded. +An internal wiki mirror is required for the new scope and remains a separate +bounded sibling-write item; the prior exact two-file grant is not reused. + +Current risks are the expired native bootstrap, accidental loss of dev's third +audit/runtime protections, and stale authority after a head change. Keep the +bootstrap prerequisite explicit, review scoped hunks, and obtain fresh final +head/tree authority. No merge or release is authorized. diff --git a/openspec/changes/security-dependencies-0-55-11-dev/TDD_EVIDENCE.md b/openspec/changes/security-dependencies-0-55-11-dev/TDD_EVIDENCE.md new file mode 100644 index 00000000..1916a0ee --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/TDD_EVIDENCE.md @@ -0,0 +1,83 @@ +# Dev-first failing-before evidence + +On 26 October 2026 Europe/Berlin, the unchanged production inputs at dev +`9b5ad1e78430cc0b061cb16de42f73de96f4f312` produced 15 assertion failures, +zero errors and zero skips for this change's 15 mapped cases. The exact JUnit +and execution/input receipt are retained under `requirements-proof/` as +`local-red-behavior-20261026.xml.gz` and its companion receipt. Root inspected each +failure message. The Git cases show rejection false, clone boundary reached +and one clone call, rather than a wheel-installation error. + +The gzip transport decompresses to the original immutable XML bytes, SHA-256 +`cdbef78a51e9bbf66e43ac826a3cfb23335b06de6092b93ed71e37606d19c407`. +Pytest's raw failure trace contains trailing whitespace. Compression preserves +that report without normalizing its contents or changing formatter rules. + +The targeted local runner used Python 3.12.13, pytest 9.1.1 and the exact +hash-pinned test-tool entries extracted from dev's CI export. It loaded normal +repository conftest/configuration with third-party plugin autoload disabled. +GitPython probes installed the exact frozen GitPython/gitdb/smmap wheels with +hash checking, no dependency resolution and isolated `-I -S` execution. + +The failures observe the five old package versions, both old public floors, +the old parser record, three missing exact OpenTelemetry admissions, three +metadata submodule paths reaching clone, and two existing advisory exclusions +where the required nine are expected. All recorded inputs stayed unchanged +during execution. No production edit preceded this run. + +The first exploratory run had 15 failures and one passing preservation case. +It is retained privately without relabeling. The already-satisfied shared-lane +case was removed from this RED plan and remains an ordinary control. Independent +review also required per-package advisory fixtures, parser hostile-fixture +adaptation and exact deselection bounds before the corrected run. This evidence +uses the corrected tests and mapping; it does not transfer the first run's +identities. + +The pinned Requirements lifecycle initially rejected the automatic floor-case +parameter IDs because comparison punctuation is not allowed in selectors. +Explicit `gitpython-floor` and `pycparser-floor` IDs corrected the metadata; +the assertions did not change. The next run had 12 intended assertion failures +and three wheel-installation CalledProcessError failures. Its total of 15 +pytest failures did not establish complete behavioral RED. Independent review +caught this distinction. That raw run and receipt remain unchanged and receive +no behavioral acceptance. The separately approved network-enabled run then +produced all 15 intended assertion failures, with unchanged inputs and no +errors or skips. The actual pinned lifecycle accepts planned maturity with no +findings, mapping digest +`sha256:7025a000601cc44c1a1e6168367f2188681471d68ea5f044cfc81a94b734f8ff` +and plan digest +`sha256:dc90453d191b282ba5e47127bf067ee23b475072d7e94a0859c7c6a26677ce3a`. +That planned-only result supplies no native RED or mapping review acceptance. + +This is local behavioral failing-before evidence. It is not accepted native CI +RED, an origin attestation, exact-head authority, a production acceptance or +merge readiness. Native bootstrap remains blocked before tests by the base's +expired pycparser 2.22 review. No renewal or check waiver is claimed. + +Passing-after, full quality gates, genuine accepted native evidence, independent +final review, CI-only Git signatures and actual forge readiness remain pending. + +## Test-prefix quality correction + +On 26 October 2026 Europe/Berlin, the normal Code Review identified duplicated +trust fixtures and unknown member types inherited by the edited audit tests. +The correction groups the five unchanged hostile fixture oracles and adds +fixture/function annotations. Mapping YAML indentation changes parsed no data. +A fresh independent review accepted these exact changes and their local results. + +The distinct `local-red-quality` run again produced 15 intended assertion +failures, zero errors and zero skips. Its receipt binds all 13 input hashes. +The separate `local-controls-quality` run passed 20 cases, including all nine +original MCP scenarios, the grouped hostile fixtures and ordinary clone paths. +These reports remain private local evidence and replace no native identity. + +The actual normal SpecFact Code Review run +`review-f821c36e-2ff9-43f9-8f4c-1a403be11ec1` reports PASS with zero findings. +Direct yamllint of the new YAML files passes. The repository-wide YAML hook +prints inherited findings and returns success incorrectly; full YAML cleanliness +is not claimed. That wrapper defect remains tracked separately in +[issue #781](https://github.com/nold-ai/specfact-cli/issues/781). + +The local test prefix has passed normal hooks. Its unsigned source snapshot is +not a published branch or accepted commit signature. A bounded create-only CI +signer must produce and verify the delivered tree with dev as its sole parent. diff --git a/openspec/changes/security-dependencies-0-55-11-dev/design.md b/openspec/changes/security-dependencies-0-55-11-dev/design.md new file mode 100644 index 00000000..28f2399e --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/design.md @@ -0,0 +1,37 @@ +# Design + +The data contract is the reviewed package/version table, two public dependency +floors and three distinct advisory records. The existing frozen refresh and audit +implementations remain the source of behavior. The existing selection test +compares nonempty SHA-256 sets from lock artifacts and exported requirements. +No new dependency resolver or advisory engine is needed. + +Use a new change root because Requirements selects the complete mapping for +each changed root. Editing the delivered `isolate-semgrep-security-runtime` +mapping would incorrectly demand new RED for cases already passing on dev. +Keep that root and all historical evidence byte-identical. + +Tests inspect the canonical lock/export and metadata, invoke the existing audit +gate with concrete hostile inputs, and exercise the frozen GitPython API with +the clone boundary observed. Passing preservation and negative controls run +alongside the selected genuinely failing cases but are not labeled RED. + +Only scoped hunks may enter existing dev files. Preserve its third audit, +runtime-smoke graph, native artifact transport, trusted planning/proof bases and +all other runtime behavior. Reuse reviewed official artifact metadata, then +regenerate and verify the lock/export with normal tooling. Never hand-edit the +generated dependency lists to force a resolver result. + +CI-only Git signing must bind the reviewed tree, exact dev parent and a new +create-only branch using existing permissions. It grants no shared-history +rewrite, protected ref change or merge. The test-only prefix must remain +separate from later production commits. + +Native bootstrap is an explicit open delivery dependency. The base's expired +parser record prevents test execution; changing that production record in the +RED prefix is not a native proof fix. The concrete trusted bootstrap or exact +admission prerequisite needs independent review and any separate protected +deployment approval before adoption. No implementation is claimed here. + +Rollback before merge is disposal of the unmerged candidate branch. A later +merged dependency correction can be reverted through an ordinary reviewed PR. diff --git a/openspec/changes/security-dependencies-0-55-11-dev/proposal.md b/openspec/changes/security-dependencies-0-55-11-dev/proposal.md new file mode 100644 index 00000000..f589df23 --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/proposal.md @@ -0,0 +1,86 @@ +# Change: Complete the 0.55.11 security dependencies on dev + +## Why + +The owner requires the security patch to enter `dev` before a release PR can +reach `main`. Dev commit `9b5ad1e78430cc0b061cb16de42f73de96f4f312` already +contains merged #784, core 0.55.11, the certificate correction and the current +Requirements controls. The reviewed dependency updates and their supporting +records remain absent. PR #785 contains an incomplete test-only release prefix +and is not a replacement for this dev follow-up. + +## What Changes + +- Select official GitPython 3.2.1, packaging 26.3, pycparser 3.12, + python-discovery 1.6.1, virtualenv 21.14.13, filelock 4.2.1, msgpack 1.2.3, + setuptools 84.1.0 and Semgrep 1.182.1 in the frozen graph/export. +- Select official Pylint 4.0.9 in the separate Code Review input and lock to + fix GHSA-22qq-2fwx-7p67 without changing its six transitive selections. +- Require GitPython >=3.2.1 and pycparser >=3.12, excluding the blocked 3.0 + family, in both public dependency declarations. +- Replace the expired pycparser 2.22 provenance record with the reviewed exact + official 3.12 artifact. Do not renew the expired record. +- Add the separately owner-approved OpenTelemetry API 1.37.0 advisory record, + distinct from MCP 1.29.0. Preserve the original MCP record and add only + the three separately accepted MCP advisory identities in their exact + package/version record reviewed on 29 October 2026. All three records + end on 4 November 2026 inclusive. This accepts temporary residual risk; + it does not remediate MCP or OpenTelemetry. +- Exercise GitPython submodule creation against metadata paths and ordinary + checkout controls through the exact frozen wheels. +- Exclude only frozen-version assertions from the scheduled advisory resolver + lanes. Keep ordinary negative tests and every blocking job selected. +- Preserve dev runtime changes, portable smoke fixtures, all three strict + audits, existing version 0.55.11 and all delivered Requirements controls. + +## Capabilities + +### New Capabilities + +- `dev-security-dependencies`: the remaining reviewed dependency selection, + provenance, bounded advisory treatment and compatibility controls. + +### Modified Capabilities + +None. The existing admission capability and its original evidence stay intact. + +## Impact + +This follow-up changes dependency inputs, their records and verification. It +does not change application source or module manifests. The frozen graph's +PyJWT remediation is not a claim about every public resolver outcome. + +Native CI currently rejects the base's expired parser record before tests. +Local failing tests alone cannot replace native provenance. A legitimate +trusted bootstrap or separately approved exact admission must resolve that +boundary before production history is adopted. No old PR #785 or #784 grant +transfers to this change, branch, PR, plan, head or tree. + +No merge, publication, protected deployment, local signing, access expansion, +check waiver, exception renewal or advisory exception beyond these exact +reviewed records is authorized. A later dependency-only release must retain the published v0.55.4 application payload; +this dev PR does not authorize wholesale dev promotion. + +## Source Tracking + +- Repository: nold-ai/specfact-cli. +- Issue: . +- Parent: #747. Live status on 25 October 2026 is Todo. +- Open blockers #783 and #523 remain recorded; this proposal does not close them. +- Supersedes no delivered change or historical proof. + +## Acceptance Criteria + +- Every selected new RED case fails for its specified missing behavior, without + errors or skips, before dependency/workflow edits. +- Frozen refresh is generated normally and changes only the reviewed dependency + closure and declared metadata. +- The lock and export contain equal nonempty SHA-256 artifact sets for each + reviewed package; a version-only export or substituted digest fails. +- All three strict audits pass on the actual frozen CI, Code Review and + runtime-smoke graphs, including setuptools 84.1.0, with only the three exact + bounded records above. Skipped subjects or unrelated advisories fail. +- Exact artifact, GitPython compatibility and preservation controls pass. +- Native producer, fresh/final consumers, signatures, Code Review and every + required check pass for the actual final head under fresh authority. +- The dev PR is reviewable and forge-ready. No merge occurs. diff --git a/openspec/changes/security-dependencies-0-55-11-dev/requirements-evidence.yaml b/openspec/changes/security-dependencies-0-55-11-dev/requirements-evidence.yaml new file mode 100644 index 00000000..0d25511a --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/requirements-evidence.yaml @@ -0,0 +1,269 @@ +schema_version: '2' +requirements: + openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:select-reviewed-official-patch-versions: + rationale: Deliver only the reviewed missing official updates on current dev. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: input-1 + kind: config_file + locator: uv.lock + - id: input-2 + kind: config_file + locator: requirements/ci/locked.txt + - id: proof-test + kind: test + locator: tests/unit/security/test_release_dependency_selection.py + verification_cases: + - case_id: DEV-PIN-GITPYTHON + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official gitpython 3.2.1. + observable: Frozen lock and hash export select official gitpython 3.2.1. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[gitpython-3.2.1] + - case_id: DEV-PIN-PACKAGING + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official packaging 26.3. + observable: Frozen lock and hash export select official packaging 26.3. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[packaging-26.3] + - case_id: DEV-PIN-PYCPARSER + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official pycparser 3.12. + observable: Frozen lock and hash export select official pycparser 3.12. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[pycparser-3.12] + - case_id: DEV-PIN-PYTHON-DISCOVERY + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official python-discovery 1.6.1. + observable: Frozen lock and hash export select official python-discovery 1.6.1. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[python-discovery-1.6.1] + - case_id: DEV-PIN-VIRTUALENV + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official virtualenv 21.14.13. + observable: Frozen lock and hash export select official virtualenv 21.14.13. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[virtualenv-21.14.13] + - case_id: DEV-PIN-FILELOCK + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official filelock 4.2.1. + observable: Frozen lock and hash export select official filelock 4.2.1. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[filelock-4.2.1] + - case_id: DEV-PIN-MSGPACK + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official msgpack 1.2.3. + observable: Frozen lock and hash export select official msgpack 1.2.3. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[msgpack-1.2.3] + - case_id: DEV-PIN-SETUPTOOLS + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official setuptools 84.1.0. + observable: Frozen lock and hash export select official setuptools 84.1.0. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[setuptools-84.1.0] + - case_id: DEV-PIN-SEMGREP + scenario_id: select-reviewed-official-patch-versions + method: test + intent: Frozen lock and hash export select official semgrep 1.182.1. + observable: Frozen lock and hash export select official semgrep 1.182.1. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[semgrep-1.182.1] + openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:declare-the-corrected-public-dependency-floors: + rationale: Keep both distributed declarations above the corrected security floors. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: input-1 + kind: config_file + locator: pyproject.toml + - id: input-2 + kind: config_file + locator: setup.py + - id: proof-test + kind: test + locator: tests/unit/security/test_release_dependency_selection.py + verification_cases: + - case_id: DEV-FLOOR-GITPYTHON + scenario_id: declare-the-corrected-public-dependency-floors + method: test + intent: Both public declarations require gitpython>=3.2.1. + observable: Both public declarations require gitpython>=3.2.1. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_public_declarations_require_reviewed_security_floor[gitpython-floor] + - case_id: DEV-FLOOR-PYCPARSER + scenario_id: declare-the-corrected-public-dependency-floors + method: test + intent: Both public declarations require pycparser>=3.12,!=3.0.*. + observable: Both public declarations require pycparser>=3.12,!=3.0.*. + selector: + runner: pytest + node_id: tests/unit/security/test_release_dependency_selection.py::test_public_declarations_require_reviewed_security_floor[pycparser-floor] + ? openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:replace-expired-parser-provenance-with-exact-reviewed-bytes + : rationale: Replace the expired artifact without extending its authority. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: input-1 + kind: config_file + locator: ci/dependency-trust-exceptions.json + - id: proof-test + kind: test + locator: tests/unit/scripts/test_dependency_trust_review.py + verification_cases: + - case_id: DEV-PARSER-EXACT-REVIEW + scenario_id: replace-expired-parser-provenance-with-exact-reviewed-bytes + method: test + intent: The record binds only the reviewed official 3.12 artifact and exact original review/expiry window. + observable: The record binds only the reviewed official 3.12 artifact and exact original review/expiry window. + selector: + runner: pytest + node_id: tests/unit/scripts/test_dependency_trust_review.py::test_pycparser_has_current_exact_version_review_record + openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:bound-the-separately-approved-opentelemetry-exception: + rationale: Use the existing strict engine with the separately approved exact record. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: input-1 + kind: config_file + locator: ci/vulnerability-audit-exceptions.json + - id: proof-test + kind: test + locator: tests/unit/scripts/test_security_audit_gate.py + verification_cases: + - case_id: DEV-OTEL-EXACT-CVE + scenario_id: bound-the-separately-approved-opentelemetry-exception + method: test + intent: The exact reviewed advisory is visibly admitted only within the approved window. + observable: The exact reviewed advisory is visibly admitted only within the approved window. + selector: + runner: pytest + node_id: tests/unit/scripts/test_security_audit_gate.py::test_committed_otel_exception_is_exact_and_time_bounded[exact-cve] + - case_id: DEV-OTEL-EXACT-GHSA + scenario_id: bound-the-separately-approved-opentelemetry-exception + method: test + intent: The exact reviewed advisory is visibly admitted only within the approved window. + observable: The exact reviewed advisory is visibly admitted only within the approved window. + selector: + runner: pytest + node_id: tests/unit/scripts/test_security_audit_gate.py::test_committed_otel_exception_is_exact_and_time_bounded[exact-ghsa] + - case_id: DEV-OTEL-EXPIRY-DAY + scenario_id: bound-the-separately-approved-opentelemetry-exception + method: test + intent: The exact reviewed advisory is visibly admitted only within the approved window. + observable: The exact reviewed advisory is visibly admitted only within the approved window. + selector: + runner: pytest + node_id: tests/unit/scripts/test_security_audit_gate.py::test_committed_otel_exception_is_exact_and_time_bounded[expiry-day] + openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:reject-submodule-creation-inside-git-metadata: + rationale: Observe the actual frozen GitPython API before clone or metadata writes. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: input-1 + kind: config_file + locator: requirements/ci/locked.txt + - id: proof-test + kind: test + locator: tests/unit/security/test_frozen_gitpython_compatibility.py + verification_cases: + - case_id: DEV-GIT-CREATE-GIT-METADATA + scenario_id: reject-submodule-creation-inside-git-metadata + method: test + intent: Metadata checkout is rejected with no clone, hook change or submodule metadata. + observable: Metadata checkout is rejected with no clone, hook change or submodule metadata. + selector: + runner: pytest + node_id: tests/unit/security/test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone[git-metadata] + - case_id: DEV-GIT-CREATE-GIT-SHORT-NAME + scenario_id: reject-submodule-creation-inside-git-metadata + method: test + intent: Metadata checkout is rejected with no clone, hook change or submodule metadata. + observable: Metadata checkout is rejected with no clone, hook change or submodule metadata. + selector: + runner: pytest + node_id: tests/unit/security/test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone[git-short-name] + - case_id: DEV-GIT-CREATE-ABSOLUTE-METADATA + scenario_id: reject-submodule-creation-inside-git-metadata + method: test + intent: Metadata checkout is rejected with no clone, hook change or submodule metadata. + observable: Metadata checkout is rejected with no clone, hook change or submodule metadata. + selector: + runner: pytest + node_id: tests/unit/security/test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone[absolute-metadata] + openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:keep-frozen-assertions-out-of-advisory-resolver-lanes: + rationale: Keep scheduled moving-resolution checks distinct from blocking frozen delivery. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: input-1 + kind: config_file + locator: .github/workflows/pr-orchestrator.yml + - id: proof-test + kind: test + locator: tests/unit/scripts/test_dependency_compatibility_workflow.py + verification_cases: + - case_id: DEV-ADVISORY-SELECTION + scenario_id: keep-frozen-assertions-out-of-advisory-resolver-lanes + method: test + intent: Collection retains all ordinary cases and excludes only the reviewed frozen selectors. + observable: Collection retains all ordinary cases and excludes only the reviewed frozen selectors. + selector: + runner: pytest + node_id: tests/unit/scripts/test_dependency_compatibility_workflow.py::test_resolver_selection_excludes_only_frozen_release_cases + openspec:security-dependencies-0-55-11-dev:dev-security-dependencies:preserve-strict-audits-with-exact-bounded-residual-risk: + rationale: Preserve exact advisory scope and fix the isolated Code Review vulnerability. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: exact-advisories + kind: config_file + locator: ci/vulnerability-audit-exceptions.json + - id: code-review-input + kind: config_file + locator: requirements/code-review/requirements.in + - id: code-review-lock + kind: config_file + locator: requirements/code-review/locked.txt + - id: code-review-test + kind: test + locator: tests/unit/security/test_code_review_dependency_security.py + - id: exact-advisory-test + kind: test + locator: tests/unit/security/test_release_promotion_security_gates.py + verification_cases: + - case_id: DEV-PYLINT-FIXED + scenario_id: preserve-strict-audits-with-exact-bounded-residual-risk + method: test + intent: Actual Code Review installer inputs select patched Pylint and official hashes. + observable: Exact version, full artifact hashes and input digest agree. + selector: + runner: pytest + node_id: tests/unit/security/test_code_review_dependency_security.py::test_code_review_pins_fixed_pylint_with_bound_artifact_hashes + - case_id: DEV-EXACT-RESIDUAL-RISK + scenario_id: preserve-strict-audits-with-exact-bounded-residual-risk + method: test + intent: Only the three exact reviewed records remain admitted within their dates. + observable: Exact records, versions, IDs, dates and official frozen sources agree. + selector: + runner: pytest + node_id: tests/unit/security/test_release_promotion_security_gates.py::test_frozen_graph_uses_official_semgrep_with_bounded_mcp_exception diff --git a/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/local-red-behavior-20261026-receipt.json b/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/local-red-behavior-20261026-receipt.json new file mode 100644 index 00000000..62f8319d --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/local-red-behavior-20261026-receipt.json @@ -0,0 +1,49 @@ +{ + "scope": "local targeted failing-before only; NOT native proof or authority", + "network": "sandbox escalation approved for exact pinned wheel probes", + "started_at": "2026-10-25T23:12:40.638724+00:00", + "finished_at": "2026-10-25T23:12:41.716231+00:00", + "base": "9b5ad1e78430cc0b061cb16de42f73de96f4f312", + "command": [ + "/private/tmp/specfact-release-20261022/worktrees/codex/security-dependencies-0-55-11-dev/.venv/bin/python", + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + "--junitxml", + "/private/tmp/specfact-release-20261022/dev-first-security-followup-20261025/local-red-network/junit.xml", + "tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[gitpython-3.2.1]", + "tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[packaging-26.3]", + "tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[pycparser-3.12]", + "tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[python-discovery-1.6.1]", + "tests/unit/security/test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export[virtualenv-21.14.12]", + "tests/unit/security/test_release_dependency_selection.py::test_public_declarations_require_reviewed_security_floor[gitpython-floor]", + "tests/unit/security/test_release_dependency_selection.py::test_public_declarations_require_reviewed_security_floor[pycparser-floor]", + "tests/unit/scripts/test_dependency_trust_review.py::test_pycparser_has_current_exact_version_review_record", + "tests/unit/scripts/test_security_audit_gate.py::test_committed_otel_exception_is_exact_and_time_bounded[exact-cve]", + "tests/unit/scripts/test_security_audit_gate.py::test_committed_otel_exception_is_exact_and_time_bounded[exact-ghsa]", + "tests/unit/scripts/test_security_audit_gate.py::test_committed_otel_exception_is_exact_and_time_bounded[expiry-day]", + "tests/unit/security/test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone[git-metadata]", + "tests/unit/security/test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone[git-short-name]", + "tests/unit/security/test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone[absolute-metadata]", + "tests/unit/scripts/test_dependency_compatibility_workflow.py::test_resolver_selection_excludes_only_frozen_release_cases" + ], + "returncode": 1, + "input_sha256": { + ".github/workflows/pr-orchestrator.yml": "390841809210f700a0e9abaa66fd266847c47d4ead1aeafc038c93c6691e5e5f", + "ci/dependency-trust-exceptions.json": "aed15e900850691789baedbcd7ee6c82cef6a80e8e09bf95ab9ac8f23329a875", + "ci/vulnerability-audit-exceptions.json": "9578e8e0e66a82c57ab74b53793e55cebe076697002f0ac753b5797f14af57c9", + "openspec/changes/security-dependencies-0-55-11-dev/requirements-evidence.yaml": "ab2f82f599653b227ecacc3aee60eff02c99f78ba79e6fefa0ea4b080ad96aa8", + "pyproject.toml": "e8501d1a393ed195a2a121a6e7c48ce47f59dfba39cd53bd1f6c5b11e2af9238", + "requirements/ci/locked.txt": "0bf003b492508a171177f2ba386126b76200262e7e49c3d1a25e1c8139250266", + "setup.py": "e151a99bf9e5580d69a8587095d023a60ff1d528c9478369f0756455b87bb2f3", + "tests/unit/scripts/test_dependency_compatibility_workflow.py": "416e367877990ebb2d809e11847c363cd599c32f32d014a818f09053d5fab7fa", + "tests/unit/scripts/test_dependency_trust_review.py": "c2566b2dd92c68d2756177972fcaa6762267292b96b69f7a27af028cc2531fd8", + "tests/unit/scripts/test_security_audit_gate.py": "2c856244aaf8c52b491968ce1e51f1fdab0c07dddab8b10c4d8cc293a133d0ef", + "tests/unit/security/test_frozen_gitpython_compatibility.py": "c1d428aba630c5f4abd4f236c10540632fb86e9c0e93ea1a9ba91809dbe415af", + "tests/unit/security/test_release_dependency_selection.py": "f713acdfeba8235bd5f7e2c22b7a29585d61693798972b6e9a24a31423e7a4e6", + "uv.lock": "850b941d7d7dabf102d39ce92fb23d7586d183384fac03c140ceae0a07d169b7" + }, + "inputs_unchanged": true +} diff --git a/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/local-red-behavior-20261026.xml.gz b/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/local-red-behavior-20261026.xml.gz new file mode 100644 index 00000000..03851551 Binary files /dev/null and b/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/local-red-behavior-20261026.xml.gz differ diff --git a/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/review-evidence.json b/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/review-evidence.json new file mode 100644 index 00000000..67c863ce --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/requirements-proof/review-evidence.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "decision": "accepted", + "reviewer_id": "Codex:/root/current_mapping_acceptance", + "reviewer_role": "independent AI technical static SPEC/TEST/MAPPING reviewer only", + "recorded_at": "2026-10-29T20:54:51.528173+00:00", + "reference": "https://github.com/nold-ai/specfact-cli/issues/780", + "mapping_digest": "sha256:61afa8becd7fed3cbe92a273c955c2e8ba389583f62f0e519c97cafafa7d4307" +} diff --git a/openspec/changes/security-dependencies-0-55-11-dev/specs/dev-security-dependencies/spec.md b/openspec/changes/security-dependencies-0-55-11-dev/specs/dev-security-dependencies/spec.md new file mode 100644 index 00000000..3abdc245 --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/specs/dev-security-dependencies/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: Select reviewed official patch versions + +The frozen graph and CI export SHALL select official GitPython 3.2.1, +packaging 26.3, pycparser 3.12, python-discovery 1.6.1, virtualenv 21.14.13, +filelock 4.2.1, msgpack 1.2.3, setuptools 84.1.0 and Semgrep 1.182.1. +Every selected artifact SHALL remain hash-bound to the official registry. + +#### Scenario: Each missing package update reaches frozen delivery + +- **GIVEN** the reviewed package/version table +- **WHEN** the lock and CI export are inspected +- **THEN** each contains exactly the reviewed official version and equal + nonempty SHA-256 artifact sets; missing or substituted export hashes fail. + +### Requirement: Declare the corrected public dependency floors + +Both public dependency declarations SHALL require GitPython >=3.2.1 and +pycparser >=3.12,!=3.0.* while preserving all unrelated requirements. + +#### Scenario: Both declarations reject the superseded vulnerable selections + +- **GIVEN** pyproject.toml and setup.py +- **WHEN** their dependency requirements are read +- **THEN** both expose the corrected GitPython and parser floors. + +### Requirement: Replace expired parser provenance with exact reviewed bytes + +The parser review SHALL bind official pycparser 3.12 wheel SHA-256 +`a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e`, +reviewed 22 October 2026 UTC and expiring 4 November 2026 inclusive. The +expired 2.22 review SHALL NOT be renewed. Blocked 3.0 aliases, mismatched +artifacts and dates outside the review window SHALL remain refused. + +#### Scenario: Bootstrap validates the reviewed parser record + +- **GIVEN** the exact committed parser record and matching frozen artifacts +- **WHEN** the standard-library trust checker evaluates them +- **THEN** it accepts only the reviewed version, source, digest and dates. + +### Requirement: Bound the separately approved OpenTelemetry exception + +The existing strict audit gate MAY admit only OpenTelemetry API 1.37.0 +CVE-2026-114603 or its GHSA-xcq7-p3pf-p88h alias from 22 October through +4 November 2026 inclusive, under the separately approved mitigation. Ordinary +local-rule CLI scanning SHALL NOT expose an instrumented HTTP service to +untrusted propagation headers. The MCP record SHALL remain separate. Other +packages, versions, advisories, skipped subjects and invalid dates SHALL fail. + +#### Scenario: Exact OpenTelemetry finding passes within the approval window + +- **GIVEN** the distinct committed OpenTelemetry record +- **WHEN** the audit gate sees either exact advisory ID on an authorized date +- **THEN** only that finding is visibly admitted and other checks remain strict. + +### Requirement: Reject submodule creation inside Git metadata + +The frozen GitPython API SHALL reject `.git/hooks`, `GIT~1/hooks` and absolute +metadata paths before cloning or modifying Git metadata. Ordinary `vendor` +and `.github` checkouts SHALL still reach the clone boundary. + +#### Scenario: Metadata checkout refuses before side effects + +- **GIVEN** the exact frozen GitPython and its hash-pinned dependency closure +- **WHEN** create_submodule receives a metadata checkout path +- **THEN** it raises ValueError before cloning, leaves hooks unchanged and + creates neither .gitmodules nor submodule metadata. + +### Requirement: Keep frozen assertions out of advisory resolver lanes + +The scheduled advisory latest/lower-bound resolver lanes SHALL add exactly +the seven reviewed frozen-release deselections to their two existing file +exclusions. Both lanes SHALL use identical selection, retaining every other +ordinary case in the exercised test files. Blocking jobs SHALL retain their +complete frozen test selection. + +#### Scenario: Both advisory resolutions preserve the intended selection + +- **GIVEN** both scheduled resolver commands and the actual test inventory +- **WHEN** their selected cases are collected +- **THEN** both use exactly the two existing file exclusions and seven added + selectors, retain all other cases in the exercised files and leave every + blocking job unchanged. + +### Requirement: Preserve strict audits with exact bounded residual risk + +All three strict frozen audits SHALL remain enforced, including setuptools +84.1.0 in the affected graphs and Pylint 4.0.9 in the isolated Code Review +input and hash-bound lock. Only the exact reviewed package, version and +advisory identities MAY be admitted within their existing dates. The original +MCP 1.29.0 record and the separate OpenTelemetry 1.37.0 record SHALL remain +separate from the MCP 1.29.0 record reviewed on 29 October 2026 for +CVE-2026-116685 / GHSA-5h93-6whr-6q8j, CVE-2026-116686 / +GHSA-rwrf-2pqf-9j8j and CVE-2026-116684 / GHSA-w4fh-qvv9-3v23. +All records SHALL expire on 4 November 2026 inclusive. This is accepted +residual risk while the official Semgrep 1.182.1 pin remains incompatible +with patched MCP, not remediation or a general waiver. The documented CLI-only +mitigations SHALL remain in force. + +#### Scenario: Unreviewed or expired findings remain blocking + +- **GIVEN** the three strict audit reports and the exact three review records +- **WHEN** a finding has another package, version or advisory, or the record + is expired, or an audit subject is skipped +- **THEN** the audit fails without widening an exception or omitting a subject. diff --git a/openspec/changes/security-dependencies-0-55-11-dev/tasks.md b/openspec/changes/security-dependencies-0-55-11-dev/tasks.md new file mode 100644 index 00000000..f8757615 --- /dev/null +++ b/openspec/changes/security-dependencies-0-55-11-dev/tasks.md @@ -0,0 +1,25 @@ +# Tasks + +- [x] Reconcile current dev with the complete reviewed release patch. +- [x] Specify the additive dev scope without modifying delivered evidence. +- [ ] Add the mapped failing tests and passing preservation controls. +- [ ] Review the mapping and capture genuine failing-before results. +- [ ] Publish a CI-signed test-only draft PR to dev through normal hooks. +- [ ] Resolve the native bootstrap prerequisite through its legitimate review + and approval boundary; obtain accepted native failing-first evidence. +- [ ] Apply the reviewed updates, including setuptools 84.1.0, Semgrep 1.182.1, + virtualenv 21.14.13, filelock 4.2.1 and msgpack 1.2.3, two floors and + three exact scoped advisory records without extending their expiry. +- [ ] Correct the separate Code Review input and lock to official Pylint 4.0.9, + keeping its six compatible transitive records unchanged. +- [ ] Regenerate and verify the frozen graph/export normally, including equality + of each reviewed package's nonempty artifact SHA-256 sets. +- [ ] Correct scheduled advisory selection after genuine failing evidence. +- [ ] Run focused/full verification, all three audits and independent review. +- [ ] Run normal hooks and CI-only signing for the production candidate. +- [ ] Obtain fresh exact-head authority and all native evidence transfers. +- [ ] Verify every required check and actual forge readiness without merging. +- [ ] Mirror this new dev scope in the internal wiki and rebuild its graph + within explicitly authorized sibling-write scope. +- [ ] Reconcile the main release only after verified dev replacement coverage. +- [ ] Archive with OpenSpec only after completion and merge. diff --git a/tests/unit/scripts/test_dependency_compatibility_workflow.py b/tests/unit/scripts/test_dependency_compatibility_workflow.py new file mode 100644 index 00000000..09875b3d --- /dev/null +++ b/tests/unit/scripts/test_dependency_compatibility_workflow.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import re +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any, cast + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[3] +SECURITY_TESTS = "tests/unit/security/" +FROZEN_RELEASE_SELECTORS = ( + SECURITY_TESTS + "test_release_dependency_selection.py::test_reviewed_tooling_versions_match_frozen_export", + SECURITY_TESTS + + "test_release_promotion_security_gates.py::test_frozen_graph_uses_official_semgrep_with_bounded_mcp_exception", + SECURITY_TESTS + "test_frozen_dependency_artifacts.py::test_frozen_certifi_bundle_excludes_compromised_root", + SECURITY_TESTS + "test_frozen_dependency_artifacts.py::test_artifact_download_rejects_wrong_sha256", + SECURITY_TESTS + "test_frozen_jwt_compatibility.py::test_inspection_preserves_options_and_expiry_on_reuse", + SECURITY_TESTS + + "test_frozen_gitpython_compatibility.py::test_submodule_update_rejects_outside_checkout_before_clone", + SECURITY_TESTS + "test_frozen_gitpython_compatibility.py::test_submodule_creation_validates_checkout_before_clone", +) + + +def _workflow_jobs() -> dict[str, dict[str, Any]]: + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/pr-orchestrator.yml").read_text(encoding="utf-8")) + return cast(dict[str, dict[str, Any]], workflow["jobs"]) + + +def _resolver_command() -> str: + steps = cast(list[dict[str, Any]], _workflow_jobs()["dependency-compatibility"]["steps"]) + return str( + next(step["run"] for step in steps if step.get("name") == "Exercise latest and lower-bound resolver lanes") + ) + + +def test_resolver_filters_are_shared_by_both_advisory_lanes() -> None: + job = _workflow_jobs()["dependency-compatibility"] + assert job["if"] == "github.event_name == 'schedule'" + assert job["continue-on-error"] is True + commands = [shlex.split(line) for line in _resolver_command().splitlines()] + pytest_command = ["python", "-m", "pytest", "-q", "${compatibility_deselections[@]}", "tests/unit"] + resolver_start = commands.index(["uv", "lock", "--upgrade"]) + assert commands[resolver_start:] == [ + ["uv", "lock", "--upgrade"], + ["uv", "sync", "--locked", "--all-extras"], + pytest_command, + ["uv", "lock", "--resolution", "lowest-direct"], + ["uv", "sync", "--locked", "--all-extras", "--resolution", "lowest-direct"], + pytest_command, + ] + + +def test_frozen_release_filters_do_not_leak_into_blocking_jobs() -> None: + for name, job in _workflow_jobs().items(): + if name == "dependency-compatibility": + continue + source = yaml.safe_dump(job) + assert "compatibility_deselections" not in source, name + assert all(f"--deselect={selector}" not in source for selector in FROZEN_RELEASE_SELECTORS), name + + +def _collect_release_tests(deselections: list[str]) -> set[str]: + test_files = sorted({selector.split("::")[0] for selector in FROZEN_RELEASE_SELECTORS}) + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "--collect-only", + "--override-ini", + "addopts=", + "-q", + "-p", + "no:cacheprovider", + *deselections, + *test_files, + "tests/unit/scripts/test_security_audit_gate.py", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + return {line for line in result.stdout.splitlines() if line.startswith("tests/") and "::" in line} + + +def test_resolver_selection_excludes_only_frozen_release_cases() -> None: + match = re.search(r"compatibility_deselections=\((.*?)\)", _resolver_command(), re.DOTALL) + assert match is not None + deselections = shlex.split(match.group(1)) + assert len(deselections) == len(FROZEN_RELEASE_SELECTORS) + 2 + assert set(deselections) == { + "--deselect=tests/unit/scripts/test_dependency_trust_review.py", + "--deselect=tests/unit/scripts/test_reproducible_delivery.py", + *(f"--deselect={selector}" for selector in FROZEN_RELEASE_SELECTORS), + } + baseline = _collect_release_tests([]) + selected = _collect_release_tests(deselections) + frozen_cases = {node for node in baseline if node.split("[")[0] in FROZEN_RELEASE_SELECTORS} + assert {node.split("[")[0] for node in frozen_cases} == set(FROZEN_RELEASE_SELECTORS) + ordinary_cases = baseline - frozen_cases + assert ordinary_cases + assert selected == ordinary_cases, ( + f"Frozen cases still selected: {sorted(selected & frozen_cases)}; " + f"ordinary cases lost: {sorted(ordinary_cases - selected)}" + ) diff --git a/tests/unit/scripts/test_dependency_trust_review.py b/tests/unit/scripts/test_dependency_trust_review.py index 95987975..76e77e78 100644 --- a/tests/unit/scripts/test_dependency_trust_review.py +++ b/tests/unit/scripts/test_dependency_trust_review.py @@ -8,6 +8,9 @@ import sys from datetime import date from pathlib import Path +from types import ModuleType + +import pytest REPO_ROOT = Path(__file__).resolve().parents[3] @@ -15,7 +18,7 @@ CHECKER = REPO_ROOT / "scripts" / "check_dependency_trust_exceptions.py" -def _load_checker(): +def _load_checker() -> ModuleType: spec = importlib.util.spec_from_file_location("check_dependency_trust_exceptions", CHECKER) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) @@ -43,19 +46,22 @@ def test_pycparser_has_current_exact_version_review_record() -> None: payload = json.loads(TRUST_RECORD.read_text(encoding="utf-8")) records = payload["exceptions"] pycparser = next(record for record in records if record["package"] == "pycparser") - assert pycparser["version"] == "2.22" - assert pycparser["source_url"].startswith("https://files.pythonhosted.org/") - assert pycparser["artifact_sha256"] == "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" + assert pycparser["version"] == "3.12" + assert pycparser["source_url"] == ( + "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/" + "pycparser-3.12-py3-none-any.whl" + ) + assert pycparser["artifact_sha256"] == "a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e" assert pycparser["classification"] == "source-provenance-reviewed" - assert pycparser["expires_on"] >= "2026-07-24" - assert "cryptography" in pycparser["transitive_path"] + assert pycparser["reviewed_on"] == "2026-10-22" + assert pycparser["expires_on"] == "2026-11-04" + assert pycparser["transitive_path"] == "specfact-cli -> cryptography -> cffi -> pycparser" -def test_alerted_pycparser_release_is_blocked_even_with_a_review_record(tmp_path: Path) -> None: - """A Socket-alerted release cannot be normalized through an exception record.""" - record_path = tmp_path / "exceptions.json" - record_path.write_text( - json.dumps( +@pytest.mark.parametrize( + "payload, today, expected_errors", + [ + pytest.param( { "exceptions": [ { @@ -70,15 +76,63 @@ def test_alerted_pycparser_release_is_blocked_even_with_a_review_record(tmp_path "rationale": "test fixture", } ] - } + }, + date(2026, 7, 24), + ["pycparser==3.0 is blocked after a security-obfuscation alert"], + id="blocked-pycparser-3.0", ), - encoding="utf-8", - ) + pytest.param( + { + "exceptions": [ + { + "package": "pycparser", + "version": "2.22", + "source_url": "https://files.pythonhosted.org/example.whl", + "artifact_sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", + "classification": "source-provenance-reviewed", + "reviewed_on": "2026-07-24", + "expires_on": "2026-07-23", + "transitive_path": "specfact-cli -> cryptography -> cffi -> pycparser", + "rationale": "test fixture", + } + ] + }, + date(2026, 7, 24), + ["pycparser==2.22 expired on 2026-07-23"], + id="expired-review-record", + ), + pytest.param( + { + "exceptions": [ + { + "package": "NodeJS_Wheel.Binaries", + "version": "1.0.0", + "source_url": "https://files.pythonhosted.org/example.whl", + "artifact_sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", + "classification": "source-provenance-reviewed", + "reviewed_on": "2026-07-23", + "expires_on": "2026-10-22", + "transitive_path": "specfact-cli -> test fixture", + "rationale": "test fixture", + } + ] + }, + date(2026, 7, 24), + ["nodejs-wheel-binaries==1.0.0 is prohibited from the dependency trust register"], + id="prohibited-pep503-name", + ), + ], +) +def test_invalid_trust_register_is_rejected( + tmp_path: Path, payload: dict[str, list[dict[str, str]]], today: date, expected_errors: list[str] +) -> None: + record_path = tmp_path / "exceptions.json" + record_path.write_text(json.dumps(payload), encoding="utf-8") checker = _load_checker() - errors = checker.validate_exception_register(record_path, today=date(2026, 7, 24)) + errors = checker.validate_exception_register(record_path, today=today) - assert errors == ["pycparser==3.0 is blocked after a security-obfuscation alert"] + assert errors == expected_errors def test_alerted_pycparser_release_family_is_blocked_with_a_pep440_spelling(tmp_path: Path) -> None: @@ -109,47 +163,18 @@ def test_unofficial_executable_wheel_is_not_accepted() -> None: assert all(record["package"] != "nodejs-wheel-binaries" for record in payload["exceptions"]) -def test_expired_exception_fails_closed(tmp_path: Path) -> None: - """Expiry is enforced by CI rather than merely documented in static policy.""" - record_path = tmp_path / "exceptions.json" - record_path.write_text( - json.dumps( - { - "exceptions": [ - { - "package": "pycparser", - "version": "2.22", - "source_url": "https://files.pythonhosted.org/example.whl", - "artifact_sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", - "classification": "source-provenance-reviewed", - "reviewed_on": "2026-07-24", - "expires_on": "2026-07-23", - "transitive_path": "specfact-cli -> cryptography -> cffi -> pycparser", - "rationale": "test fixture", - } - ] - } - ), - encoding="utf-8", - ) - checker = _load_checker() - - errors = checker.validate_exception_register(record_path, today=date(2026, 7, 24)) - - assert errors == ["pycparser==2.22 expired on 2026-07-23"] - - def test_reviewed_artifact_must_exist_in_the_frozen_lock(tmp_path: Path) -> None: """A copied review record cannot approve a different frozen artifact.""" payload = json.loads(TRUST_RECORD.read_text(encoding="utf-8")) - payload["exceptions"][0]["artifact_sha256"] = "0" * 64 + reviewed = payload["exceptions"][0] + reviewed["artifact_sha256"] = "0" * 64 register_path = tmp_path / "exceptions.json" register_path.write_text(json.dumps(payload), encoding="utf-8") checker = _load_checker() - errors = checker.validate_frozen_dependency_policy(register_path, today=date(2026, 7, 24)) + errors = checker.validate_frozen_dependency_policy(register_path, today=date.fromisoformat(reviewed["reviewed_on"])) - assert errors == ["pycparser==2.22 artifact digest is absent from its frozen lock record"] + assert errors == [f"pycparser=={reviewed['version']} artifact digest is absent from its frozen lock record"] def test_review_evidence_must_bind_to_the_matching_lock_package(tmp_path: Path) -> None: @@ -164,7 +189,7 @@ def test_review_evidence_must_bind_to_the_matching_lock_package(tmp_path: Path) [ "[[package]]", 'name = "pycparser"', - 'version = "2.22"', + f'version = "{reviewed["version"]}"', 'sdist = { url = "https://files.pythonhosted.org/other.tar.gz", hash = "sha256:' + "0" * 64 + '" }', "", "[[package]]", @@ -182,68 +207,42 @@ def test_review_evidence_must_bind_to_the_matching_lock_package(tmp_path: Path) ) checker = _load_checker() - errors = checker.validate_frozen_dependency_policy(register_path, lock_path, today=date(2026, 7, 24)) + errors = checker.validate_frozen_dependency_policy( + register_path, lock_path, today=date.fromisoformat(reviewed["reviewed_on"]) + ) assert errors == [ - "pycparser==2.22 source artifact is absent from its frozen lock record", - "pycparser==2.22 artifact digest is absent from its frozen lock record", + f"pycparser=={reviewed['version']} source artifact is absent from its frozen lock record", + f"pycparser=={reviewed['version']} artifact digest is absent from its frozen lock record", ] -def test_prohibited_executable_wheel_name_is_pep503_normalized(tmp_path: Path) -> None: - """Separator and case variants cannot evade the executable-wheel denylist.""" - record_path = tmp_path / "exceptions.json" - record_path.write_text( - json.dumps( - { - "exceptions": [ - { - "package": "NodeJS_Wheel.Binaries", - "version": "1.0.0", - "source_url": "https://files.pythonhosted.org/example.whl", - "artifact_sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", - "classification": "source-provenance-reviewed", - "reviewed_on": "2026-07-23", - "expires_on": "2026-10-22", - "transitive_path": "specfact-cli -> test fixture", - "rationale": "test fixture", - } - ] - } +@pytest.mark.parametrize( + "lock_text, today, expected_errors", + [ + pytest.param( + '\n[[package]]\nname = "NodeJS_Wheel.Binaries"\nversion = "1.0.0"\n', + date(2026, 7, 24), + ["nodejs-wheel-binaries==1.0.0 is prohibited in the frozen lock"], + id="prohibited-executable-wheel", ), - encoding="utf-8", - ) - checker = _load_checker() - - errors = checker.validate_exception_register(record_path, today=date(2026, 7, 24)) - - assert errors == ["nodejs-wheel-binaries==1.0.0 is prohibited from the dependency trust register"] - - -def test_prohibited_executable_wheel_in_lock_fails_without_an_exception(tmp_path: Path) -> None: - """The lock itself is the enforcement boundary for executable wheel runtimes.""" - register_path = tmp_path / "exceptions.json" - register_path.write_text('{"exceptions": []}', encoding="utf-8") - lock_path = tmp_path / "uv.lock" - lock_path.write_text( - '\n[[package]]\nname = "NodeJS_Wheel.Binaries"\nversion = "1.0.0"\n', - encoding="utf-8", - ) - checker = _load_checker() - - errors = checker.validate_frozen_dependency_policy(register_path, lock_path, today=date(2026, 7, 24)) - - assert errors == ["nodejs-wheel-binaries==1.0.0 is prohibited in the frozen lock"] - - -def test_semgrep_lock_below_reviewed_security_floor_fails_before_install(tmp_path: Path) -> None: - """A lock downgrade cannot silently restore the reported vulnerable Semgrep line.""" + pytest.param( + '\n[[package]]\nname = "semgrep"\nversion = "1.70.1"\n', + date(2026, 7, 24), + ["semgrep==1.70.1 is below the reviewed security floor 1.175.0"], + id="semgrep-below-security-floor", + ), + ], +) +def test_invalid_frozen_lock_is_rejected( + tmp_path: Path, lock_text: str, today: date, expected_errors: list[str] +) -> None: register_path = tmp_path / "exceptions.json" register_path.write_text('{"exceptions": []}', encoding="utf-8") lock_path = tmp_path / "uv.lock" - lock_path.write_text('\n[[package]]\nname = "semgrep"\nversion = "1.70.1"\n', encoding="utf-8") + lock_path.write_text(lock_text, encoding="utf-8") checker = _load_checker() - errors = checker.validate_frozen_dependency_policy(register_path, lock_path, today=date(2026, 7, 24)) + errors = checker.validate_frozen_dependency_policy(register_path, lock_path, today=today) - assert errors == ["semgrep==1.70.1 is below the reviewed security floor 1.175.0"] + assert errors == expected_errors diff --git a/tests/unit/scripts/test_security_audit_gate.py b/tests/unit/scripts/test_security_audit_gate.py index 19c08cdb..a12cb08c 100644 --- a/tests/unit/scripts/test_security_audit_gate.py +++ b/tests/unit/scripts/test_security_audit_gate.py @@ -13,7 +13,7 @@ import pytest -def _load_gate_mod(): +def _load_gate_mod() -> ModuleType: root = Path(__file__).resolve().parents[3] path = root / "scripts" / "security_audit_gate.py" assert path.is_file(), path @@ -25,17 +25,17 @@ def _load_gate_mod(): @pytest.fixture -def gate_mod(): +def gate_mod() -> ModuleType: return _load_gate_mod() -def test_cvss_for_vuln_reads_numeric_fields(gate_mod) -> None: +def test_cvss_for_vuln_reads_numeric_fields(gate_mod: ModuleType) -> None: """Nested CVSS-like keys should contribute to the max score.""" vuln = {"id": "TEST-1", "severity": {"score": 8.2}} assert gate_mod._cvss_for_vuln(vuln) == pytest.approx(8.2) -def test_main_passes_when_no_vulnerabilities(gate_mod, capsys) -> None: +def test_main_passes_when_no_vulnerabilities(gate_mod: ModuleType, capsys: pytest.CaptureFixture[str]) -> None: payload = {"dependencies": [{"name": "requests", "version": "2.0.0", "vulns": []}]} proc = MagicMock(stdout=json.dumps(payload), stderr="", returncode=0) with patch.object(gate_mod.subprocess, "run", return_value=proc): @@ -43,7 +43,7 @@ def test_main_passes_when_no_vulnerabilities(gate_mod, capsys) -> None: assert "passed" in capsys.readouterr().out.lower() -def test_main_warns_when_cvss_below_threshold(gate_mod, capsys) -> None: +def test_main_warns_when_cvss_below_threshold(gate_mod: ModuleType, capsys: pytest.CaptureFixture[str]) -> None: payload = { "dependencies": [ { @@ -60,7 +60,7 @@ def test_main_warns_when_cvss_below_threshold(gate_mod, capsys) -> None: assert "ACTION REQUIRED" in out -def test_main_fails_when_cvss_at_or_above_threshold(gate_mod, capsys) -> None: +def test_main_fails_when_cvss_at_or_above_threshold(gate_mod: ModuleType, capsys: pytest.CaptureFixture[str]) -> None: payload = { "dependencies": [ { @@ -76,13 +76,15 @@ def test_main_fails_when_cvss_at_or_above_threshold(gate_mod, capsys) -> None: assert "ACTION REQUIRED" in capsys.readouterr().out -def test_main_fail_closed_on_empty_stdout(gate_mod) -> None: +def test_main_fail_closed_on_empty_stdout(gate_mod: ModuleType) -> None: proc = MagicMock(stdout="", stderr="pip-audit failed\n", returncode=2) with patch.object(gate_mod.subprocess, "run", return_value=proc): assert gate_mod.main() == 1 -def test_main_fails_when_strict_audit_skips_a_dependency(gate_mod, capsys) -> None: +def test_main_fails_when_strict_audit_skips_a_dependency( + gate_mod: ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: """A strict audit cannot report success when any dependency was skipped.""" payload = {"dependencies": [{"name": "opaque", "skip_reason": "could not audit"}]} proc = MagicMock(stdout=json.dumps(payload), stderr="", returncode=1) @@ -91,7 +93,9 @@ def test_main_fails_when_strict_audit_skips_a_dependency(gate_mod, capsys) -> No assert "skipped dependency" in capsys.readouterr().err.lower() -def test_main_fails_when_strict_audit_returns_an_unexplained_error(gate_mod, capsys) -> None: +def test_main_fails_when_strict_audit_returns_an_unexplained_error( + gate_mod: ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: """A nonzero strict-audit status without advisories is an audit failure.""" proc = MagicMock(stdout=json.dumps({"dependencies": []}), stderr="resolver failed", returncode=1) with patch.object(gate_mod.subprocess, "run", return_value=proc): @@ -99,12 +103,12 @@ def test_main_fails_when_strict_audit_returns_an_unexplained_error(gate_mod, cap assert "strict audit failed" in capsys.readouterr().err.lower() -def test_main_fail_closed_when_pip_audit_unavailable(gate_mod) -> None: +def test_main_fail_closed_when_pip_audit_unavailable(gate_mod: ModuleType) -> None: with patch.object(gate_mod.subprocess, "run", side_effect=FileNotFoundError("pip-audit not found")): assert gate_mod.main() == 1 -def test_dependencies_from_pip_audit_json_accepts_wrapped_and_bare_list(gate_mod) -> None: +def test_dependencies_from_pip_audit_json_accepts_wrapped_and_bare_list(gate_mod: ModuleType) -> None: wrapped = {"dependencies": [{"name": "a", "version": "1", "vulns": []}], "fixes": []} assert gate_mod._dependencies_from_pip_audit_json(wrapped) == wrapped["dependencies"] bare = [{"name": "b", "version": "2", "vulns": []}] @@ -113,7 +117,7 @@ def test_dependencies_from_pip_audit_json_accepts_wrapped_and_bare_list(gate_mod assert gate_mod._dependencies_from_pip_audit_json({"dependencies": "nope"}) is None -def test_main_passes_with_top_level_list_json(gate_mod, capsys) -> None: +def test_main_passes_with_top_level_list_json(gate_mod: ModuleType, capsys: pytest.CaptureFixture[str]) -> None: payload = [{"name": "requests", "version": "2.0.0", "vulns": []}] proc = MagicMock(stdout=json.dumps(payload), stderr="", returncode=0) with patch.object(gate_mod.subprocess, "run", return_value=proc): @@ -121,7 +125,7 @@ def test_main_passes_with_top_level_list_json(gate_mod, capsys) -> None: assert "passed" in capsys.readouterr().out.lower() -def test_main_audits_the_frozen_requirements_file_in_strict_mode(gate_mod) -> None: +def test_main_audits_the_frozen_requirements_file_in_strict_mode(gate_mod: ModuleType) -> None: payload = {"dependencies": [{"name": "requests", "version": "2.0.0", "vulns": []}]} proc = MagicMock(stdout=json.dumps(payload), stderr="", returncode=0) with patch.object(gate_mod.subprocess, "run", return_value=proc) as run_mock: @@ -134,12 +138,14 @@ def test_main_audits_the_frozen_requirements_file_in_strict_mode(gate_mod) -> No assert "--skip-editable" not in cmd -def test_main_rejects_an_unexpected_argument(gate_mod) -> None: +def test_main_rejects_an_unexpected_argument(gate_mod: ModuleType) -> None: with pytest.raises(SystemExit, match="2"): gate_mod.main(["--unknown"]) -def test_main_allows_only_a_matching_unexpired_reviewed_exception(gate_mod, tmp_path: Path, capsys) -> None: +def test_main_allows_only_a_matching_unexpired_reviewed_exception( + gate_mod: ModuleType, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: """A non-upgradable transitive advisory needs an exact, short-lived review record.""" payload = { "dependencies": [ @@ -184,6 +190,90 @@ class McpExceptionScenario(NamedTuple): message: str +class OtelExceptionScenario(NamedTuple): + version: str + advisory_ids: list[str] + today: str + expected: int + message: str + package: str = "opentelemetry-api" + skip_reason: str | None = None + omit_entry: bool = False + + +@pytest.mark.parametrize( + "scenario", + [ + pytest.param(OtelExceptionScenario("1.37.0", ["CVE-2026-114603"], "2026-10-22", 0, "WAIVED"), id="exact-cve"), + pytest.param( + OtelExceptionScenario("1.37.0", ["GHSA-xcq7-p3pf-p88h"], "2026-10-22", 0, "WAIVED"), id="exact-ghsa" + ), + pytest.param(OtelExceptionScenario("1.37.0", ["CVE-2026-114603"], "2026-11-04", 0, "WAIVED"), id="expiry-day"), + pytest.param( + OtelExceptionScenario("1.38.0", ["CVE-2026-114603"], "2026-10-22", 1, "FAIL:"), id="other-version" + ), + pytest.param( + OtelExceptionScenario("1.37.0", ["CVE-2026-114603"], "2026-10-22", 1, "FAIL:", package="other-package"), + id="other-package", + ), + pytest.param( + OtelExceptionScenario("1.37.0", ["UNRELATED-1"], "2026-10-22", 1, "FAIL:"), id="unrelated-advisory" + ), + pytest.param( + OtelExceptionScenario("1.37.0", ["CVE-2026-114603", "UNRELATED-1"], "2026-10-22", 1, "FAIL:"), + id="mixed-advisories", + ), + pytest.param( + OtelExceptionScenario( + "1.37.0", ["CVE-2026-114603"], "2026-10-22", 1, "skipped dependency", skip_reason="could not audit" + ), + id="skipped", + ), + pytest.param( + OtelExceptionScenario("1.37.0", ["CVE-2026-114603"], "2026-11-05", 1, "not currently valid"), id="expired" + ), + pytest.param( + OtelExceptionScenario("1.37.0", ["CVE-2026-114603"], "2026-10-21", 1, "not currently valid"), + id="future-review", + ), + pytest.param( + OtelExceptionScenario("1.37.0", ["CVE-2026-114603"], "2026-10-22", 1, "FAIL:", omit_entry=True), + id="missing-entry", + ), + ], +) +def test_committed_otel_exception_is_exact_and_time_bounded( + gate_mod: ModuleType, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + scenario: OtelExceptionScenario, +) -> None: + class AuditDate(date): + @classmethod + def today(cls) -> date: + return date.fromisoformat(scenario.today) + + dependency: dict[str, object] = { + "name": scenario.package, + "version": scenario.version, + "vulns": [{"id": value} for value in scenario.advisory_ids], + } + if scenario.skip_reason is not None: + dependency["skip_reason"] = scenario.skip_reason + proc = MagicMock(stdout=json.dumps({"dependencies": [dependency]}), stderr="", returncode=1) + committed = Path(__file__).resolve().parents[3] / "ci/vulnerability-audit-exceptions.json" + register = json.loads(committed.read_text(encoding="utf-8")) + register["exceptions"] = [ + entry for entry in register["exceptions"] if entry["package"] == "opentelemetry-api" and not scenario.omit_entry + ] + exceptions = tmp_path / "exceptions.json" + exceptions.write_text(json.dumps(register), encoding="utf-8") + with patch.object(gate_mod, "date", AuditDate), patch.object(gate_mod.subprocess, "run", return_value=proc): + assert gate_mod.main(["--exceptions", str(exceptions)]) == scenario.expected + captured = capsys.readouterr() + assert scenario.message in captured.out + captured.err + + @pytest.mark.parametrize( "scenario", [ @@ -226,6 +316,7 @@ def test_committed_mcp_exception_is_exact_and_time_bounded( gate_mod: ModuleType, capsys: pytest.CaptureFixture[str], scenario: McpExceptionScenario, + tmp_path: Path, ) -> None: class AuditDate(date): @classmethod @@ -240,7 +331,13 @@ def today(cls) -> date: if scenario.skip_reason: dependency["skip_reason"] = scenario.skip_reason proc = MagicMock(stdout=json.dumps({"dependencies": [dependency]}), stderr="", returncode=1) - exceptions = Path(__file__).resolve().parents[3] / "ci/vulnerability-audit-exceptions.json" + committed = Path(__file__).resolve().parents[3] / "ci/vulnerability-audit-exceptions.json" + register = json.loads(committed.read_text(encoding="utf-8")) + register["exceptions"] = [ + entry for entry in register["exceptions"] if entry["package"] == "mcp" and entry["reviewed_on"] == "2026-10-21" + ] + exceptions = tmp_path / "exceptions.json" + exceptions.write_text(json.dumps(register), encoding="utf-8") with patch.object(gate_mod, "date", AuditDate), patch.object(gate_mod.subprocess, "run", return_value=proc): assert gate_mod.main(["--exceptions", str(exceptions)]) == scenario.expected captured = capsys.readouterr() diff --git a/tests/unit/security/test_code_review_dependency_security.py b/tests/unit/security/test_code_review_dependency_security.py new file mode 100644 index 00000000..8c69efcd --- /dev/null +++ b/tests/unit/security/test_code_review_dependency_security.py @@ -0,0 +1,20 @@ +"""Prevent the isolated Code Review environment from restoring vulnerable pylint.""" + +import hashlib +from pathlib import Path + + +def test_code_review_pins_fixed_pylint_with_bound_artifact_hashes() -> None: + """GHSA-22qq-2fwx-7p67 must be fixed in the actual installer inputs.""" + repository_root = Path(__file__).resolve().parents[3] + requirements = repository_root / "requirements" / "code-review" + input_bytes = (requirements / "requirements.in").read_bytes() + assert "pylint==4.0.9" in input_bytes.decode("utf-8") + locked = (requirements / "locked.txt").read_text(encoding="utf-8") + assert f"# input-sha256: {hashlib.sha256(input_bytes).hexdigest()}" in locked.splitlines() + pylint_record = next(record for record in locked.replace("\\\n", "").splitlines() if record.startswith("pylint==")) + assert pylint_record.split()[0] == "pylint==4.0.9" + assert {item.removeprefix("--hash=sha256:") for item in pylint_record.split()[1:]} == { + "c4ba5bbaf1e70ecf05f08cd7d6091ea04824b79a2b2d9f5b10dc25fc34cb1124", + "6f1305780229ae720e89b8aff2b3ff857cbb268fbab98c8b586960267667ad34", + } diff --git a/tests/unit/security/test_frozen_gitpython_compatibility.py b/tests/unit/security/test_frozen_gitpython_compatibility.py index 19635578..8be7f32b 100644 --- a/tests/unit/security/test_frozen_gitpython_compatibility.py +++ b/tests/unit/security/test_frozen_gitpython_compatibility.py @@ -5,9 +5,10 @@ import shutil import subprocess import sys -from importlib.metadata import distribution from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[3] FROZEN_WHEEL_INSTALL_FLAGS = ( @@ -45,12 +46,21 @@ def _frozen_gitpython_requirement(tmp_path: Path) -> tuple[Path, str]: locked = (REPO_ROOT / "requirements/ci/locked.txt").read_text(encoding="utf-8") - entries = re.findall(r"(?m)^gitpython==([^\s]+) \\\n((?:[ \t]+--hash=sha256:[0-9a-f]{64}(?: \\)?\n)+)", locked) - assert len(entries) == 1, "Expected one hash-pinned GitPython delivery requirement" - version, hashes = entries[0] + requirements: dict[str, tuple[str, str]] = {} + for package in ("gitdb", "gitpython", "smmap"): + declarations = re.findall(rf"(?m)^{package}(?=[\s=<>!~;\[@]|$)", locked) + assert len(declarations) == 1, f"Expected one {package} delivery requirement" + entries = re.findall( + rf"(?m)^{package}==([^\s]+) \\\n((?:[ \t]+--hash=sha256:[0-9a-f]{{64}}(?: \\)?\n)+)", locked + ) + assert len(entries) == 1, f"Expected one hash-pinned {package} delivery requirement" + requirements[package] = entries[0] requirement = tmp_path / "gitpython.txt" - requirement.write_text(f"gitpython=={version} \\\n{hashes}", encoding="utf-8") - return requirement, version + requirement.write_text( + "".join(f"{package}=={version} \\\n{hashes}" for package, (version, hashes) in requirements.items()), + encoding="utf-8", + ) + return requirement, requirements["gitpython"][0] def test_submodule_update_rejects_outside_checkout_before_clone(tmp_path: Path) -> None: @@ -67,8 +77,7 @@ def test_submodule_update_rejects_outside_checkout_before_clone(tmp_path: Path) text=True, timeout=120, ) - dependency_roots = {str(distribution(package).locate_file("")) for package in ("gitdb", "smmap")} - probe_arguments = [str(target), *sorted(dependency_roots), str(tmp_path)] + probe_arguments = [str(target), str(tmp_path)] observed = subprocess.run( [sys.executable, "-I", "-S", "-c", SUBMODULE_PROBE, *probe_arguments], check=False, @@ -82,3 +91,84 @@ def test_submodule_update_rejects_outside_checkout_before_clone(tmp_path: Path) assert result["rejected"] is True assert result["clone_calls"] == 0 assert result["outside_exists"] is False + + +SUBMODULE_ADD_PROBE = """ +import json, sys +from pathlib import Path +from unittest.mock import patch +sys.path.append(sys.argv[1]) +import git +from git.objects.submodule.base import Submodule +workspace = Path(sys.argv[2]) +checkout_path = sys.argv[3] +parent = git.Repo.init(workspace / "parent") +if checkout_path == "absolute-metadata": + checkout_path = str(Path(parent.working_tree_dir) / ".git" / "hooks") +hooks = Path(parent.git_dir) / "hooks" +before_hooks = {str(path.relative_to(hooks)): path.read_bytes().hex() + for path in hooks.rglob("*") if path.is_file()} +class CloneBoundaryReached(Exception): + pass +rejected = False +boundary_reached = False +with patch.object(Submodule, "_clone_repo", side_effect=CloneBoundaryReached) as clone: + try: + parent.create_submodule(name="module", path=checkout_path, url="unused") + except ValueError: + rejected = True + except CloneBoundaryReached: + boundary_reached = True + clone_calls = clone.call_count +after_hooks = {str(path.relative_to(hooks)): path.read_bytes().hex() + for path in hooks.rglob("*") if path.is_file()} +print(json.dumps({"version": git.__version__, "rejected": rejected, + "boundary_reached": boundary_reached, "clone_calls": clone_calls, + "hooks_unchanged": before_hooks == after_hooks, + "gitmodules_exists": (Path(parent.working_tree_dir) / ".gitmodules").exists(), + "module_metadata_exists": (Path(parent.git_dir) / "modules").exists()})) +""" + + +@pytest.mark.parametrize( + ("checkout_path", "expected_rejection"), + [ + pytest.param(".git/hooks", True, id="git-metadata"), + pytest.param("GIT~1/hooks", True, id="git-short-name"), + pytest.param("absolute-metadata", True, id="absolute-metadata"), + pytest.param("vendor/module", False, id="ordinary-checkout"), + pytest.param(".github/module", False, id="github-checkout"), + ], +) +def test_submodule_creation_validates_checkout_before_clone( + tmp_path: Path, checkout_path: str, expected_rejection: bool +) -> None: + requirement, version = _frozen_gitpython_requirement(tmp_path) + uv = shutil.which("uv") + assert uv is not None, "The frozen delivery probe requires uv" + target = tmp_path / "delivery-package" + subprocess.run( + [uv, *FROZEN_WHEEL_INSTALL_FLAGS, "--target", str(target), "-r", str(requirement)], + check=True, + capture_output=True, + text=True, + timeout=120, + ) + observed = subprocess.run( + [sys.executable, "-I", "-S", "-c", SUBMODULE_ADD_PROBE, str(target), str(tmp_path), checkout_path], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + assert observed.returncode == 0, observed.stderr + result = json.loads(observed.stdout) + assert result == { + "version": version, + "rejected": expected_rejection, + "boundary_reached": not expected_rejection, + "clone_calls": 0 if expected_rejection else 1, + "hooks_unchanged": True, + "gitmodules_exists": False, + "module_metadata_exists": False, + } diff --git a/tests/unit/security/test_release_dependency_selection.py b/tests/unit/security/test_release_dependency_selection.py new file mode 100644 index 00000000..ce0f9264 --- /dev/null +++ b/tests/unit/security/test_release_dependency_selection.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import ast +import re +import tomllib +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize( + ("name", "version"), + [ + ("certifi", "2026.10.19"), + ("gitpython", "3.2.1"), + ("pyjwt", "2.15.1"), + ("hatchling", "1.32.4"), + ("urllib3", "2.8.0"), + ("virtualenv", "21.14.13"), + ("filelock", "4.2.1"), + ("msgpack", "1.2.3"), + ("setuptools", "84.1.0"), + ("semgrep", "1.182.1"), + ("python-discovery", "1.6.1"), + ("packaging", "26.3"), + ("pycparser", "3.12"), + ], +) +def test_reviewed_tooling_versions_match_frozen_export(name: str, version: str) -> None: + lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + packages = [package for package in lock["package"] if package["name"] == name] + assert [(package["version"], package["source"]) for package in packages] == [ + (version, {"registry": "https://pypi.org/simple"}) + ] + export = (REPO_ROOT / "requirements/ci/locked.txt").read_text(encoding="utf-8") + requirements = re.findall( + rf"^{re.escape(name)}=={re.escape(version)}(?:[ \t][^\n]*|$)", + export.replace("\\\n", ""), + re.MULTILINE, + ) + assert len(requirements) == 1 + artifacts = [packages[0]["sdist"], *packages[0]["wheels"]] + expected_hashes = {artifact["hash"] for artifact in artifacts} + assert expected_hashes + assert all(re.fullmatch(r"sha256:[0-9a-f]{64}", digest) for digest in expected_hashes) + assert set(re.findall(r"--hash=(\S+)", requirements[0])) == expected_hashes + + +@pytest.mark.parametrize( + ("name", "version"), + [("jekyll-feed", "0.18.0"), ("jekyll-redirect-from", "0.17.0"), ("jekyll-relative-links", "0.9.1")], +) +def test_reviewed_documentation_plugins_are_locked(name: str, version: str) -> None: + locked = (REPO_ROOT / "docs/Gemfile.lock").read_text(encoding="utf-8") + assert re.search(rf"^ {re.escape(name)} \({re.escape(version)}\)$", locked, re.MULTILINE) + + +def test_documentation_json_has_reviewed_floor_and_major_bound() -> None: + gemfile = (REPO_ROOT / "docs/Gemfile").read_text(encoding="utf-8") + assert 'gem "json", ">= 2.21.2", "< 3"' in gemfile + + +@pytest.mark.parametrize( + "requirement", + [ + pytest.param("gitpython>=3.2.1", id="gitpython-floor"), + pytest.param("pycparser>=3.12,!=3.0.*", id="pycparser-floor"), + ], +) +def test_public_declarations_require_reviewed_security_floor(requirement: str) -> None: + project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert requirement in project["project"]["dependencies"] + setup = ast.parse((REPO_ROOT / "setup.py").read_text(encoding="utf-8")) + declarations = [ + ast.literal_eval(keyword.value) + for node in ast.walk(setup) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "setup" + for keyword in node.keywords + if keyword.arg == "install_requires" + ] + assert len(declarations) == 1 + assert requirement in declarations[0] diff --git a/tests/unit/security/test_release_promotion_security_gates.py b/tests/unit/security/test_release_promotion_security_gates.py index e1f0aac2..721d3c13 100644 --- a/tests/unit/security/test_release_promotion_security_gates.py +++ b/tests/unit/security/test_release_promotion_security_gates.py @@ -397,33 +397,67 @@ def test_frozen_graph_uses_official_semgrep_with_bounded_mcp_exception() -> None assert {"semgrep>=1.175.0"} <= set(dev) & set(scanning) locked = (REPO_ROOT / "requirements" / "ci" / "locked.txt").read_text(encoding="utf-8") - assert set(re.findall(r"^(?:semgrep|mcp)==[^\s]+", locked, re.MULTILINE)) == {"semgrep==1.179.0", "mcp==1.29.0"} + assert set(re.findall(r"^(?:semgrep|mcp)==[^\s]+", locked, re.MULTILINE)) == {"semgrep==1.182.1", "mcp==1.29.0"} + assert re.search(r"^opentelemetry-api==1\.37\.0[ \t]", locked, re.MULTILINE) policy = json.loads((REPO_ROOT / "ci" / "security-tool-minimum-versions.json").read_text(encoding="utf-8")) - assert policy["minimum_versions"] == {"mcp": "1.28.1", "semgrep": "1.175.0"} exceptions = json.loads((REPO_ROOT / "ci" / "vulnerability-audit-exceptions.json").read_text(encoding="utf-8")) records = exceptions["exceptions"] - assert len(records) == 1, "Only the reviewed MCP advisory may be waived" - record = records[0] - expected_record = { - "package": "mcp", - "version": "1.29.0", - "vulnerability_ids": ["CVE-2026-59951", "GHSA-84m7-p3x7-pcfv"], - "reviewed_on": "2026-10-21", - "expires_on": "2026-11-04", - } - actual_record = {key: record[key] for key in expected_record} - actual_record["vulnerability_ids"] = sorted(record["vulnerability_ids"]) - assert actual_record == expected_record - assert tuple(bool(record[key].strip()) for key in ("rationale", "mitigation")) == (True, True) + assert (policy["minimum_versions"], len(records)) == ({"mcp": "1.28.1", "semgrep": "1.175.0"}, 3) + expected_records = [ + { + "package": "mcp", + "version": "1.29.0", + "vulnerability_ids": ["CVE-2026-59951", "GHSA-84m7-p3x7-pcfv"], + "reviewed_on": "2026-10-21", + "expires_on": "2026-11-04", + }, + { + "package": "mcp", + "version": "1.29.0", + "vulnerability_ids": [ + "CVE-2026-116684", + "CVE-2026-116685", + "CVE-2026-116686", + "GHSA-5h93-6whr-6q8j", + "GHSA-rwrf-2pqf-9j8j", + "GHSA-w4fh-qvv9-3v23", + ], + "reviewed_on": "2026-10-29", + "expires_on": "2026-11-04", + }, + { + "package": "opentelemetry-api", + "version": "1.37.0", + "vulnerability_ids": ["CVE-2026-114603", "GHSA-xcq7-p3pf-p88h"], + "reviewed_on": "2026-10-22", + "expires_on": "2026-11-04", + }, + ] + for record, expected_record in zip( + sorted(records, key=lambda entry: (entry["package"], entry["reviewed_on"])), expected_records, strict=True + ): + actual_record = { + "package": record["package"], + "version": record["version"], + "vulnerability_ids": sorted(record["vulnerability_ids"]), + "reviewed_on": record["reviewed_on"], + "expires_on": record["expires_on"], + } + assert (actual_record, bool(record["rationale"].strip()), bool(record["mitigation"].strip())) == ( + expected_record, + True, + True, + ) lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) packages = [ (package["name"], package["version"], package["source"]) for package in lock["package"] - if package["name"] in {"semgrep", "mcp", "specfact-semgrep"} + if package["name"] in {"semgrep", "mcp", "opentelemetry-api", "specfact-semgrep"} ] assert sorted(packages) == [ ("mcp", "1.29.0", {"registry": "https://pypi.org/simple"}), - ("semgrep", "1.179.0", {"registry": "https://pypi.org/simple"}), + ("opentelemetry-api", "1.37.0", {"registry": "https://pypi.org/simple"}), + ("semgrep", "1.182.1", {"registry": "https://pypi.org/simple"}), ]