From 40dc5c93d2a25284550247ce04147ae4e8509df6 Mon Sep 17 00:00:00 2001 From: Dom Date: Fri, 16 Oct 2026 22:54:48 +0200 Subject: [PATCH 1/3] docs(security): specify downstream Semgrep admission --- openspec/CHANGE_ORDER.md | 11 +- .../downstream-semgrep-admission/design.md | 188 ++++++++++++++++++ .../downstream-semgrep-admission/proposal.md | 56 ++++++ .../requirements-evidence.yaml | 78 ++++++++ .../downstream-semgrep-admission/spec.md | 96 +++++++++ .../downstream-semgrep-admission/tasks.md | 24 +++ 6 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/downstream-semgrep-admission/design.md create mode 100644 openspec/changes/downstream-semgrep-admission/proposal.md create mode 100644 openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml create mode 100644 openspec/changes/downstream-semgrep-admission/specs/downstream-semgrep-admission/spec.md create mode 100644 openspec/changes/downstream-semgrep-admission/tasks.md diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 730dbafb..386d6ec2 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -4,6 +4,15 @@ This document is the **single source of truth for active work** in this repository. It lists what is in flight, what is paused, and the order in which active changes should be implemented. +## Downstream Semgrep admission (#772, 2026-10-16) + +The owner authorized `downstream-semgrep-admission` as a separate implementation +PR under #747. Build metadata-only downstream wheels with fixed MCP, execute the +seven-platform / three-Python matrix, and require exact protected provenance +before production adoption. Candidate tests do not authorize their own artifacts +or replace the currently blocked frozen audit. Preserve every existing gate, +provider pin and signature boundary. No merge or main promotion is authorized. + ## Authorized #747 dependency stack (2026-10-10) The owner approved `dev <- #744 <- #727 <- #752 <- #754 <- #753`, retaining @@ -73,7 +82,7 @@ Recovered from uncommitted proposal-only feature worktrees into local `dev`; rem | Bucket | Count | Location | |---|---:|---| -| **Active-tree entries** | 41 | [`openspec/changes/`](changes/) | +| **Active-tree entries** | 47 | [`openspec/changes/`](changes/) | | **Parking-lot entries** | 21 | [`openspec/parking-lot/`](parking-lot/) | | **Archived** | 120 | [`openspec/changes/archive/`](changes/archive/) | diff --git a/openspec/changes/downstream-semgrep-admission/design.md b/openspec/changes/downstream-semgrep-admission/design.md new file mode 100644 index 00000000..eda28287 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/design.md @@ -0,0 +1,188 @@ +# Design: downstream Semgrep build and admission + +## Decision + +Use one standard-library artifact owner under scripts. Candidate build/validation +and protected production admission are separate operations. The implementation PR +must not install an unadmitted candidate into the ordinary project environment or +claim that the existing frozen MCP audit is green. Protected publication/adoption +requires subsequent human-controlled integration; no merge is part of this task. + +## Synthesis decision + +Three independent architecture candidates were compared against trust bootstrap, +closed failure modes, frozen/audit coverage, real matrix verification and interface +depth. The independent judge selected candidate A. Retain A's single preinstall +identity boundary and real lifecycle tests; graft B's reviewed output hashes. +Do not add a new hosting/provider surface or a second production route. Candidate +artifacts may be retained by CI but are never protected producer evidence. + +## Caller contracts + +PR validation caller (unprivileged, disposable environment): + +```python +from downstream_semgrep import build_candidate, validate_candidate + +candidate = build_candidate(recipe_path, upstream_wheel, output_directory) +report = verify_candidate(recipe_path, upstream_wheel, candidate.path) +assert report.functional_checks_passed +# report.production_admission is always "not_requested"; not an admission token. +``` + +Production setup caller, before any project dependency installation: + +```python +from downstream_semgrep import admit_artifact + +admitted = admit_artifact(wheel, evidence_bundle) +install_frozen_environment(admitted.wheel, locked_requirements) +``` + +Existing audit caller, after admission verification and before accepting findings: + +```python +audit_input = admitted.audit_input(requirements_file, audit_output) +result = run_existing_strict_audit(audit_input.requirements) +audit_input.assert_complete_result(result) +# Report original Semgrep local identity + mapped upstream + actual MCP identity. +``` + +The production CLI has no `--allow-untrusted`, `--skip-provenance`, candidate flag, or caller-selected trust root. Candidate validation is a separate entry point and result type. Its passing result cannot be supplied to production admission. + +## Types and signatures + +Sketch only; bodies raise `NotImplementedError`. Serialized dictionaries and attestation transport objects are parsed inside the core. Opaque production objects are created only by boundary functions; private constructors prevent accidental use, not a security sandbox against hostile Python. + +```python +@dataclass(frozen=True) +class PlatformWheel: + filename: str + platform_tag: str + upstream_sha256: str + upstream_url: str + +@dataclass(frozen=True) +class Recipe: + upstream_version: str + downstream_version: str + mcp_from: str + mcp_to: str + wheels: tuple[PlatformWheel, ...] + python_versions: tuple[str, ...] + build_runtime_digest: str + +class CandidateWheel: + # Private validated constructor; path, digest, recipe digest, platform. + ... + +class CandidateReport: + functional_checks_passed: bool + production_admission: Literal["not_requested"] + ... + +class TrustedPolicy: + # Obtained from the protected verifier's immutable configuration only. + ... + +class AdmittedWheel: + # Private constructor; exact digest + provenance + policy binding. + wheel: Path + def audit_input(self, actual_requirements: Path, output: Path) -> BoundAuditInput: + raise NotImplementedError + +class BoundAuditInput: + requirements: Path + def assert_complete_result(self, result: AuditResult) -> None: + raise NotImplementedError + +def build_candidate(recipe_path: Path, upstream_wheel: Path, + output_directory: Path) -> CandidateWheel: + raise NotImplementedError + +def verify_candidate(recipe_path: Path, upstream_wheel: Path, + candidate_wheel: Path) -> CandidateWheel: + raise NotImplementedError + +def required_matrix(recipe_path: Path) -> list[dict[str, str]]: + raise NotImplementedError + +def aggregate_candidate_reports(recipe_path: Path, reports: list[dict]) -> dict: + raise NotImplementedError + +def admit_artifact(wheel: Path, evidence_bundle: Path) -> AdmittedWheel: + raise NotImplementedError +``` + +The core validates version grammar, exact identities, unique platforms, complete matrix, hashes, metadata cardinality, record format and provenance at entry. Validated values then flow without repeated caller checks. Public surface hides ZIP and evidence schemas, audit transformation and policy evaluation. Existing gate scripts retain their current entry points. + +## Trust and scope + +The recipe is the sole source of wheel/platform and Python matrix inputs. It binds +upstream Semgrep 1.179.0, downstream 1.179.0+specfact.1 and MCP 1.30.0. Build output +hashes are independently reviewed facts; production admission never generates its +own expected hash. Archive equivalence includes complete RECORD verification, +permissions, executable payload, entry points and unchanged license notices. + +Production uses an independent protected policy source, never a caller-selected +PR policy. It binds exact repository, protected source commit, signer workflow, +recipe, output hashes, complete matrix proof and expiry. Verify provenance before +interpreting claims. Tool absence, API failure, PR-origin/ref, stale policy and +unknown identities reject. There is no allow-untrusted or skip-provenance switch. +The initial production admission register is empty. Existing provider/capsule +catalogs and signature keys are not modified or reused for downstream identity. + +All active actual package identities remain visible. Only an admitted exact +Semgrep artifact may query its authenticated upstream code identity. Every other +actual dependency, including fixed MCP, is audited without upstream re-resolution. +The response must cover every requested identity exactly once. Empty/malformed +skip records, missing/extra/duplicate packages, changed versions, service failures +and advisories fail. No vulnerability exception is added. Production adoption, +floor/lock refresh and frozen setup changes remain blocked until protected +artifacts exist; isolated candidate audits are labeled candidate evidence. + +## Platform and MCP verification + +Generate 21 cells from seven reviewed wheel platforms and Python 3.11–3.13. +Native macOS x86_64/arm64, Windows AMD64 and Linux glibc/musl x86_64/arm64 must +actually install and execute. Native musl containers on matching native Linux +hosts are valid; emulation or tag-only downloads are not execution proof. Record +actual OS, architecture, libc, interpreter, wheel hashes and installed graph. +Missing, duplicate, failed or skipped cells reject aggregate admission. + +Use clean hashed installs with normal resolution and pip check. Preserve the +existing SAST rules, findings/error behavior and positive/negative controls. +Drive actual SDK ASGI lifespan and HTTP initialization, tool requests, DELETE, +short configured idle expiry, small capacity, refused openings and shutdown. +Check production defaults independently. Semgrep CLI/stdio integration must +remain functional. Deterministic OAuth discovery in a bounded protocol test may +not disable credential validation or substitute for an actual integration run. + +## Risks and rollback + +Protected bootstrap cannot be completed by trusting this unmerged PR. Candidate +verification may finish while production admission and the original MCP audit +remain blocked. Removing/expiring admission blocks installation again. Restoring +the vulnerable original pins is not a release-ready rollback. Matrix runtime and +CI cost are unmeasured; all 21 cells and ordinary quality/native/FULL proof remain +required. Artifact transport/frozen materialization is not considered proven until +actual cross-platform experiments pass. + +## Implementation contract reconciliation (2026-10-16) + +The root architect selected explicit candidate verification and matrix operations +instead of the earlier generic validation sketch. Production admission takes no +caller-selected policy: its private loader reads the canonical protected dev +policy at an immutable freshly resolved source commit. The initial register is +empty. Evidence is a directory containing recipe.json, matrix.json, +provenance.jsonl and the exact original wheel under upstream/. Protected approval +binds recipe/matrix SHA-256, source/signer commit, dates and exact wheel identities. +The trusted attestation verifier enforces repository, signer workflow/digest, +protected source ref/digest, OIDC issuer and hosted runner identity. Plain claims +inside the predicate do not establish these facts. + +The first audit input contract accepts a complete active per-cell flat hashed +inventory; unsupported markers/options reject rather than being guessed. Bound +result reconciliation rejects every unknown, skipped, missing, duplicate, +malformed, vulnerable or nonzero result. This operation is not wired into normal +frozen delivery until independently protected artifact adoption is available. diff --git a/openspec/changes/downstream-semgrep-admission/proposal.md b/openspec/changes/downstream-semgrep-admission/proposal.md new file mode 100644 index 00000000..4238c208 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/proposal.md @@ -0,0 +1,56 @@ +# Change: Downstream Semgrep build and admission + +## Why + +The frozen Semgrep 1.179.0 distribution requires MCP 1.29.0, which is affected by +CVE-2026-59951. Normal resolution cannot select the fixed MCP 1.30.0 while that +metadata remains unchanged. The owner authorized a separate implementation PR on +2026-10-16 Europe/Berlin. No merge or main promotion is authorized. + +## What Changes + +- Build a distinct downstream Semgrep local version from exact reviewed upstream + wheel hashes. Preserve every runtime byte, archive permission and entry point; + change only the distribution version, MCP requirement, associated directory names + and complete RECORD. +- Verify seven platform wheel variants on Python 3.11, 3.12 and 3.13. Test actual + installation, dependency coherence, unchanged SAST rules and positive/negative + controls, plus MCP stateful session expiry, capacity and cleanup behavior. +- Add a standard-library admission boundary that requires exact artifacts, recipe, + expiry, platform coverage and independently authenticated protected provenance. + PR-built candidates cannot authorize themselves for production consumption. +- Bind vulnerability queries to the exact authenticated upstream Semgrep identity + while retaining the actual local identity and complete fixed dependency graph. + Unknown, skipped, missing, duplicate or failed audit results remain blocking. +- Keep production adoption blocked until protected producer evidence is available. + Candidate validation does not replace the existing frozen graph or its audit. + +## Capabilities + +### New Capabilities + +- `downstream-semgrep-admission`: deterministic metadata-only builds, platform + verification and exact authenticated downstream artifact admission. + +### Modified Capabilities + +None. Existing provider, native capsule, Requirements, signature, Socket, FULL +review, coverage and timeout policies retain their authority. + +## Impact + +Affected surfaces: repository-owned tooling under scripts, reviewed build inputs +under ci, focused regression tests and a dedicated candidate/protected build +workflow. This does not modify the trusted Code Review provider catalog, issue +511, module signatures or existing application runtime behavior. + +## Source Tracking + + + +- GitHub Issue: #772, +- Parent: #747; project: SpecFact CLI #1, Todo at implementation bootstrap. +- Labels: openspec, change-proposal, dependencies, security. +- Native blocked-by/blocking issue edges: empty at verified bootstrap. +- Production dependency: protected producer and full admission proof, not a + prerequisite for isolated candidate implementation and testing. diff --git a/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml b/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml new file mode 100644 index 00000000..36385d94 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml @@ -0,0 +1,78 @@ +schema_version: '2' +requirements: + openspec:downstream-semgrep-admission:downstream-semgrep-admission:deterministic-metadata-only-downstream-build: + rationale: Deterministic Metadata Only Downstream Build + stakeholder_refs: + - nold-ai/specfact-cli#772 + touchpoints: + - id: deterministic-metadata-only-downstream-build + kind: code_symbol + locator: scripts/downstream_semgrep.py + verification_cases: + - case_id: deterministic-metadata-only-downstream-build-1 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Reproducible legitimate downstream wheel + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + - case_id: deterministic-metadata-only-downstream-build-2 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Changed upstream or unexpected payload rejects + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + openspec:downstream-semgrep-admission:downstream-semgrep-admission:exact-protected-admission: + rationale: Exact Protected Admission + stakeholder_refs: + - nold-ai/specfact-cli#772 + touchpoints: + - id: exact-protected-admission + kind: code_symbol + locator: scripts/downstream_semgrep.py + verification_cases: + - case_id: exact-protected-admission-1 + scenario_id: exact-protected-admission + method: test + intent: Untrusted candidate cannot authorize itself + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + - case_id: exact-protected-admission-2 + scenario_id: exact-protected-admission + method: test + intent: Exact complete protected candidate is admitted + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + openspec:downstream-semgrep-admission:downstream-semgrep-admission:complete-vulnerability-identity-coverage: + rationale: Complete Vulnerability Identity Coverage + stakeholder_refs: + - nold-ai/specfact-cli#772 + touchpoints: + - id: complete-vulnerability-identity-coverage + kind: code_symbol + locator: scripts/downstream_semgrep.py + verification_cases: + - case_id: complete-vulnerability-identity-coverage-1 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Complete fixed graph retains downstream inventory identity + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + - case_id: complete-vulnerability-identity-coverage-2 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Incomplete or vulnerable graph fails closed + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + openspec:downstream-semgrep-admission:downstream-semgrep-admission:complete-platform-and-mcp-behavioral-proof: + rationale: Complete Platform And MCP Behavioral Proof + stakeholder_refs: + - nold-ai/specfact-cli#772 + touchpoints: + - id: complete-platform-and-mcp-behavioral-proof + kind: code_symbol + locator: scripts/downstream_semgrep.py + verification_cases: + - case_id: complete-platform-and-mcp-behavioral-proof-1 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix aggregate rejects missing or conflicting cells + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + - case_id: complete-platform-and-mcp-behavioral-proof-2 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Fixed MCP reclaims sessions and limits capacity + observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. diff --git a/openspec/changes/downstream-semgrep-admission/specs/downstream-semgrep-admission/spec.md b/openspec/changes/downstream-semgrep-admission/specs/downstream-semgrep-admission/spec.md new file mode 100644 index 00000000..b985383c --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/specs/downstream-semgrep-admission/spec.md @@ -0,0 +1,96 @@ +## ADDED Requirements + +### Requirement: Deterministic Metadata Only Downstream Build + +The builder SHALL accept only a reviewed upstream Semgrep wheel whose filename, +platform and SHA-256 match the recipe. It SHALL produce a distinct local version +with the exact MCP 1.30.0 requirement. Its complete archive comparison SHALL allow +only the version and MCP metadata changes, corresponding dist-info/data directory +renames and a regenerated bijective RECORD. Runtime contents, modes, entry points +and every other metadata field SHALL remain unchanged. Invalid, duplicate, +traversing, linked, signed or unexpected archive entries SHALL reject before output. + +#### Scenario: Reproducible legitimate downstream wheel + +- **GIVEN** an exact reviewed upstream wheel and recipe +- **WHEN** the builder runs twice +- **THEN** both output bytes SHALL be identical and runtime contents unchanged +- **AND** RECORD SHALL authenticate every resulting entry exactly once + +#### Scenario: Changed upstream or unexpected payload rejects + +- **GIVEN** a wrong hash, unsafe archive member or unauthorized metadata change +- **WHEN** a build or archive comparison is requested +- **THEN** it SHALL reject without installing or publishing an artifact + +### Requirement: Exact Protected Admission + +Production admission SHALL require an exact reviewed local version, candidate and +upstream hashes, recipe identity, platform, validity interval and complete platform +evidence. Provenance SHALL authenticate the configured repository, immutable source +and signer workflow on the protected branch through a trusted verifier. A JSON +claim, PR event, unrecognized local version, missing or failed verifier, wrong +identity, expired approval or incomplete evidence SHALL NOT grant admission. +Admission SHALL precede production installation or audit identity substitution. + +#### Scenario: Untrusted candidate cannot authorize itself + +- **GIVEN** a PR-built candidate with internally consistent claims +- **WHEN** production admission runs without authenticated protected provenance +- **THEN** it SHALL reject before installation or identity substitution + +#### Scenario: Exact complete protected candidate is admitted + +- **GIVEN** exact independently verified provenance and complete fresh matrix proof +- **WHEN** all recipe, artifact, platform and validity bindings match +- **THEN** admission SHALL return only that exact verified artifact identity + +### Requirement: Complete Vulnerability Identity Coverage + +The audit SHALL retain actual installed distribution identities and SHALL query +the exact admitted upstream Semgrep code identity in addition to every package in +the actual fixed graph. Only that exact artifact binding may map a local identity. +No generic suffix removal, dependency override, package omission or new advisory +exception SHALL be introduced. Every unreviewed advisory, skipped or malformed +entry, absent or duplicate package, unexpected version, service error and timeout +SHALL reject. The existing upstream Semgrep floor and blocked releases SHALL remain +effective; downstream operation SHALL require MCP 1.30.0 or later in the fixed 1.x +line covered by the recipe. + +#### Scenario: Complete fixed graph retains downstream inventory identity + +- **GIVEN** an admitted exact downstream wheel and actual fixed dependency graph +- **WHEN** strict advisory lookup succeeds for every expected identity +- **THEN** the audit SHALL retain the local Semgrep identity in its report +- **AND** lookup SHALL use only its exact authenticated upstream identity + +#### Scenario: Incomplete or vulnerable graph fails closed + +- **GIVEN** a vulnerable control graph, missing package or failed advisory response +- **WHEN** audit evidence is reconciled against the complete actual inventory +- **THEN** the audit SHALL fail without a skip, exception or resolver fallback + +### Requirement: Complete Platform And MCP Behavioral Proof + +One reviewed platform inventory SHALL define seven wheel targets and Python 3.11, +3.12 and 3.13. All 21 cells SHALL execute on their real architecture and OS/libc; +missing cells, skips and emulation reported as native SHALL NOT pass. Every cell +SHALL prove ordinary hash-enforced installation, package consistency, unchanged +SAST findings and errors, positive/negative detection controls, MCP command startup +and actual stateful session behavior. Tests SHALL exercise idle expiry, capacity, +DELETE cleanup and refused-opening cleanup with bounded test settings, and confirm +the fixed production defaults. Candidate evidence SHALL remain distinct from +protected producer admission. + +#### Scenario: Matrix aggregate rejects missing or conflicting cells + +- **GIVEN** cell records from the reviewed matrix +- **WHEN** any required platform/Python result is absent, duplicated or unsuccessful +- **THEN** the aggregate SHALL reject and production adoption SHALL remain blocked + +#### Scenario: Fixed MCP reclaims sessions and limits capacity + +- **GIVEN** the real downstream Semgrep package and its fixed MCP dependency +- **WHEN** real local transport requests create, expire, delete or refuse sessions +- **THEN** lifecycle regressions SHALL observe reclamation and bounded capacity +- **AND** legitimate scanning and ordinary MCP requests SHALL remain functional diff --git a/openspec/changes/downstream-semgrep-admission/tasks.md b/openspec/changes/downstream-semgrep-admission/tasks.md new file mode 100644 index 00000000..d0915a48 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/tasks.md @@ -0,0 +1,24 @@ +## 1. Contracts and failing evidence + +- [ ] 1.1 Validate focused issue metadata, OpenSpec and architecture. +- [ ] 1.2 Commit the specification with normal signed hooks. +- [ ] 1.3 Author mapped behavioral tests and record exact owner acceptance. +- [ ] 1.4 Capture actual failing-before evidence before implementation. + +## 2. Implementation + +- [ ] 2.1 Implement deterministic metadata-only wheel build and archive verification. +- [ ] 2.2 Implement exact fail-closed protected admission and complete audit binding. +- [ ] 2.3 Implement one platform inventory, candidate matrix and protected producer workflow. +- [ ] 2.4 Implement real MCP lifecycle and SAST positive/negative compatibility probes. + +## 3. Verification and publication + +- [ ] 3.1 Record passing local regression evidence and complete independent review. +- [ ] 3.2 Execute all 21 actual platform/Python cells; reject missing or failed cells. +- [ ] 3.3 Run normal quality, native/FULL, signature and dependency trust gates. +- [ ] 3.4 Publish a separate signed PR with exact status and proof limitations. +- [ ] 3.5 Retain production adoption as blocked until authenticated protected evidence exists. + +No merge, release publication or main promotion is included. Do not archive an +unmerged or incomplete change. From 4a1afa902a5dc62990e60cde00a4b45b4e78abea Mon Sep 17 00:00:00 2001 From: Dom Date: Fri, 16 Oct 2026 23:06:24 +0200 Subject: [PATCH 2/3] test(security): cover downstream artifact admission --- .../TDD_EVIDENCE.md | 53 ++ .../downstream-semgrep-admission/design.md | 19 +- .../requirements-evidence.yaml | 383 ++++++++++++- .../requirements-proof/review-evidence.json | 9 + tests/unit/scripts/test_downstream_semgrep.py | 515 ++++++++++++++++++ 5 files changed, 947 insertions(+), 32 deletions(-) create mode 100644 openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md create mode 100644 openspec/changes/downstream-semgrep-admission/requirements-proof/review-evidence.json create mode 100644 tests/unit/scripts/test_downstream_semgrep.py diff --git a/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md b/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md new file mode 100644 index 00000000..e45b3535 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md @@ -0,0 +1,53 @@ +# Downstream Semgrep evidence + +## Specification and authored RED + +Signed specification commit: `40dc5c93d2a25284550247ce04147ae4e8509df6`. +Normal hooks and strict OpenSpec validation passed before authored tests were +copied into the worktree. The pinned planned Requirements validator accepted +all four native requirement mappings without findings. + +Actual worktree run before production code existed: + +```text +python -m pytest tests/unit/scripts/test_downstream_semgrep.py -q --tb=short +40 failed in 0.47s +JUnit: 40 tests, 40 AssertionError failures, 0 errors, 0 skips +``` + +The failures assert that the real build/admission boundary is absent. They are +initial missing-behavior evidence, not a claim that every rejection input exposed +a separate existing vulnerability. Synthetic wheel, provenance and audit fixtures +exercise the contract; they are not authenticated production admission. + +The owner accepted exact mapping +`sha256:83f12bcec0ff756681c2cc1188c431fce1327e6d3371fae6bc4df4bf1ddd8425` +on 2026-10-16 at 23:04 Europe/Berlin. The separate accepted record identifies the +actual human reply. The native matrix inspection obligation remains pending. + +The mapped complete-audit case was strengthened with candidate-only audit +assertions under the same accepted mapping. Actual pre-extension run: +`1 failed in 0.09s`, AssertionError `Candidate audit proof is absent`. +The original frozen test proposal, both RED runs and hashes remain retained in +`/private/tmp/specfact-downstream-semgrep-20261016/` during local preparation. + +## Local MCP candidate experiments + +A separate non-listening control on MCP 1.29.0 fails eight lifecycle/default +controls, with zero errors. Actual DELETE and refused opening requests retain a +registered session. MCP 1.30.0 passes those eight checks and the actual Semgrep +stdio initialize/list/custom-scan probe. Additional local checks verify the real +Semgrep HTTP factory rejects missing/malformed credentials and the SDK binds a +session to the verified test principal. These are isolated candidate experiments, +not seven-platform proof or protected producer admission. + +## Pending + +- Signed authored-test commit and exact commit-bound RED retention. +- Final build/admission GREEN and independent review. +- All 21 real platform/Python cells, including full SAST and MCP evidence. +- Normal native/FULL, quality, dependency/provider trust and signature gates. +- Protected producer bootstrap and separately reviewed production adoption. + +No production pin, existing audit/floor or trusted provider has been changed. The +existing frozen MCP audit remains a release blocker. No merge is authorized. diff --git a/openspec/changes/downstream-semgrep-admission/design.md b/openspec/changes/downstream-semgrep-admission/design.md index eda28287..daa07ad8 100644 --- a/openspec/changes/downstream-semgrep-admission/design.md +++ b/openspec/changes/downstream-semgrep-admission/design.md @@ -62,6 +62,7 @@ class PlatformWheel: upstream_sha256: str upstream_url: str + @dataclass(frozen=True) class Recipe: upstream_version: str @@ -72,44 +73,54 @@ class Recipe: python_versions: tuple[str, ...] build_runtime_digest: str + class CandidateWheel: # Private validated constructor; path, digest, recipe digest, platform. ... + class CandidateReport: functional_checks_passed: bool production_admission: Literal["not_requested"] ... + class TrustedPolicy: # Obtained from the protected verifier's immutable configuration only. ... + class AdmittedWheel: # Private constructor; exact digest + provenance + policy binding. wheel: Path + def audit_input(self, actual_requirements: Path, output: Path) -> BoundAuditInput: raise NotImplementedError + class BoundAuditInput: requirements: Path + def assert_complete_result(self, result: AuditResult) -> None: raise NotImplementedError -def build_candidate(recipe_path: Path, upstream_wheel: Path, - output_directory: Path) -> CandidateWheel: + +def build_candidate(recipe_path: Path, upstream_wheel: Path, output_directory: Path) -> CandidateWheel: raise NotImplementedError -def verify_candidate(recipe_path: Path, upstream_wheel: Path, - candidate_wheel: Path) -> CandidateWheel: + +def verify_candidate(recipe_path: Path, upstream_wheel: Path, candidate_wheel: Path) -> CandidateWheel: raise NotImplementedError + def required_matrix(recipe_path: Path) -> list[dict[str, str]]: raise NotImplementedError + def aggregate_candidate_reports(recipe_path: Path, reports: list[dict]) -> dict: raise NotImplementedError + def admit_artifact(wheel: Path, evidence_bundle: Path) -> AdmittedWheel: raise NotImplementedError ``` diff --git a/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml b/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml index 36385d94..ab4f220d 100644 --- a/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml +++ b/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml @@ -1,7 +1,7 @@ schema_version: '2' requirements: openspec:downstream-semgrep-admission:downstream-semgrep-admission:deterministic-metadata-only-downstream-build: - rationale: Deterministic Metadata Only Downstream Build + rationale: Deterministic metadata only downstream build stakeholder_refs: - nold-ai/specfact-cli#772 touchpoints: @@ -9,18 +9,143 @@ requirements: kind: code_symbol locator: scripts/downstream_semgrep.py verification_cases: - - case_id: deterministic-metadata-only-downstream-build-1 + - case_id: deterministic-metadata-only-downstream-build-01 scenario_id: deterministic-metadata-only-downstream-build method: test - intent: Reproducible legitimate downstream wheel - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. - - case_id: deterministic-metadata-only-downstream-build-2 + intent: Build is reproducible and preserves complete runtime. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_build_is_reproducible_and_preserves_complete_runtime + - case_id: deterministic-metadata-only-downstream-build-02 scenario_id: deterministic-metadata-only-downstream-build method: test - intent: Changed upstream or unexpected payload rejects - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + intent: Wrong upstream hash cannot write candidate. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_wrong_upstream_hash_cannot_write_candidate + - case_id: deterministic-metadata-only-downstream-build-03 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[duplicate] + - case_id: deterministic-metadata-only-downstream-build-04 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[traversal] + - case_id: deterministic-metadata-only-downstream-build-05 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[symlink] + - case_id: deterministic-metadata-only-downstream-build-06 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[signature] + - case_id: deterministic-metadata-only-downstream-build-07 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[bad-record] + - case_id: deterministic-metadata-only-downstream-build-08 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[duplicate-mcp] + - case_id: deterministic-metadata-only-downstream-build-09 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[runtime] + - case_id: deterministic-metadata-only-downstream-build-10 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[mode] + - case_id: deterministic-metadata-only-downstream-build-11 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[entrypoint] + - case_id: deterministic-metadata-only-downstream-build-12 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[other-metadata] + - case_id: deterministic-metadata-only-downstream-build-13 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unreviewed downstream identity rejects. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[unknown-local] + - case_id: deterministic-metadata-only-downstream-build-14 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unreviewed downstream identity rejects. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[masquerade] + - case_id: deterministic-metadata-only-downstream-build-15 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unreviewed downstream identity rejects. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[below-floor] openspec:downstream-semgrep-admission:downstream-semgrep-admission:exact-protected-admission: - rationale: Exact Protected Admission + rationale: Exact protected admission stakeholder_refs: - nold-ai/specfact-cli#772 touchpoints: @@ -28,18 +153,80 @@ requirements: kind: code_symbol locator: scripts/downstream_semgrep.py verification_cases: - - case_id: exact-protected-admission-1 + - case_id: exact-protected-admission-01 + scenario_id: exact-protected-admission + method: test + intent: Exact protected boundary admits only the verified wheel. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_exact_protected_boundary_admits_only_the_verified_wheel + - case_id: exact-protected-admission-02 + scenario_id: exact-protected-admission + method: test + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[empty-policy] + - case_id: exact-protected-admission-03 + scenario_id: exact-protected-admission + method: test + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[expired] + - case_id: exact-protected-admission-04 scenario_id: exact-protected-admission method: test - intent: Untrusted candidate cannot authorize itself - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. - - case_id: exact-protected-admission-2 + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[wrong-hash] + - case_id: exact-protected-admission-05 scenario_id: exact-protected-admission method: test - intent: Exact complete protected candidate is admitted - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[wrong-matrix] + - case_id: exact-protected-admission-06 + scenario_id: exact-protected-admission + method: test + intent: Verifier failure cannot turn claims into admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[pr-origin] + - case_id: exact-protected-admission-07 + scenario_id: exact-protected-admission + method: test + intent: Verifier failure cannot turn claims into admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[missing-verifier] + - case_id: exact-protected-admission-08 + scenario_id: exact-protected-admission + method: test + intent: Verifier failure cannot turn claims into admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[timeout] openspec:downstream-semgrep-admission:downstream-semgrep-admission:complete-vulnerability-identity-coverage: - rationale: Complete Vulnerability Identity Coverage + rationale: Complete vulnerability identity coverage stakeholder_refs: - nold-ai/specfact-cli#772 touchpoints: @@ -47,18 +234,98 @@ requirements: kind: code_symbol locator: scripts/downstream_semgrep.py verification_cases: - - case_id: complete-vulnerability-identity-coverage-1 + - case_id: complete-vulnerability-identity-coverage-01 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit preserves real graph and reports local identity. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_preserves_real_graph_and_reports_local_identity + - case_id: complete-vulnerability-identity-coverage-02 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[empty-skip] + - case_id: complete-vulnerability-identity-coverage-03 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[missing] + - case_id: complete-vulnerability-identity-coverage-04 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[duplicate] + - case_id: complete-vulnerability-identity-coverage-05 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[wrong-version] + - case_id: complete-vulnerability-identity-coverage-06 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[malformed-vuln] + - case_id: complete-vulnerability-identity-coverage-07 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[advisory-and-error] + - case_id: complete-vulnerability-identity-coverage-08 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects unbound inventory before writing view. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[vulnerable-mcp] + - case_id: complete-vulnerability-identity-coverage-09 scenario_id: complete-vulnerability-identity-coverage method: test - intent: Complete fixed graph retains downstream inventory identity - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. - - case_id: complete-vulnerability-identity-coverage-2 + intent: Bound audit rejects unbound inventory before writing view. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[wrong-local-hash] + - case_id: complete-vulnerability-identity-coverage-10 scenario_id: complete-vulnerability-identity-coverage method: test - intent: Incomplete or vulnerable graph fails closed - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + intent: Bound audit rejects unbound inventory before writing view. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[marker] openspec:downstream-semgrep-admission:downstream-semgrep-admission:complete-platform-and-mcp-behavioral-proof: - rationale: Complete Platform And MCP Behavioral Proof + rationale: Complete platform and mcp behavioral proof stakeholder_refs: - nold-ai/specfact-cli#772 touchpoints: @@ -66,13 +333,73 @@ requirements: kind: code_symbol locator: scripts/downstream_semgrep.py verification_cases: - - case_id: complete-platform-and-mcp-behavioral-proof-1 + - case_id: complete-platform-and-mcp-behavioral-proof-01 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix requires all seven platforms and three interpreters. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_requires_all_seven_platforms_and_three_interpreters + - case_id: complete-platform-and-mcp-behavioral-proof-02 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[missing] + - case_id: complete-platform-and-mcp-behavioral-proof-03 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[duplicate] + - case_id: complete-platform-and-mcp-behavioral-proof-04 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[failed] + - case_id: complete-platform-and-mcp-behavioral-proof-05 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[emulated] + - case_id: complete-platform-and-mcp-behavioral-proof-06 scenario_id: complete-platform-and-mcp-behavioral-proof method: test - intent: Matrix aggregate rejects missing or conflicting cells - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. - - case_id: complete-platform-and-mcp-behavioral-proof-2 + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[skipped-check] + - case_id: complete-platform-and-mcp-behavioral-proof-07 scenario_id: complete-platform-and-mcp-behavioral-proof method: test - intent: Fixed MCP reclaims sessions and limits capacity - observable: All SHALL outcomes in this scenario hold; missing or contradictory proof rejects. + intent: Complete candidate matrix does not grant production admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service + fixture. This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_complete_candidate_matrix_does_not_grant_production_admission + - case_id: native-platform-lifecycle-pending + scenario_id: complete-platform-and-mcp-behavioral-proof + method: inspection + intent: Require separate real-package native matrix and MCP lifecycle evidence before adoption. + observable: All 21 native cells must prove ordinary hashed installation, SAST controls and real + idle/capacity/DELETE/refused-opening behavior. Synthetic unit reports do not supply this proof. + Missing native evidence blocks adoption. diff --git a/openspec/changes/downstream-semgrep-admission/requirements-proof/review-evidence.json b/openspec/changes/downstream-semgrep-admission/requirements-proof/review-evidence.json new file mode 100644 index 00000000..37dc6f62 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/requirements-proof/review-evidence.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "decision": "accepted", + "reviewer_id": "djm81", + "reviewer_role": "owner", + "recorded_at": "2026-10-16T21:04:10Z", + "reference": "codex-user-reply:call_a8966dad83764b70933191fc9b9a75f6:Accept this exact mapping", + "mapping_digest": "sha256:83f12bcec0ff756681c2cc1188c431fce1327e6d3371fae6bc4df4bf1ddd8425" +} diff --git a/tests/unit/scripts/test_downstream_semgrep.py b/tests/unit/scripts/test_downstream_semgrep.py new file mode 100644 index 00000000..9ef7683d --- /dev/null +++ b/tests/unit/scripts/test_downstream_semgrep.py @@ -0,0 +1,515 @@ +"""Observable downstream wheel, admission and audit contracts with synthetic wheels.""" + +from __future__ import annotations + +import base64 +import copy +import csv +import hashlib +import importlib.util +import io +import json +import stat +import subprocess +import sys +import warnings +import zipfile +from datetime import date, timedelta +from pathlib import Path +from unittest.mock import patch + +import pytest + + +PLATFORMS = ( + "macosx_10_14_x86_64", + "macosx_11_0_arm64", + "manylinux_2_34_x86_64", + "manylinux_2_34_aarch64", + "musllinux_1_2_x86_64", + "musllinux_1_2_aarch64", + "win_amd64", +) +UPSTREAM = "1.179.0" +DOWNSTREAM = "1.179.0+specfact.1" +TAGS = "cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none" + + +def _module(): + root = Path(__file__).resolve().parents[3] + path = root / "scripts/downstream_semgrep.py" + assert path.is_file(), f"Downstream admission boundary is absent: {path}" + spec = importlib.util.spec_from_file_location("_downstream_semgrep_contract", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _write_json(path: Path, value: object) -> Path: + path.write_text(json.dumps(value, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def _members(version: str = UPSTREAM) -> list[tuple[str, bytes, int]]: + prefix = f"semgrep-{version}.dist-info/" + return [ + ( + prefix + "METADATA", + f"Metadata-Version: 2.1\nName: semgrep\nVersion: {version}\nRequires-Dist: mcp==1.29.0\nRequires-Dist: click>=8\nLicense: LGPL-2.1\n\nSynthetic fixture\n".encode(), + 0o100644, + ), + ( + prefix + "WHEEL", + ( + "Wheel-Version: 1.0\nGenerator: test\nRoot-Is-Purelib: false\n" + + "".join(f"Tag: {tag}-none-{PLATFORMS[0]}\n" for tag in TAGS.removesuffix("-none").split(".")) + ).encode(), + 0o100644, + ), + (prefix + "entry_points.txt", b"[console_scripts]\nsemgrep = semgrep.main:main\n", 0o100644), + (prefix + "licenses/LICENSE", b"Synthetic license fixture\n", 0o100644), + (f"semgrep-{version}.data/purelib/semgrep/main.py", b"def main():\n return 7\n", 0o100644), + (f"semgrep-{version}.data/purelib/semgrep/bin/semgrep-core", b"synthetic-native-payload\x00", 0o100755), + ] + + +def _write_wheel(path: Path, members: list[tuple[str, bytes, int]], version: str = UPSTREAM) -> None: + record_path = f"semgrep-{version}.dist-info/RECORD" + rows = [] + for name, content, _mode in members: + digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode().rstrip("=") + rows.append((name, "sha256=" + digest, str(len(content)))) + rows.append((record_path, "", "")) + record = io.StringIO(newline="") + csv.writer(record, lineterminator="\n").writerows(rows) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(path, "w") as archive: + for name, content, mode in [*members, (record_path, record.getvalue().encode(), 0o100644)]: + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = mode << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, content) + + +@pytest.fixture +def wheel_recipe(tmp_path): + upstream = tmp_path / f"semgrep-{UPSTREAM}-{TAGS}-{PLATFORMS[0]}.whl" + _write_wheel(upstream, _members()) + recipe = { + "schema_version": 1, + "package": "semgrep", + "upstream_version": UPSTREAM, + "downstream_version": DOWNSTREAM, + "mcp_from": "1.29.0", + "mcp_to": "1.30.0", + "python_versions": ["3.11", "3.12", "3.13"], + "build_runtime_digest": "a" * 64, + "wheels": [ + { + "filename": f"semgrep-{UPSTREAM}-{TAGS}-{platform}.whl", + "platform_tag": platform, + "upstream_sha256": _sha(upstream.read_bytes()) if platform == PLATFORMS[0] else "b" * 64, + "upstream_url": f"https://files.pythonhosted.org/packages/test/semgrep-{UPSTREAM}-{TAGS}-{platform}.whl", + } + for platform in PLATFORMS + ], + } + recipe_path = _write_json(tmp_path / "recipe.json", recipe) + return recipe_path, recipe, upstream + + +def _assert_record(archive: zipfile.ZipFile) -> None: + names = archive.namelist() + assert len(names) == len(set(names)) + record_name = f"semgrep-{DOWNSTREAM}.dist-info/RECORD" + rows = list(csv.reader(io.StringIO(archive.read(record_name).decode()))) + assert len(rows) == len(names) + assert {row[0] for row in rows} == set(names) + for name, digest, size in rows: + if name == record_name: + assert (digest, size) == ("", "") + else: + content = archive.read(name) + assert digest == "sha256=" + base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode().rstrip("=") + assert size == str(len(content)) + + +def test_build_is_reproducible_and_preserves_complete_runtime(wheel_recipe, tmp_path): + module = _module() + recipe_path, _recipe, upstream = wheel_recipe + one = module.build_candidate(recipe_path, upstream, tmp_path / "one") + two = module.build_candidate(recipe_path, upstream, tmp_path / "two") + assert one.path.read_bytes() == two.path.read_bytes() + assert one.sha256 == _sha(one.path.read_bytes()) + assert one.recipe_sha256 == _sha(recipe_path.read_bytes()) + assert one.platform == PLATFORMS[0] + with zipfile.ZipFile(upstream) as original, zipfile.ZipFile(one.path) as built: + _assert_record(built) + expected_names = { + name.replace(f"semgrep-{UPSTREAM}.", f"semgrep-{DOWNSTREAM}.", 1) for name in original.namelist() + } + assert set(built.namelist()) == expected_names + for name in original.namelist(): + renamed = name.replace(f"semgrep-{UPSTREAM}.", f"semgrep-{DOWNSTREAM}.", 1) + assert built.getinfo(renamed).external_attr == original.getinfo(name).external_attr + if not name.endswith(("/METADATA", "/RECORD")): + assert built.read(renamed) == original.read(name) + expected = ( + original.read(f"semgrep-{UPSTREAM}.dist-info/METADATA") + .replace(b"Version: 1.179.0\n", b"Version: 1.179.0+specfact.1\n") + .replace(b"Requires-Dist: mcp==1.29.0\n", b"Requires-Dist: mcp==1.30.0\n") + ) + assert built.read(f"semgrep-{DOWNSTREAM}.dist-info/METADATA") == expected + verified = module.verify_candidate(recipe_path, upstream, one.path) + assert (verified.path, verified.sha256, verified.platform) == (one.path, one.sha256, one.platform) + + +def test_wrong_upstream_hash_cannot_write_candidate(wheel_recipe, tmp_path): + module = _module() + recipe_path, _recipe, upstream = wheel_recipe + upstream.write_bytes(upstream.read_bytes() + b"tamper") + output = tmp_path / "output" + with pytest.raises(ValueError): + module.build_candidate(recipe_path, upstream, output) + assert not list(output.glob("*.whl")) + + +@pytest.mark.parametrize( + "damage", ["duplicate", "traversal", "symlink", "signature", "bad-record", "duplicate-mcp"], ids=str +) +def test_unsafe_upstream_archive_rejects_even_with_reviewed_hash(wheel_recipe, tmp_path, damage): + module = _module() + recipe_path, recipe, upstream = wheel_recipe + members = _members() + if damage == "duplicate": + members.append(members[-1]) + elif damage == "traversal": + members.append(("../escape", b"outside", 0o100644)) + elif damage == "symlink": + members.append(("semgrep-link", b"/tmp/outside", stat.S_IFLNK | 0o777)) + elif damage == "signature": + members.append((f"semgrep-{UPSTREAM}.dist-info/RECORD.jws", b"stale-signature", 0o100644)) + elif damage == "duplicate-mcp": + name, content, mode = members[0] + members[0] = (name, content.replace(b"Requires-Dist: mcp==1.29.0\n", b"Requires-Dist: mcp==1.29.0\n" * 2), mode) + _write_wheel(upstream, members) + if damage == "bad-record": + with zipfile.ZipFile(upstream) as archive: + entries = [(item, archive.read(item)) for item in archive.infolist()] + with zipfile.ZipFile(upstream, "w") as archive: + for item, content in entries: + archive.writestr(item, b"wrong,sha256=bad,1\n" if item.filename.endswith("/RECORD") else content) + recipe["wheels"][0]["upstream_sha256"] = _sha(upstream.read_bytes()) + _write_json(recipe_path, recipe) + output = tmp_path / "rejected" + with pytest.raises(ValueError): + module.build_candidate(recipe_path, upstream, output) + assert not list(output.glob("*.whl")) + assert not (tmp_path / "escape").exists() + + +@pytest.mark.parametrize("damage", ["runtime", "mode", "entrypoint", "other-metadata"], ids=str) +def test_candidate_rejects_unauthorized_change_with_valid_record(wheel_recipe, tmp_path, damage): + module = _module() + recipe_path, _recipe, upstream = wheel_recipe + candidate = module.build_candidate(recipe_path, upstream, tmp_path / "candidate") + with zipfile.ZipFile(candidate.path) as archive: + members = [ + (item.filename, archive.read(item), item.external_attr >> 16) + for item in archive.infolist() + if not item.filename.endswith("/RECORD") + ] + for index, (name, content, mode) in enumerate(members): + if damage == "runtime" and name.endswith("main.py"): + members[index] = (name, b"raise RuntimeError('changed')\n", mode) + elif damage == "mode" and name.endswith("semgrep-core"): + members[index] = (name, content, 0o100644) + elif damage == "entrypoint" and name.endswith("entry_points.txt"): + members[index] = (name, b"[console_scripts]\nsemgrep = evil:main\n", mode) + elif damage == "other-metadata" and name.endswith("/METADATA"): + members[index] = (name, content.replace(b"click>=8", b"click>=1"), mode) + _write_wheel(candidate.path, members, DOWNSTREAM) + with pytest.raises(ValueError): + module.verify_candidate(recipe_path, upstream, candidate.path) + + +@pytest.mark.parametrize( + "version", + ["1.179.0+attacker.1", "1.179.0", "1.174.0+specfact.1"], + ids=["unknown-local", "masquerade", "below-floor"], +) +def test_unreviewed_downstream_identity_rejects(wheel_recipe, tmp_path, version): + module = _module() + recipe_path, recipe, upstream = wheel_recipe + recipe["downstream_version"] = version + _write_json(recipe_path, recipe) + with pytest.raises(ValueError): + module.build_candidate(recipe_path, upstream, tmp_path / "output") + + +def test_matrix_requires_all_seven_platforms_and_three_interpreters(wheel_recipe): + module = _module() + recipe_path, _recipe, _upstream = wheel_recipe + matrix = module.required_matrix(recipe_path) + assert len(matrix) == 21 + assert {(cell["platform"], cell["python_version"]) for cell in matrix} == { + (platform, python) for platform in PLATFORMS for python in ("3.11", "3.12", "3.13") + } + + +def _reports(recipe_path, recipe): + return [ + { + "platform": wheel["platform_tag"], + "python_version": python, + "candidate_sha256": "c" * 64, + "upstream_sha256": wheel["upstream_sha256"], + "recipe_sha256": _sha(recipe_path.read_bytes()), + "status": "passed", + "native": True, + "checks": dict.fromkeys( + ("install", "pip_check", "sast", "positive_control", "negative_control", "mcp_stdio", "mcp_lifecycle"), + True, + ), + } + for wheel in recipe["wheels"] + for python in recipe["python_versions"] + ] + + +@pytest.mark.parametrize("damage", ["missing", "duplicate", "failed", "emulated", "skipped-check"], ids=str) +def test_matrix_rejects_incomplete_or_non_native_evidence(wheel_recipe, damage): + module = _module() + recipe_path, recipe, _upstream = wheel_recipe + reports = _reports(recipe_path, recipe) + if damage == "missing": + reports.pop() + elif damage == "duplicate": + reports[-1] = copy.deepcopy(reports[0]) + elif damage == "failed": + reports[-1]["status"] = "failed" + elif damage == "emulated": + reports[-1]["native"] = False + else: + reports[-1]["checks"]["mcp_lifecycle"] = False + with pytest.raises(ValueError): + module.aggregate_candidate_reports(recipe_path, reports) + + +def test_complete_candidate_matrix_does_not_grant_production_admission(wheel_recipe): + module = _module() + recipe_path, recipe, _upstream = wheel_recipe + report = module.aggregate_candidate_reports(recipe_path, _reports(recipe_path, recipe)) + assert report["production_admission"] == "not_requested" + assert report["status"] == "passed" + + +def _protected_fixture(module, wheel_recipe, tmp_path): + recipe_path, recipe, upstream = wheel_recipe + candidate = module.build_candidate(recipe_path, upstream, tmp_path / "candidate") + bundle = tmp_path / "evidence" + (bundle / "upstream").mkdir(parents=True) + (bundle / "recipe.json").write_bytes(recipe_path.read_bytes()) + (bundle / "upstream" / upstream.name).write_bytes(upstream.read_bytes()) + (bundle / "provenance.jsonl").write_text('{"synthetic": "not cryptographically verified"}\n') + reports = _reports(recipe_path, recipe) + for report in reports: + if report["platform"] == PLATFORMS[0]: + report["candidate_sha256"] = candidate.sha256 + matrix = _write_json(bundle / "matrix.json", reports) + policy = { + "schema_version": 1, + "admissions": [ + { + "recipe_sha256": _sha(recipe_path.read_bytes()), + "matrix_sha256": _sha(matrix.read_bytes()), + "source_commit": "d" * 40, + "signer_commit": "e" * 40, + "reviewed_on": (date.today() - timedelta(days=1)).isoformat(), + "expires_on": (date.today() + timedelta(days=7)).isoformat(), + "artifacts": [ + { + "filename": item["filename"].replace(f"semgrep-{UPSTREAM}-", f"semgrep-{DOWNSTREAM}-", 1), + "candidate_sha256": candidate.sha256 if item["platform_tag"] == PLATFORMS[0] else "c" * 64, + "upstream_sha256": item["upstream_sha256"], + "platform_tag": item["platform_tag"], + } + for item in recipe["wheels"] + ], + } + ], + } + return candidate, bundle, policy + + +def test_exact_protected_boundary_admits_only_the_verified_wheel(wheel_recipe, tmp_path): + module = _module() + candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) + with ( + patch.object(module, "_fetch_protected_policy", return_value=policy), + patch.object(module, "_verify_provenance", return_value=None) as verifier, + ): + admitted = module.admit_artifact(candidate.path, bundle) + assert admitted.wheel == candidate.path + verifier.assert_called_once_with(candidate.path, bundle / "provenance.jsonl", "d" * 40, "e" * 40) + + +@pytest.mark.parametrize("damage", ["empty-policy", "expired", "wrong-hash", "wrong-matrix"], ids=str) +def test_unapproved_artifact_rejects_before_verifier(wheel_recipe, tmp_path, damage): + module = _module() + candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) + if damage == "empty-policy": + policy["admissions"] = [] + elif damage == "expired": + policy["admissions"][0]["expires_on"] = (date.today() - timedelta(days=1)).isoformat() + elif damage == "wrong-hash": + policy["admissions"][0]["artifacts"][0]["candidate_sha256"] = "f" * 64 + else: + policy["admissions"][0]["matrix_sha256"] = "f" * 64 + with ( + patch.object(module, "_fetch_protected_policy", return_value=policy), + patch.object( + module, "_verify_provenance", side_effect=AssertionError("Unapproved artifacts must not reach verifier") + ), + pytest.raises(ValueError), + ): + module.admit_artifact(candidate.path, bundle) + + +@pytest.mark.parametrize("failure", ["pr-origin", "missing-verifier", "timeout"], ids=str) +def test_verifier_failure_cannot_turn_claims_into_admission(wheel_recipe, tmp_path, failure): + module = _module() + candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) + _write_json( + bundle / "provenance.jsonl", {"event_name": "pull_request", "ref": "refs/pull/772/merge", "verified": True} + ) + result = subprocess.CompletedProcess(["gh"], 1, '{"verified":true}', "source ref is not protected dev") + error = { + "pr-origin": None, + "missing-verifier": FileNotFoundError("gh"), + "timeout": subprocess.TimeoutExpired("gh", 30), + }[failure] + with ( + patch.object(module, "_fetch_protected_policy", return_value=policy), + patch.object(module.subprocess, "run", return_value=result, side_effect=error) as run, + pytest.raises(ValueError), + ): + module.admit_artifact(candidate.path, bundle) + assert run.call_args_list + for call in run.call_args_list: + argv = call.args[0] + assert "attestation" in argv and "verify" in argv + assert "refs/heads/dev" in argv + assert "d" * 40 in argv and "e" * 40 in argv + assert "--deny-self-hosted-runners" in argv + + +def _bound_audit(module, wheel_recipe, tmp_path): + candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) + actual = tmp_path / "actual.txt" + actual.write_text( + f"semgrep=={DOWNSTREAM} --hash=sha256:{candidate.sha256}\n" + f"mcp==1.30.0 --hash=sha256:{'1' * 64}\n" + f"click==8.3.0 --hash=sha256:{'2' * 64}\n", + encoding="utf-8", + ) + with ( + patch.object(module, "_fetch_protected_policy", return_value=policy), + patch.object(module, "_verify_provenance", return_value=None), + ): + admitted = module.admit_artifact(candidate.path, bundle) + return admitted, actual, candidate + + +def _audit_result(): + return { + "dependencies": [ + {"name": "semgrep", "version": UPSTREAM, "vulns": []}, + {"name": "mcp", "version": "1.30.0", "vulns": []}, + {"name": "click", "version": "8.3.0", "vulns": []}, + ] + } + + +def test_bound_audit_preserves_real_graph_and_reports_local_identity(wheel_recipe, tmp_path): + module = _module() + admitted, actual, candidate = _bound_audit(module, wheel_recipe, tmp_path) + output = tmp_path / "audit.txt" + original = actual.read_bytes() + bound = admitted.audit_input(actual, output) + rendered = output.read_text() + assert bound.requirements == output + assert actual.read_bytes() == original + assert f"semgrep=={UPSTREAM}" in rendered + assert DOWNSTREAM not in rendered + assert candidate.sha256 not in rendered + assert wheel_recipe[1]["wheels"][0]["upstream_sha256"] in rendered + for line in actual.read_text().splitlines()[1:]: + assert line in rendered + assert bound.actual_inventory == {"semgrep": DOWNSTREAM, "mcp": "1.30.0", "click": "8.3.0"} + report = bound.assert_complete_result( + subprocess.CompletedProcess(["pip-audit"], 0, json.dumps(_audit_result()), "") + ) + assert report["actual_inventory"] == bound.actual_inventory + + candidate_identity = module.verify_candidate(wheel_recipe[0], wheel_recipe[2], candidate.path) + assert callable(getattr(candidate_identity, "candidate_audit_input", None)), "Candidate audit proof is absent" + candidate_bound = candidate_identity.candidate_audit_input(actual, tmp_path / "candidate-audit.txt") + candidate_report = candidate_bound.assert_complete_result( + subprocess.CompletedProcess(["pip-audit"], 0, json.dumps(_audit_result()), "") + ) + assert candidate_report["actual_inventory"] == bound.actual_inventory + assert candidate_report["production_admission"] == "not_requested" + + +@pytest.mark.parametrize( + "damage", ["empty-skip", "missing", "duplicate", "wrong-version", "malformed-vuln", "advisory-and-error"], ids=str +) +def test_bound_audit_rejects_incomplete_or_unsafe_responses(wheel_recipe, tmp_path, damage): + module = _module() + admitted, actual, _candidate = _bound_audit(module, wheel_recipe, tmp_path) + bound = admitted.audit_input(actual, tmp_path / "audit.txt") + result = _audit_result() + deps = result["dependencies"] + returncode, stderr = 0, "" + if damage == "empty-skip": + deps[-1]["skip_reason"] = "" + elif damage == "missing": + deps.pop() + elif damage == "duplicate": + deps.append(copy.deepcopy(deps[0])) + elif damage == "wrong-version": + deps[1]["version"] = "1.29.0" + elif damage == "malformed-vuln": + deps[0]["vulns"] = [None] + else: + deps[1]["vulns"] = [{"id": "CVE-2026-59951"}] + returncode, stderr = 2, "HTTP 503 fetching another dependency" + with pytest.raises(ValueError): + bound.assert_complete_result(subprocess.CompletedProcess(["pip-audit"], returncode, json.dumps(result), stderr)) + + +@pytest.mark.parametrize("damage", ["vulnerable-mcp", "wrong-local-hash", "marker"], ids=str) +def test_bound_audit_rejects_unbound_inventory_before_writing_view(wheel_recipe, tmp_path, damage): + module = _module() + admitted, actual, candidate = _bound_audit(module, wheel_recipe, tmp_path) + contents = actual.read_text() + if damage == "vulnerable-mcp": + contents = contents.replace("mcp==1.30.0", "mcp==1.29.0") + elif damage == "wrong-local-hash": + contents = contents.replace(candidate.sha256, "f" * 64) + else: + contents = contents.replace("click==8.3.0", 'click==8.3.0 ; python_version < "3.12"') + actual.write_text(contents) + output = tmp_path / "rejected-audit.txt" + with pytest.raises(ValueError): + admitted.audit_input(actual, output) + assert not output.exists() From 0ba968a2854f0e6486db50c3e0efca7e63ff1b89 Mon Sep 17 00:00:00 2001 From: Dom Date: Fri, 16 Oct 2026 23:26:30 +0200 Subject: [PATCH 3/3] fix(security): verify downstream Semgrep artifacts --- .github/workflows/downstream-semgrep.yml | 142 ++++ ci/downstream-semgrep-admissions.json | 4 + ci/downstream-semgrep-recipe.json | 105 +++ .../REVIEW_EXCEPTIONS.md | 47 ++ .../TDD_EVIDENCE.md | 35 +- .../decision-trail.tsv | 8 + .../downstream-semgrep-admission/design.md | 15 + .../requirements-evidence.yaml | 765 +++++++++--------- .../downstream-semgrep-admission/tasks.md | 14 +- scripts/downstream_semgrep.py | 652 +++++++++++++++ scripts/verify_downstream_semgrep.py | 609 ++++++++++++++ tests/unit/scripts/test_downstream_semgrep.py | 383 ++++++--- .../test_mcp_regressions.py | 371 +++++++++ 13 files changed, 2660 insertions(+), 490 deletions(-) create mode 100644 .github/workflows/downstream-semgrep.yml create mode 100644 ci/downstream-semgrep-admissions.json create mode 100644 ci/downstream-semgrep-recipe.json create mode 100644 openspec/changes/downstream-semgrep-admission/REVIEW_EXCEPTIONS.md create mode 100644 openspec/changes/downstream-semgrep-admission/decision-trail.tsv create mode 100644 scripts/downstream_semgrep.py create mode 100644 scripts/verify_downstream_semgrep.py create mode 100644 tools/downstream_semgrep/test_mcp_regressions.py diff --git a/.github/workflows/downstream-semgrep.yml b/.github/workflows/downstream-semgrep.yml new file mode 100644 index 00000000..bc8a1e00 --- /dev/null +++ b/.github/workflows/downstream-semgrep.yml @@ -0,0 +1,142 @@ +name: Downstream Semgrep candidate + +on: + pull_request: + paths: + - 'scripts/downstream_semgrep.py' + - 'scripts/verify_downstream_semgrep.py' + - 'ci/downstream*' + - 'tools/downstream_semgrep/**' + - '.github/workflows/downstream-semgrep.yml' + - 'tests/unit/scripts/test_downstream_semgrep.py' + workflow_dispatch: + +permissions: + contents: read + +jobs: + matrix: + runs-on: ubuntu-24.04 + outputs: + cells: ${{ steps.matrix.outputs.cells }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.13' + - id: matrix + shell: bash + run: | + cells="$(python scripts/verify_downstream_semgrep.py matrix)" + echo "cells=$cells" >> "$GITHUB_OUTPUT" + + candidate: + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.matrix.outputs.cells) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: ${{ matrix.python_version }} + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + enable-cache: false + version: '0.12.19' + - name: Execute native candidate cell + if: matrix.container_image == '' + shell: bash + env: + CELL_PLATFORM: ${{ matrix.platform }} + CELL_PYTHON: ${{ matrix.python_version }} + run: | + python scripts/verify_downstream_semgrep.py cell --platform "$CELL_PLATFORM" --python-version "$CELL_PYTHON" --output "$RUNNER_TEMP/downstream-cell" + - name: Execute native musl candidate cell + if: matrix.container_image != '' + shell: bash + env: + CELL_PLATFORM: ${{ matrix.platform }} + CELL_PYTHON: ${{ matrix.python_version }} + CELL_IMAGE: ${{ matrix.container_image }} + run: | + case "$CELL_IMAGE" in python:*@sha256:*) ;; *) echo 'Unpinned musl image' >&2; exit 1 ;; esac + mkdir -p "$RUNNER_TEMP/downstream-musl" + status=0 + docker run --rm \ + --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/workspace,readonly" \ + --mount "type=bind,src=$RUNNER_TEMP/downstream-musl,dst=/evidence" \ + -e CELL_PLATFORM -e CELL_PYTHON \ + -w /workspace "$CELL_IMAGE" sh -euc ' + apk add --no-cache git build-base libffi-dev openssl-dev cargo + git config --global --add safe.directory /workspace + python scripts/verify_downstream_semgrep.py uv-requirements > /evidence/uv-bootstrap.txt + python -m pip install --require-hashes --only-binary=:all: --index-url https://pypi.org/simple -r /evidence/uv-bootstrap.txt + python scripts/verify_downstream_semgrep.py cell --platform "$CELL_PLATFORM" --python-version "$CELL_PYTHON" --output /evidence/downstream-cell + ' || status=$? + if test -d "$RUNNER_TEMP/downstream-musl/downstream-cell"; then + cp -R "$RUNNER_TEMP/downstream-musl/downstream-cell" "$RUNNER_TEMP/downstream-cell" + fi + exit "$status" + - name: Retain candidate evidence on success or failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: downstream-${{ matrix.platform }}-${{ matrix.python_version }} + path: | + ${{ runner.temp }}/downstream-cell + !${{ runner.temp }}/downstream-cell/*-env/** + !${{ runner.temp }}/downstream-cell/build-two/** + if-no-files-found: error + retention-days: 14 + + aggregate: + permissions: + contents: read + actions: read + if: always() + needs: [matrix, candidate] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.13' + - name: Download immutable job artifacts + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + shell: bash + run: | + gh run download "$RUN_ID" --pattern 'downstream-*' --dir "$RUNNER_TEMP/downstream-reports" + - name: Require complete successful candidate matrix + env: + MATRIX_RESULT: ${{ needs.matrix.result }} + CANDIDATE_RESULT: ${{ needs.candidate.result }} + shell: bash + run: | + test "$MATRIX_RESULT" = success + test "$CANDIDATE_RESULT" = success + python scripts/verify_downstream_semgrep.py aggregate --reports "$RUNNER_TEMP/downstream-reports" + + protected-producer: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' + needs: aggregate + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Refuse production bootstrap until separately approved roots exist + shell: bash + run: | + echo 'Candidate evidence only. Production producer bootstrap is unconfigured; no signing, attestation or publication is permitted.' >&2 + exit 1 diff --git a/ci/downstream-semgrep-admissions.json b/ci/downstream-semgrep-admissions.json new file mode 100644 index 00000000..438d0fca --- /dev/null +++ b/ci/downstream-semgrep-admissions.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "admissions": [] +} diff --git a/ci/downstream-semgrep-recipe.json b/ci/downstream-semgrep-recipe.json new file mode 100644 index 00000000..9c17a3ad --- /dev/null +++ b/ci/downstream-semgrep-recipe.json @@ -0,0 +1,105 @@ +{ + "schema_version": 1, + "package": "semgrep", + "upstream_version": "1.179.0", + "downstream_version": "1.179.0+specfact.1", + "mcp_from": "1.29.0", + "mcp_to": "1.30.0", + "python_versions": [ + "3.11", + "3.12", + "3.13" + ], + "build_runtime_policy": { + "implementation": "CPython", + "python_versions": [ + "3.11", + "3.12", + "3.13" + ], + "wheel_compression": "ZIP_STORED", + "description": "Runtime-policy digest; actual executable and environment identities are bound separately by each cell report." + }, + "build_runtime_digest": "31432b8918f5a5aef72fbdb8f5aefe74090f5002917c33e7f4f78478998c0f51", + "wheels": [ + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", + "platform_tag": "macosx_10_14_x86_64", + "upstream_sha256": "34b5e801e3f4325c93014124ccfab6da96857f74aae68458d2a07defd4727b0f", + "upstream_url": "https://files.pythonhosted.org/packages/85/4b/4cbaa0c3c124ae7d522e688c01f06013038ff59cc818c21dfa44c96380b3/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_10_14_x86_64.whl", + "runner": "macos-15-intel", + "containers": {} + }, + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", + "platform_tag": "macosx_11_0_arm64", + "upstream_sha256": "9b57026f587a4bc7833bca2b6e4d6778072a79dcf0b9f2e5e01d3d5df39c7759", + "upstream_url": "https://files.pythonhosted.org/packages/bd/77/b573a0faa5a5b0848dc884a0f8595fcb5c550f55fa5f455a233b29a52c0c/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", + "runner": "macos-15", + "containers": {} + }, + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", + "platform_tag": "manylinux_2_34_x86_64", + "upstream_sha256": "b529c4db63f293918989b84ce4640f12f1b1e4680d9e7fcab5f3554f9113dfc5", + "upstream_url": "https://files.pythonhosted.org/packages/cc/6c/621944b4843c7484aeaefd5b232d93780bdf95259567c10a9a8129f08dc6/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_x86_64.whl", + "runner": "ubuntu-24.04", + "containers": {} + }, + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", + "platform_tag": "manylinux_2_34_aarch64", + "upstream_sha256": "c23d79c4839540372acc39b59aad167c1c7f3dc21bacacb27f19aa2edd79d271", + "upstream_url": "https://files.pythonhosted.org/packages/e3/ba/b9f1afbed852a21b6f7e72c064343161c5ab249954c46db1796b59314290/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-manylinux_2_34_aarch64.whl", + "runner": "ubuntu-24.04-arm", + "containers": {} + }, + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", + "platform_tag": "musllinux_1_2_x86_64", + "upstream_sha256": "69bce348296374df6c51e72758801ce06dd18d6dfb307bc73d24b9de53bbe5f1", + "upstream_url": "https://files.pythonhosted.org/packages/ee/c1/59096c02408630b79be19c5b97d145786141e254782ab0e62939c1eea52d/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_x86_64.whl", + "runner": "ubuntu-24.04", + "containers": { + "3.11": "python:3.11-alpine3.22@sha256:a4fc589b32e824f3f02ed9d7e7be19518aa47e105b80416336af9f202275a489", + "3.12": "python:3.12-alpine3.22@sha256:a190708a2dec1bd18b1decb539f8e8f5407abaa9bf39cacda583f7f8c11db322", + "3.13": "python:3.13-alpine3.22@sha256:e81548ac35b07a3bd4805f275107592ef458b1e893c0e04d45aedaa19416cca5" + } + }, + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", + "platform_tag": "musllinux_1_2_aarch64", + "upstream_sha256": "dc6317fbfad49b1dbe396df7da7d9702cffc5ea9ea62cb3b4b2209824496b425", + "upstream_url": "https://files.pythonhosted.org/packages/1d/41/dc51c4e2f2cd730c1512739db792f70f7e9c665f6a0fb40fef62ff4b8eb3/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-musllinux_1_2_aarch64.whl", + "runner": "ubuntu-24.04-arm", + "containers": { + "3.11": "python:3.11-alpine3.22@sha256:a4fc589b32e824f3f02ed9d7e7be19518aa47e105b80416336af9f202275a489", + "3.12": "python:3.12-alpine3.22@sha256:a190708a2dec1bd18b1decb539f8e8f5407abaa9bf39cacda583f7f8c11db322", + "3.13": "python:3.13-alpine3.22@sha256:e81548ac35b07a3bd4805f275107592ef458b1e893c0e04d45aedaa19416cca5" + } + }, + { + "filename": "semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", + "platform_tag": "win_amd64", + "upstream_sha256": "6c6cf104ad0959c0b11de56d15c5d31f56949c620b4bd130c5d1fb899f2bb5a3", + "upstream_url": "https://files.pythonhosted.org/packages/ec/3e/dbf54b028afb807f26dc1e1c94489faac5926a8fa745b41bf505b4c53a7e/semgrep-1.179.0-cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none-win_amd64.whl", + "runner": "windows-2022", + "containers": {} + } + ], + "uv_bootstrap": { + "version": "0.12.19", + "wheels": [ + { + "filename": "uv-0.12.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", + "sha256": "b466eb0f74645883df52446d54474905e8515d75fdf0b313bf099dedc3237896", + "url": "https://files.pythonhosted.org/packages/49/5c/4690490a19726b44d8fd01cdbdc56061fdebf99bb3e466215f276eefbd5d/uv-0.12.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl" + }, + { + "filename": "uv-0.12.19-py3-none-musllinux_1_1_x86_64.whl", + "sha256": "a6512549e6bf12013190639bd5d9ef780f197d7adf4770a628c5e681577543d8", + "url": "https://files.pythonhosted.org/packages/28/cd/e57952eef1e24d52403a73c25c73d5697791eb72debe4a2facf9006fcba5/uv-0.12.19-py3-none-musllinux_1_1_x86_64.whl" + } + ] + } +} diff --git a/openspec/changes/downstream-semgrep-admission/REVIEW_EXCEPTIONS.md b/openspec/changes/downstream-semgrep-admission/REVIEW_EXCEPTIONS.md new file mode 100644 index 00000000..702ee4f4 --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/REVIEW_EXCEPTIONS.md @@ -0,0 +1,47 @@ +# Preinstallation review exceptions + +The unchanged SpecFact review reports nine `MISSING_ICONTRACT` warnings and one +`kiss.parameter-count.warning`. These are explicit, scoped exceptions under +`docs/agent-rules/50-quality-gates-and-review.md`. The review scorer, thresholds, +configuration and contract checker remain unchanged. + +## Standard-library preinstallation boundary + +The nine affected entry points are `build_candidate`, `verify_candidate`, +`required_matrix`, `aggregate_candidate_reports`, `admit_artifact`, +`CandidateWheel.candidate_audit_input`, `AdmittedWheel.audit_input`, +`BoundAuditInput.assert_complete_result`, and the verification driver's `main`. + +These scripts run before an environment or a downstream artifact is trusted. +Importing icontract or beartype here would require a third-party installation +before the boundary can check it. A fallback decorator would falsely imply an +enforced contract. Instead, the core validates its external JSON, paths, +identities, archive members, signatures and audit inventory with explicit +fail-closed checks. The driver validates its arguments and invokes the same +boundary. The forty mapped regressions exercise observable success and rejection +behavior. The independent final security review accepted this limited rationale. + +This exception applies only to these preinstallation entry points. It does not +permit application APIs to omit contracts, or permit a caller to bypass admission. +Reassess it if this code moves into an authenticated installed environment. + +## External MCP factory signature + +`_FixedManagerFactory.__call__` mirrors the MCP 1.30.0 factory arguments exactly. +The reported six parameters include `self`. The regression checks the installed +SDK signature before casting it to the protocol. Reducing the signature to five +parameters would lose a tested control or misrepresent the external API. This +exception applies only to that external protocol declaration. + +No other current review finding is covered by these exceptions. Real native +platform proof and protected producer provenance remain separate obligations. + +## Credential fixture readability + +The FULL review also reports `ai-bloat.loc-vs-complexity` on the 53-line private +`_authenticated_fixture`. Manual inspection retains that fixture. The same +function defines ephemeral signed credentials, their signature verifier, and the +server's audience policy. Splitting these single-use pieces into more helpers +would spread the security assumptions across callers without removing logic. +The HTTP test itself now contains only the ownership request sequence. This is +an explicit retained readability advisory, not a change to the clean-code gate. diff --git a/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md b/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md index e45b3535..82805065 100644 --- a/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md +++ b/openspec/changes/downstream-semgrep-admission/TDD_EVIDENCE.md @@ -43,11 +43,42 @@ not seven-platform proof or protected producer admission. ## Pending -- Signed authored-test commit and exact commit-bound RED retention. -- Final build/admission GREEN and independent review. +- All 21 native platform/Python cells are still pending; no matrix acceptance is claimed. +- Final independent review and signed implementation remain pending. - All 21 real platform/Python cells, including full SAST and MCP evidence. - Normal native/FULL, quality, dependency/provider trust and signature gates. - Protected producer bootstrap and separately reviewed production adoption. No production pin, existing audit/floor or trusted provider has been changed. The existing frozen MCP audit remains a release blocker. No merge is authorized. + +## Signed test snapshot and local GREEN + +Signed authored-test commit `4a1afa902a5dc62990e60cde00a4b45b4e78abea` +was exported into a separate snapshot before the implementation commit. Its real +JUnit run records 40 AssertionError failures, zero errors and zero skips. +The corresponding current implementation run records 40 passes. + +Independent security review reproduced two defects in the first implementation. +The audit parser rejected pip-audit's exact clean diagnostic, and Python JSON +accepted nonstandard numeric constants. Regression assertions failed before each +fix. The corrected boundary permits only the exact clean diagnostic and rejects +NaN and both infinities. A later actual failing assertion showed that dirty or +mixed-source matrix reports were accepted. Those reports now fail; production +admission also binds the matrix source commit to the protected approval. + +The unchanged FULL review on 2026-10-16 at 23:21 Europe/Berlin passed with score +95, zero errors, ten documented warnings and one retained readability advisory. +Canonical BasedPyright analyzed 704 files with zero errors and 1456 existing +warnings. None of its diagnostics concern this change. Strict selected OpenSpec, +Ruff and `uv lock --check` pass. Normal implementation hooks remain pending. +`REVIEW_EXCEPTIONS.md` identifies the narrow preinstallation and external-signature +exceptions. It does not alter the review scorer or any trust gate. + +The real macOS arm64 Python 3.13 candidate environment passes all eleven MCP +regressions after fixture extraction. The vulnerable MCP 1.29.0 control fails +eight lifecycle/default assertions with zero errors. Actual default SAST runs +scan 306 files with identical candidate/control findings, errors and path sets. +Both unchanged baseline gates pass; positive and negative controls exit one and +zero respectively. These are dirty-source local experiments, not eligible +committed matrix evidence. All raw failed attempts remain retained locally. diff --git a/openspec/changes/downstream-semgrep-admission/decision-trail.tsv b/openspec/changes/downstream-semgrep-admission/decision-trail.tsv new file mode 100644 index 00000000..1f7cbf3d --- /dev/null +++ b/openspec/changes/downstream-semgrep-admission/decision-trail.tsv @@ -0,0 +1,8 @@ +utc phase decision why evidence result +2026-10-16 acceptance Record exact human mapping83f12bcec0ff756681c2cc1188c431fce1327e6d3371fae6bc4df4bf1ddd8425 Distinct owner approval received owner-acceptance.json Accepted mapping;21nativecells pending +2026-10-16 TDD Retain signed test snapshot4a1afa90 RED Bind failing evidence to actual signed authored commit signed-authored-red.xml 40AssertionErrors0errors0skips +2026-10-16 review Fix audit diagnostic and nonstandard JSON parsing Independently reproduced failures core-security-review.md;strict-json-red.log 40mappedcasesPASS +2026-10-16 matrix Reject dirty/mixed source reports and bind approved source Prevent stale source evidence admission matrix-source-binding-red.log;source-binding-green.log 40mappedcasesPASS +2026-10-16 SAST Use unchanged default repository scan and baselinegate Initial src-only probe insufficient full-default-sast-proof-2/result.json 306scanned exactcandidate/controlparity;bothbaselinePASS +2026-10-16 bootstrap Use official hash-pinned PyPI uv wheels in musl OCI attestation verification failed; no unverifiable image execution uv-image-attestation.log;uv-musl-wheels.json Originalfailure retained;2officialhashes recipepinned +2026-10-16 review Document narrow stdlib preinstall/externalprotocol exceptions No thirdparty before trust/no fake decorators REVIEW_EXCEPTIONS.md Scorer/checkers unchanged diff --git a/openspec/changes/downstream-semgrep-admission/design.md b/openspec/changes/downstream-semgrep-admission/design.md index daa07ad8..c3e370e0 100644 --- a/openspec/changes/downstream-semgrep-admission/design.md +++ b/openspec/changes/downstream-semgrep-admission/design.md @@ -197,3 +197,18 @@ inventory; unsupported markers/options reject rather than being guessed. Bound result reconciliation rejects every unknown, skipped, missing, duplicate, malformed, vulnerable or nonzero result. This operation is not wired into normal frozen delivery until independently protected artifact adoption is available. + +## Candidate audit verification interface + +The root architect accepted a candidate-only audit method after the real native +harness showed it needed shared exact graph reconciliation without fabricating an +admitted object. `CandidateWheel.candidate_audit_input` uses the same private +binding renderer and returns an audit report labeled +`production_admission: not_requested`. It cannot construct an admitted artifact. +A failing assertion in the existing mapped complete-audit case preceded this +extension; the exact owner-accepted mapping remains unchanged. + +`build_runtime_digest` binds the explicit runtime-policy JSON in the production +recipe. It does not claim to identify 21 different interpreter executables. +Each cell separately records its actual executable, tool hash and runtime details; +the complete matrix and recipe hashes are bound by protected approval. diff --git a/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml b/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml index ab4f220d..d58b11dd 100644 --- a/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml +++ b/openspec/changes/downstream-semgrep-admission/requirements-evidence.yaml @@ -3,403 +3,402 @@ requirements: openspec:downstream-semgrep-admission:downstream-semgrep-admission:deterministic-metadata-only-downstream-build: rationale: Deterministic metadata only downstream build stakeholder_refs: - - nold-ai/specfact-cli#772 + - nold-ai/specfact-cli#772 touchpoints: - - id: deterministic-metadata-only-downstream-build - kind: code_symbol - locator: scripts/downstream_semgrep.py + - id: deterministic-metadata-only-downstream-build + kind: code_symbol + locator: scripts/downstream_semgrep.py verification_cases: - - case_id: deterministic-metadata-only-downstream-build-01 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Build is reproducible and preserves complete runtime. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_build_is_reproducible_and_preserves_complete_runtime - - case_id: deterministic-metadata-only-downstream-build-02 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Wrong upstream hash cannot write candidate. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_wrong_upstream_hash_cannot_write_candidate - - case_id: deterministic-metadata-only-downstream-build-03 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unsafe upstream archive rejects even with reviewed hash. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[duplicate] - - case_id: deterministic-metadata-only-downstream-build-04 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unsafe upstream archive rejects even with reviewed hash. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[traversal] - - case_id: deterministic-metadata-only-downstream-build-05 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unsafe upstream archive rejects even with reviewed hash. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[symlink] - - case_id: deterministic-metadata-only-downstream-build-06 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unsafe upstream archive rejects even with reviewed hash. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[signature] - - case_id: deterministic-metadata-only-downstream-build-07 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unsafe upstream archive rejects even with reviewed hash. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[bad-record] - - case_id: deterministic-metadata-only-downstream-build-08 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unsafe upstream archive rejects even with reviewed hash. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[duplicate-mcp] - - case_id: deterministic-metadata-only-downstream-build-09 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Candidate rejects unauthorized change with valid record. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[runtime] - - case_id: deterministic-metadata-only-downstream-build-10 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Candidate rejects unauthorized change with valid record. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[mode] - - case_id: deterministic-metadata-only-downstream-build-11 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Candidate rejects unauthorized change with valid record. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[entrypoint] - - case_id: deterministic-metadata-only-downstream-build-12 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Candidate rejects unauthorized change with valid record. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[other-metadata] - - case_id: deterministic-metadata-only-downstream-build-13 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unreviewed downstream identity rejects. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[unknown-local] - - case_id: deterministic-metadata-only-downstream-build-14 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unreviewed downstream identity rejects. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[masquerade] - - case_id: deterministic-metadata-only-downstream-build-15 - scenario_id: deterministic-metadata-only-downstream-build - method: test - intent: Unreviewed downstream identity rejects. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[below-floor] + - case_id: deterministic-metadata-only-downstream-build-01 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Build is reproducible and preserves complete runtime. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_build_is_reproducible_and_preserves_complete_runtime + - case_id: deterministic-metadata-only-downstream-build-02 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Wrong upstream hash cannot write candidate. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_wrong_upstream_hash_cannot_write_candidate + - case_id: deterministic-metadata-only-downstream-build-03 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[duplicate] + - case_id: deterministic-metadata-only-downstream-build-04 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[traversal] + - case_id: deterministic-metadata-only-downstream-build-05 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[symlink] + - case_id: deterministic-metadata-only-downstream-build-06 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[signature] + - case_id: deterministic-metadata-only-downstream-build-07 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[bad-record] + - case_id: deterministic-metadata-only-downstream-build-08 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unsafe upstream archive rejects even with reviewed hash. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unsafe_upstream_archive_rejects_even_with_reviewed_hash[duplicate-mcp] + - case_id: deterministic-metadata-only-downstream-build-09 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[runtime] + - case_id: deterministic-metadata-only-downstream-build-10 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[mode] + - case_id: deterministic-metadata-only-downstream-build-11 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[entrypoint] + - case_id: deterministic-metadata-only-downstream-build-12 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Candidate rejects unauthorized change with valid record. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_candidate_rejects_unauthorized_change_with_valid_record[other-metadata] + - case_id: deterministic-metadata-only-downstream-build-13 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unreviewed downstream identity rejects. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[unknown-local] + - case_id: deterministic-metadata-only-downstream-build-14 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unreviewed downstream identity rejects. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[masquerade] + - case_id: deterministic-metadata-only-downstream-build-15 + scenario_id: deterministic-metadata-only-downstream-build + method: test + intent: Unreviewed downstream identity rejects. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unreviewed_downstream_identity_rejects[below-floor] openspec:downstream-semgrep-admission:downstream-semgrep-admission:exact-protected-admission: rationale: Exact protected admission stakeholder_refs: - - nold-ai/specfact-cli#772 + - nold-ai/specfact-cli#772 touchpoints: - - id: exact-protected-admission - kind: code_symbol - locator: scripts/downstream_semgrep.py + - id: exact-protected-admission + kind: code_symbol + locator: scripts/downstream_semgrep.py verification_cases: - - case_id: exact-protected-admission-01 - scenario_id: exact-protected-admission - method: test - intent: Exact protected boundary admits only the verified wheel. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_exact_protected_boundary_admits_only_the_verified_wheel - - case_id: exact-protected-admission-02 - scenario_id: exact-protected-admission - method: test - intent: Unapproved artifact rejects before verifier. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[empty-policy] - - case_id: exact-protected-admission-03 - scenario_id: exact-protected-admission - method: test - intent: Unapproved artifact rejects before verifier. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[expired] - - case_id: exact-protected-admission-04 - scenario_id: exact-protected-admission - method: test - intent: Unapproved artifact rejects before verifier. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[wrong-hash] - - case_id: exact-protected-admission-05 - scenario_id: exact-protected-admission - method: test - intent: Unapproved artifact rejects before verifier. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[wrong-matrix] - - case_id: exact-protected-admission-06 - scenario_id: exact-protected-admission - method: test - intent: Verifier failure cannot turn claims into admission. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[pr-origin] - - case_id: exact-protected-admission-07 - scenario_id: exact-protected-admission - method: test - intent: Verifier failure cannot turn claims into admission. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[missing-verifier] - - case_id: exact-protected-admission-08 - scenario_id: exact-protected-admission - method: test - intent: Verifier failure cannot turn claims into admission. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[timeout] + - case_id: exact-protected-admission-01 + scenario_id: exact-protected-admission + method: test + intent: Exact protected boundary admits only the verified wheel. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_exact_protected_boundary_admits_only_the_verified_wheel + - case_id: exact-protected-admission-02 + scenario_id: exact-protected-admission + method: test + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[empty-policy] + - case_id: exact-protected-admission-03 + scenario_id: exact-protected-admission + method: test + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[expired] + - case_id: exact-protected-admission-04 + scenario_id: exact-protected-admission + method: test + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[wrong-hash] + - case_id: exact-protected-admission-05 + scenario_id: exact-protected-admission + method: test + intent: Unapproved artifact rejects before verifier. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_unapproved_artifact_rejects_before_verifier[wrong-matrix] + - case_id: exact-protected-admission-06 + scenario_id: exact-protected-admission + method: test + intent: Verifier failure cannot turn claims into admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[pr-origin] + - case_id: exact-protected-admission-07 + scenario_id: exact-protected-admission + method: test + intent: Verifier failure cannot turn claims into admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[missing-verifier] + - case_id: exact-protected-admission-08 + scenario_id: exact-protected-admission + method: test + intent: Verifier failure cannot turn claims into admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_verifier_failure_cannot_turn_claims_into_admission[timeout] openspec:downstream-semgrep-admission:downstream-semgrep-admission:complete-vulnerability-identity-coverage: rationale: Complete vulnerability identity coverage stakeholder_refs: - - nold-ai/specfact-cli#772 + - nold-ai/specfact-cli#772 touchpoints: - - id: complete-vulnerability-identity-coverage - kind: code_symbol - locator: scripts/downstream_semgrep.py + - id: complete-vulnerability-identity-coverage + kind: code_symbol + locator: scripts/downstream_semgrep.py verification_cases: - - case_id: complete-vulnerability-identity-coverage-01 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit preserves real graph and reports local identity. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_preserves_real_graph_and_reports_local_identity - - case_id: complete-vulnerability-identity-coverage-02 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects incomplete or unsafe responses. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[empty-skip] - - case_id: complete-vulnerability-identity-coverage-03 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects incomplete or unsafe responses. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[missing] - - case_id: complete-vulnerability-identity-coverage-04 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects incomplete or unsafe responses. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[duplicate] - - case_id: complete-vulnerability-identity-coverage-05 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects incomplete or unsafe responses. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[wrong-version] - - case_id: complete-vulnerability-identity-coverage-06 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects incomplete or unsafe responses. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[malformed-vuln] - - case_id: complete-vulnerability-identity-coverage-07 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects incomplete or unsafe responses. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[advisory-and-error] - - case_id: complete-vulnerability-identity-coverage-08 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects unbound inventory before writing view. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[vulnerable-mcp] - - case_id: complete-vulnerability-identity-coverage-09 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects unbound inventory before writing view. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[wrong-local-hash] - - case_id: complete-vulnerability-identity-coverage-10 - scenario_id: complete-vulnerability-identity-coverage - method: test - intent: Bound audit rejects unbound inventory before writing view. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[marker] + - case_id: complete-vulnerability-identity-coverage-01 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit preserves real graph and reports local identity. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_preserves_real_graph_and_reports_local_identity + - case_id: complete-vulnerability-identity-coverage-02 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[empty-skip] + - case_id: complete-vulnerability-identity-coverage-03 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[missing] + - case_id: complete-vulnerability-identity-coverage-04 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[duplicate] + - case_id: complete-vulnerability-identity-coverage-05 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[wrong-version] + - case_id: complete-vulnerability-identity-coverage-06 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[malformed-vuln] + - case_id: complete-vulnerability-identity-coverage-07 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects incomplete or unsafe responses. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_incomplete_or_unsafe_responses[advisory-and-error] + - case_id: complete-vulnerability-identity-coverage-08 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects unbound inventory before writing view. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[vulnerable-mcp] + - case_id: complete-vulnerability-identity-coverage-09 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects unbound inventory before writing view. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[wrong-local-hash] + - case_id: complete-vulnerability-identity-coverage-10 + scenario_id: complete-vulnerability-identity-coverage + method: test + intent: Bound audit rejects unbound inventory before writing view. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_bound_audit_rejects_unbound_inventory_before_writing_view[marker] openspec:downstream-semgrep-admission:downstream-semgrep-admission:complete-platform-and-mcp-behavioral-proof: rationale: Complete platform and mcp behavioral proof stakeholder_refs: - - nold-ai/specfact-cli#772 + - nold-ai/specfact-cli#772 touchpoints: - - id: complete-platform-and-mcp-behavioral-proof - kind: code_symbol - locator: scripts/downstream_semgrep.py + - id: complete-platform-and-mcp-behavioral-proof + kind: code_symbol + locator: scripts/downstream_semgrep.py verification_cases: - - case_id: complete-platform-and-mcp-behavioral-proof-01 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Matrix requires all seven platforms and three interpreters. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_requires_all_seven_platforms_and_three_interpreters - - case_id: complete-platform-and-mcp-behavioral-proof-02 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Matrix rejects incomplete or non native evidence. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[missing] - - case_id: complete-platform-and-mcp-behavioral-proof-03 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Matrix rejects incomplete or non native evidence. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[duplicate] - - case_id: complete-platform-and-mcp-behavioral-proof-04 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Matrix rejects incomplete or non native evidence. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[failed] - - case_id: complete-platform-and-mcp-behavioral-proof-05 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Matrix rejects incomplete or non native evidence. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[emulated] - - case_id: complete-platform-and-mcp-behavioral-proof-06 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Matrix rejects incomplete or non native evidence. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[skipped-check] - - case_id: complete-platform-and-mcp-behavioral-proof-07 - scenario_id: complete-platform-and-mcp-behavioral-proof - method: test - intent: Complete candidate matrix does not grant production admission. - observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service - fixture. This unit proof does not grant production provenance or replace native matrix execution. - selector: - runner: pytest - node_id: tests/unit/scripts/test_downstream_semgrep.py::test_complete_candidate_matrix_does_not_grant_production_admission - - case_id: native-platform-lifecycle-pending - scenario_id: complete-platform-and-mcp-behavioral-proof - method: inspection - intent: Require separate real-package native matrix and MCP lifecycle evidence before adoption. - observable: All 21 native cells must prove ordinary hashed installation, SAST controls and real - idle/capacity/DELETE/refused-opening behavior. Synthetic unit reports do not supply this proof. - Missing native evidence blocks adoption. + - case_id: complete-platform-and-mcp-behavioral-proof-01 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix requires all seven platforms and three interpreters. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_requires_all_seven_platforms_and_three_interpreters + - case_id: complete-platform-and-mcp-behavioral-proof-02 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[missing] + - case_id: complete-platform-and-mcp-behavioral-proof-03 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[duplicate] + - case_id: complete-platform-and-mcp-behavioral-proof-04 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[failed] + - case_id: complete-platform-and-mcp-behavioral-proof-05 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[emulated] + - case_id: complete-platform-and-mcp-behavioral-proof-06 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Matrix rejects incomplete or non native evidence. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_matrix_rejects_incomplete_or_non_native_evidence[skipped-check] + - case_id: complete-platform-and-mcp-behavioral-proof-07 + scenario_id: complete-platform-and-mcp-behavioral-proof + method: test + intent: Complete candidate matrix does not grant production admission. + observable: The exact public API outcome is asserted against a synthetic wheel or bounded external-service fixture. + This unit proof does not grant production provenance or replace native matrix execution. + selector: + runner: pytest + node_id: tests/unit/scripts/test_downstream_semgrep.py::test_complete_candidate_matrix_does_not_grant_production_admission + - case_id: native-platform-lifecycle-pending + scenario_id: complete-platform-and-mcp-behavioral-proof + method: inspection + intent: Require separate real-package native matrix and MCP lifecycle evidence before adoption. + observable: All 21 native cells must prove ordinary hashed installation, SAST controls and real idle/capacity/DELETE/refused-opening + behavior. Synthetic unit reports do not supply this proof. Missing native evidence blocks adoption. diff --git a/openspec/changes/downstream-semgrep-admission/tasks.md b/openspec/changes/downstream-semgrep-admission/tasks.md index d0915a48..8df73cb4 100644 --- a/openspec/changes/downstream-semgrep-admission/tasks.md +++ b/openspec/changes/downstream-semgrep-admission/tasks.md @@ -1,16 +1,16 @@ ## 1. Contracts and failing evidence -- [ ] 1.1 Validate focused issue metadata, OpenSpec and architecture. -- [ ] 1.2 Commit the specification with normal signed hooks. -- [ ] 1.3 Author mapped behavioral tests and record exact owner acceptance. -- [ ] 1.4 Capture actual failing-before evidence before implementation. +- [x] 1.1 Validate focused issue metadata, OpenSpec and architecture. +- [x] 1.2 Commit the specification with normal signed hooks. +- [x] 1.3 Author mapped behavioral tests and record exact owner acceptance. +- [x] 1.4 Capture actual failing-before evidence before implementation. ## 2. Implementation -- [ ] 2.1 Implement deterministic metadata-only wheel build and archive verification. -- [ ] 2.2 Implement exact fail-closed protected admission and complete audit binding. +- [x] 2.1 Implement deterministic metadata-only wheel build and archive verification. +- [x] 2.2 Implement exact fail-closed protected admission and complete audit binding. - [ ] 2.3 Implement one platform inventory, candidate matrix and protected producer workflow. -- [ ] 2.4 Implement real MCP lifecycle and SAST positive/negative compatibility probes. +- [x] 2.4 Implement real MCP lifecycle and SAST positive/negative compatibility probes. ## 3. Verification and publication diff --git a/scripts/downstream_semgrep.py b/scripts/downstream_semgrep.py new file mode 100644 index 00000000..b325ceeb --- /dev/null +++ b/scripts/downstream_semgrep.py @@ -0,0 +1,652 @@ +"""Build exact Semgrep candidates and admit only independently protected artifacts. + +This preinstallation boundary intentionally uses only the standard library. +Candidate validation never grants production installation or audit authority. +""" + +from __future__ import annotations + +import base64 +import copy +import csv +import hashlib +import io +import json +import re +import stat +import subprocess +import zipfile +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date +from pathlib import Path, PurePosixPath +from types import MappingProxyType +from typing import cast +from urllib.parse import urlsplit + + +UPSTREAM_VERSION = "1.179.0" +DOWNSTREAM_VERSION = "1.179.0+specfact.1" +PLATFORMS = frozenset( + { + "macosx_10_14_x86_64", + "macosx_11_0_arm64", + "manylinux_2_34_x86_64", + "manylinux_2_34_aarch64", + "musllinux_1_2_x86_64", + "musllinux_1_2_aarch64", + "win_amd64", + } +) +CHECKS = frozenset( + {"install", "pip_check", "sast", "positive_control", "negative_control", "mcp_stdio", "mcp_lifecycle"} +) +REPOSITORY = "nold-ai/specfact-cli" +POLICY_PATH = "ci/downstream-semgrep-admissions.json" +_AUTHORITY = object() +_CANDIDATE_AUTHORITY = object() + + +class AdmissionError(ValueError): + """An artifact, evidence record or advisory response cannot be trusted.""" + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise AdmissionError(message) + + +def _object(value: object) -> dict[str, object]: + _require(isinstance(value, dict) and all(isinstance(key, str) for key in value), "Expected a JSON object") + return cast(dict[str, object], value) + + +def _list(value: object) -> list[object]: + _require(isinstance(value, list), "Expected a JSON list") + return cast(list[object], value) + + +def _text(value: object) -> str: + _require(isinstance(value, str) and bool(value), "Expected a nonempty string") + return cast(str, value) + + +def _digest(value: object, length: int = 64) -> str: + result = _text(value) + _require(re.fullmatch(f"[0-9a-f]{{{length}}}", result) is not None, "Invalid immutable digest") + return result + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + _require(key not in result, "Duplicate JSON key") + result[key] = value + return result + + +def _reject_json_constant(value: str) -> object: + raise AdmissionError(f"Invalid JSON numeric constant: {value}") + + +def _json(text: str) -> object: + try: + return cast(object, json.loads(text, object_pairs_hook=_unique_object, parse_constant=_reject_json_constant)) + except (ValueError, TypeError) as exc: + raise AdmissionError("Invalid JSON evidence") from exc + + +def _read(path: Path) -> bytes: + try: + _require(not path.is_symlink() and path.is_file(), f"Not a regular input file: {path}") + return path.read_bytes() + except OSError as exc: + raise AdmissionError(f"Cannot read input: {path}") from exc + + +def _read_json(path: Path) -> object: + try: + return _json(_read(path).decode("utf-8")) + except UnicodeError as exc: + raise AdmissionError("Evidence must be UTF-8") from exc + + +@dataclass(frozen=True) +class _Wheel: + filename: str + platform: str + upstream_sha256: str + + +@dataclass(frozen=True) +class _Recipe: + sha256: str + wheels: tuple[_Wheel, ...] + python_versions: tuple[str, ...] + + +@dataclass(frozen=True) +class CandidateWheel: + """Verified candidate bytes without production authority.""" + + path: Path + sha256: str + platform: str + recipe_sha256: str + _upstream_sha256: str + _authority: object + + def __post_init__(self) -> None: + _require(self._authority is _CANDIDATE_AUTHORITY, "Candidate objects require archive verification") + + def candidate_audit_input(self, actual_requirements: Path, output: Path) -> BoundAuditInput: + """Prepare labeled candidate evidence without production admission.""" + _require(_sha(_read(self.path)) == self.sha256, "Candidate artifact has changed") + return _audit_input(actual_requirements, output, self.sha256, self._upstream_sha256, _CANDIDATE_AUTHORITY) + + +def _recipe_wheel(value: object) -> _Wheel: + item = _object(value) + platform, filename = _text(item.get("platform_tag")), _text(item.get("filename")) + _require(platform in PLATFORMS, "Unreviewed wheel platform") + pattern = rf"semgrep-{re.escape(UPSTREAM_VERSION)}-[a-z0-9.]+-none-{re.escape(platform)}\.whl" + _require(re.fullmatch(pattern, filename) is not None, "Unexpected upstream wheel filename") + url = urlsplit(_text(item.get("upstream_url"))) + _require( + url.scheme == "https" + and url.netloc == "files.pythonhosted.org" + and not url.query + and not url.fragment + and url.path.startswith("/packages/") + and url.path.endswith("/" + filename), + "Unexpected upstream origin", + ) + return _Wheel(filename, platform, _digest(item.get("upstream_sha256"))) + + +def _recipe(path: Path) -> _Recipe: + raw = _read(path) + try: + payload = _object(_json(raw.decode("utf-8"))) + except UnicodeError as exc: + raise AdmissionError("Recipe must be UTF-8") from exc + expected = { + "schema_version": 1, + "package": "semgrep", + "upstream_version": UPSTREAM_VERSION, + "downstream_version": DOWNSTREAM_VERSION, + "mcp_from": "1.29.0", + "mcp_to": "1.30.0", + } + _require(all(payload.get(key) == value for key, value in expected.items()), "Unreviewed recipe identity") + _digest(payload.get("build_runtime_digest")) + versions = tuple(_text(value) for value in _list(payload.get("python_versions"))) + _require(len(versions) == 3 and set(versions) == {"3.11", "3.12", "3.13"}, "Incomplete Python matrix") + wheels = [_recipe_wheel(value) for value in _list(payload.get("wheels"))] + _require( + len(wheels) == 7 and {wheel.platform for wheel in wheels} == PLATFORMS, + "Incomplete or duplicate platform matrix", + ) + return _Recipe(_sha(raw), tuple(wheels), versions) + + +def _wheel(recipe: _Recipe, upstream: Path) -> _Wheel: + matches = [wheel for wheel in recipe.wheels if wheel.filename == upstream.name] + _require(len(matches) == 1, "Wheel is absent from recipe") + wheel = matches[0] + _require(_sha(_read(upstream)) == wheel.upstream_sha256, "Upstream wheel digest differs") + return wheel + + +def _record(entries: dict[str, tuple[zipfile.ZipInfo, bytes]], record_name: str) -> bytes: + buffer = io.StringIO(newline="") + writer = csv.writer(buffer, lineterminator="\n") + for name in sorted(entries): + if name != record_name: + content = entries[name][1] + digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode().rstrip("=") + writer.writerow((name, "sha256=" + digest, len(content))) + writer.writerow((record_name, "", "")) + return buffer.getvalue().encode() + + +def _verify_record(entries: dict[str, tuple[zipfile.ZipInfo, bytes]], record_name: str) -> None: + _require(record_name in entries, "Missing wheel RECORD") + try: + rows = list(csv.reader(io.StringIO(entries[record_name][1].decode("utf-8")), strict=True)) + except (UnicodeError, csv.Error) as exc: + raise AdmissionError("Malformed wheel RECORD") from exc + _require(all(len(row) == 3 for row in rows), "Malformed RECORD row") + _require(len(rows) == len(entries) and {row[0] for row in rows} == set(entries), "RECORD is not bijective") + for name, digest, size in rows: + if name == record_name: + _require((digest, size) == ("", ""), "RECORD cannot authenticate itself") + else: + content = entries[name][1] + expected = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode().rstrip("=") + _require(digest == "sha256=" + expected and size == str(len(content)), "Invalid RECORD digest or size") + + +def _validate_archive_member(info: zipfile.ZipInfo, roots: set[str]) -> str: + name, parts = info.filename, PurePosixPath(info.filename).parts + _require( + bool(parts) + and parts[0] in roots + and not info.is_dir() + and "\\" not in name + and ":" not in name + and "\x00" not in name + and all(part not in {"", ".", ".."} for part in name.split("/")), + "Unsafe archive path", + ) + _require(stat.S_IFMT(info.external_attr >> 16) in {0, stat.S_IFREG}, "Linked or special archive member") + _require( + not (info.flag_bits & 1) and info.compress_type in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}, + "Unsupported archive encoding", + ) + _require(not name.endswith(("/RECORD.jws", "/RECORD.p7s")), "Embedded signatures cannot be retained") + return name + + +def _archive(path: Path, version: str) -> dict[str, tuple[zipfile.ZipInfo, bytes]]: + entries: dict[str, tuple[zipfile.ZipInfo, bytes]] = {} + roots = {f"semgrep-{version}.dist-info", f"semgrep-{version}.data"} + try: + with zipfile.ZipFile(io.BytesIO(_read(path))) as archive: + infos = archive.infolist() + _require( + len(infos) <= 10000 and sum(item.file_size for item in infos) <= 1024**3, "Wheel exceeds archive limits" + ) + folded: set[str] = set() + for info in infos: + name = _validate_archive_member(info, roots) + _require(name.casefold() not in folded, "Duplicate archive member") + folded.add(name.casefold()) + entries[name] = (info, archive.read(info)) + except (OSError, zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc: + raise AdmissionError("Unreadable wheel archive") from exc + _verify_record(entries, f"semgrep-{version}.dist-info/RECORD") + return entries + + +def _transformed(upstream: Path) -> dict[str, tuple[zipfile.ZipInfo, bytes]]: + entries = _archive(upstream, UPSTREAM_VERSION) + metadata_name = f"semgrep-{UPSTREAM_VERSION}.dist-info/METADATA" + _require(metadata_name in entries, "Missing wheel metadata") + metadata = entries[metadata_name][1] + replacements = ( + (b"Version: 1.179.0\n", b"Version: 1.179.0+specfact.1\n"), + (b"Requires-Dist: mcp==1.29.0\n", b"Requires-Dist: mcp==1.30.0\n"), + ) + for before, after in replacements: + _require(metadata.count(before) == 1, "Unexpected metadata field cardinality") + metadata = metadata.replace(before, after) + lines = entries[metadata_name][1].split(b"\n\n", 1)[0].splitlines() + _require( + sum(line.startswith(b"Version:") for line in lines) == 1 + and sum(line.lower().startswith(b"requires-dist: mcp") for line in lines) == 1 + and lines.count(b"Name: semgrep") == 1, + "Conflicting wheel metadata identity", + ) + result: dict[str, tuple[zipfile.ZipInfo, bytes]] = {} + for name, (original, content) in entries.items(): + renamed = name.replace(f"semgrep-{UPSTREAM_VERSION}.", f"semgrep-{DOWNSTREAM_VERSION}.", 1) + info = copy.copy(original) + info.filename, info.compress_type = renamed, zipfile.ZIP_STORED + result[renamed] = (info, metadata if name == metadata_name else content) + record_name = f"semgrep-{DOWNSTREAM_VERSION}.dist-info/RECORD" + result[record_name] = (result[record_name][0], _record(result, record_name)) + return result + + +def build_candidate(recipe_path: Path, upstream_wheel: Path, output_directory: Path) -> CandidateWheel: + """Build a reproducible metadata-only wheel without production admission.""" + recipe = _recipe(recipe_path) + wheel = _wheel(recipe, upstream_wheel) + entries = _transformed(upstream_wheel) + filename = wheel.filename.replace(f"semgrep-{UPSTREAM_VERSION}-", f"semgrep-{DOWNSTREAM_VERSION}-", 1) + output_directory.mkdir(parents=True, exist_ok=True) + target = output_directory / filename + _require(not target.exists() and not target.is_symlink(), "Candidate destination already exists") + try: + with target.open("xb") as handle, zipfile.ZipFile(handle, "w", compression=zipfile.ZIP_STORED) as archive: + for name in sorted(entries): + info, content = entries[name] + archive.writestr(info, content) + except (OSError, ValueError) as exc: + raise AdmissionError("Candidate could not be written") from exc + return CandidateWheel( + target, _sha(_read(target)), wheel.platform, recipe.sha256, wheel.upstream_sha256, _CANDIDATE_AUTHORITY + ) + + +def verify_candidate(recipe_path: Path, upstream_wheel: Path, candidate_wheel: Path) -> CandidateWheel: + """Reject every archive change outside the exact reviewed metadata repair.""" + recipe = _recipe(recipe_path) + wheel = _wheel(recipe, upstream_wheel) + expected_name = wheel.filename.replace(f"semgrep-{UPSTREAM_VERSION}-", f"semgrep-{DOWNSTREAM_VERSION}-", 1) + _require(candidate_wheel.name == expected_name, "Unexpected candidate filename") + expected, actual = _transformed(upstream_wheel), _archive(candidate_wheel, DOWNSTREAM_VERSION) + _require(set(actual) == set(expected), "Candidate archive members differ") + for name, (info, content) in expected.items(): + observed, data = actual[name] + _require(data == content, f"Candidate payload changed: {name}") + _require( + all( + getattr(info, field) == getattr(observed, field) + for field in ("external_attr", "internal_attr", "create_system", "date_time", "extra", "comment") + ), + "Candidate archive attributes changed", + ) + return CandidateWheel( + candidate_wheel, + _sha(_read(candidate_wheel)), + wheel.platform, + recipe.sha256, + wheel.upstream_sha256, + _CANDIDATE_AUTHORITY, + ) + + +def required_matrix(recipe_path: Path) -> list[dict[str, str]]: + """Return the complete execution matrix derived from the validated recipe.""" + recipe = _recipe(recipe_path) + return [ + {"platform": wheel.platform, "python_version": version} + for wheel in recipe.wheels + for version in recipe.python_versions + ] + + +def aggregate_candidate_reports(recipe_path: Path, reports: list[dict[str, object]]) -> dict[str, object]: + """Require every native cell, without converting candidate evidence into trust.""" + recipe = _recipe(recipe_path) + expected = {(wheel.platform, version): wheel for wheel in recipe.wheels for version in recipe.python_versions} + seen: set[tuple[str, str]] = set() + hashes: dict[str, str] = {} + source_commits: set[str] = set() + for value in reports: + report = _object(value) + key = (_text(report.get("platform")), _text(report.get("python_version"))) + _require(key in expected and key not in seen, "Unknown or duplicate matrix cell") + seen.add(key) + _require( + report.get("status") == "passed" and report.get("native") is True, + "Cell did not execute successfully on native platform", + ) + _require(report.get("source_dirty") is False, "Matrix source checkout is dirty") + source_commits.add(_digest(report.get("source_commit"), 40)) + checks = _object(report.get("checks")) + _require(all(checks.get(check) is True for check in CHECKS), "Required cell check missing or failed") + _require( + report.get("recipe_sha256") == recipe.sha256 + and report.get("upstream_sha256") == expected[key].upstream_sha256, + "Cell input binding differs", + ) + digest = _digest(report.get("candidate_sha256")) + _require(hashes.get(key[0], digest) == digest, "Platform cells used different candidate artifacts") + hashes[key[0]] = digest + _require(seen == set(expected), "Missing required matrix cells") + _require(len(source_commits) == 1, "Matrix cells used different source commits") + return { + "status": "passed", + "production_admission": "not_requested", + "recipe_sha256": recipe.sha256, + "artifacts": hashes, + "source_commit": next(iter(source_commits)), + } + + +def _run(command: list[str]) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise AdmissionError("Trusted verifier or GitHub API is unavailable") from exc + _require(result.returncode == 0, "Trusted verifier or GitHub API rejected the request") + return result + + +def _fetch_protected_policy() -> dict[str, object]: + branch = _object(_json(_run(["gh", "api", "--hostname", "github.com", f"repos/{REPOSITORY}/branches/dev"]).stdout)) + _require(branch.get("protected") is True, "Canonical dev branch is not protected") + commit = _digest(_object(branch.get("commit")).get("sha"), 40) + response = _object( + _json( + _run( + ["gh", "api", "--hostname", "github.com", f"repos/{REPOSITORY}/contents/{POLICY_PATH}?ref={commit}"] + ).stdout + ) + ) + _require( + response.get("encoding") == "base64" and response.get("path") == POLICY_PATH and response.get("type") == "file", + "Unexpected canonical policy response", + ) + try: + content = base64.b64decode("".join(_text(response.get("content")).split()), validate=True).decode("utf-8") + except (ValueError, UnicodeError) as exc: + raise AdmissionError("Invalid protected policy encoding") from exc + return _object(_json(content)) + + +def _verify_provenance(wheel: Path, bundle: Path, source_commit: str, signer_commit: str) -> None: + _read(bundle) + result = _run( + [ + "gh", + "attestation", + "verify", + str(wheel.resolve()), + "--hostname", + "github.com", + "--repo", + REPOSITORY, + "--signer-repo", + REPOSITORY, + "--signer-workflow", + f"{REPOSITORY}/.github/workflows/downstream-semgrep.yml", + "--source-ref", + "refs/heads/dev", + "--source-digest", + _digest(source_commit, 40), + "--signer-digest", + _digest(signer_commit, 40), + "--cert-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--deny-self-hosted-runners", + "--bundle", + str(bundle.resolve()), + "--format", + "json", + ] + ) + _require(bool(_list(_json(result.stdout))), "Verifier returned no verified attestations") + + +def _approval(policy: dict[str, object], recipe: _Recipe, matrix_sha256: str) -> dict[str, object]: + _require(policy.get("schema_version") == 1, "Unsupported protected policy schema") + matches = [ + _object(value) + for value in _list(policy.get("admissions")) + if _object(value).get("recipe_sha256") == recipe.sha256 + ] + _require(len(matches) == 1, "Recipe has no unique protected approval") + approval = matches[0] + _require(approval.get("matrix_sha256") == matrix_sha256, "Matrix is not independently approved") + try: + reviewed = date.fromisoformat(_text(approval.get("reviewed_on"))) + expires = date.fromisoformat(_text(approval.get("expires_on"))) + except ValueError as exc: + raise AdmissionError("Invalid approval validity interval") from exc + _require(reviewed <= date.today() <= expires, "Protected approval is not currently valid") + _digest(approval.get("source_commit"), 40) + _digest(approval.get("signer_commit"), 40) + return approval + + +def _approved_artifacts( + approval: dict[str, object], recipe: _Recipe, matrix: dict[str, object] +) -> dict[str, dict[str, object]]: + artifacts: dict[str, dict[str, object]] = {} + for value in _list(approval.get("artifacts")): + artifact = _object(value) + platform = _text(artifact.get("platform_tag")) + _require(platform not in artifacts, "Duplicate approved artifact") + artifacts[platform] = artifact + _require(set(artifacts) == PLATFORMS, "Incomplete protected artifact set") + hashes = _object(matrix.get("artifacts")) + for wheel in recipe.wheels: + item = artifacts[wheel.platform] + expected = wheel.filename.replace(f"semgrep-{UPSTREAM_VERSION}-", f"semgrep-{DOWNSTREAM_VERSION}-", 1) + _require( + item.get("filename") == expected + and item.get("upstream_sha256") == wheel.upstream_sha256 + and item.get("candidate_sha256") == hashes[wheel.platform], + "Approved artifact does not match tested wheel", + ) + return artifacts + + +def admit_artifact(wheel: Path, evidence_bundle: Path) -> AdmittedWheel: + """Authenticate artifact identity without waiving any existing trust or delivery gate.""" + recipe_path, matrix_path = evidence_bundle / "recipe.json", evidence_bundle / "matrix.json" + recipe = _recipe(recipe_path) + approval = _approval(_fetch_protected_policy(), recipe, _sha(_read(matrix_path))) + reports = [_object(value) for value in _list(_read_json(matrix_path))] + matrix = aggregate_candidate_reports(recipe_path, reports) + _require(matrix["source_commit"] == approval["source_commit"], "Approved source differs from matrix source") + artifacts = _approved_artifacts(approval, recipe, matrix) + matches = [item for item in artifacts.values() if item["filename"] == wheel.name] + _require( + len(matches) == 1 and matches[0]["candidate_sha256"] == _sha(_read(wheel)), + "Candidate bytes have no protected approval", + ) + artifact = matches[0] + upstream_name = wheel.name.replace(f"semgrep-{DOWNSTREAM_VERSION}-", f"semgrep-{UPSTREAM_VERSION}-", 1) + verified = verify_candidate(recipe_path, evidence_bundle / "upstream" / upstream_name, wheel) + _verify_provenance( + wheel, evidence_bundle / "provenance.jsonl", _text(approval["source_commit"]), _text(approval["signer_commit"]) + ) + _require(_sha(_read(wheel)) == verified.sha256, "Artifact changed during admission") + return AdmittedWheel(wheel, verified.sha256, _text(artifact["upstream_sha256"]), _AUTHORITY) + + +def _inventory(path: Path) -> tuple[dict[str, str], dict[str, list[str]], list[str]]: + try: + lines = _read(path).decode("utf-8").splitlines(keepends=True) + except UnicodeError as exc: + raise AdmissionError("Requirements must be UTF-8") from exc + inventory: dict[str, str] = {} + hashes: dict[str, list[str]] = {} + pattern = r"([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9][A-Za-z0-9.!+_-]*)( +--hash=sha256:[0-9a-f]{64})+" + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + match = re.fullmatch(pattern, stripped) + _require(match is not None, "Requirements must be flat exact hash-pinned identities without markers or options") + assert match is not None + name = re.sub(r"[-_.]+", "-", match[1]).lower() + _require(name not in inventory, "Duplicate inventory identity") + inventory[name] = match[2] + hashes[name] = re.findall(r"--hash=sha256:([0-9a-f]{64})", stripped) + _require(bool(inventory), "Empty actual inventory") + return inventory, hashes, lines + + +@dataclass(frozen=True) +class AdmittedWheel: + """Exact admitted bytes; constructing this object requires the private authority.""" + + wheel: Path + _sha256: str + _upstream_sha256: str + _authority: object + + def __post_init__(self) -> None: + _require(self._authority is _AUTHORITY, "Admission objects require protected verification") + + def audit_input(self, actual_requirements: Path, output: Path) -> BoundAuditInput: + """Map only this approved Semgrep artifact; retain every actual dependency.""" + _require(_sha(_read(self.wheel)) == self._sha256, "Admitted artifact has changed") + return _audit_input(actual_requirements, output, self._sha256, self._upstream_sha256, _AUTHORITY) + + +def _audit_input( + actual: Path, output: Path, candidate_sha256: str, upstream_sha256: str, authority: object +) -> BoundAuditInput: + inventory, hashes, lines = _inventory(actual) + _require( + inventory.get("semgrep") == DOWNSTREAM_VERSION and hashes.get("semgrep") == [candidate_sha256], + "Inventory does not bind this downstream artifact", + ) + mcp = inventory.get("mcp", "") + _require( + re.fullmatch(r"1\.\d+\.\d+", mcp) is not None and tuple(int(part) for part in mcp.split(".")) >= (1, 30, 0), + "MCP is below the fixed 1.x security floor", + ) + _require( + output.resolve() != actual.resolve() and not output.exists() and not output.is_symlink(), + "Audit output must be a new separate file", + ) + rendered = "".join( + f"semgrep=={UPSTREAM_VERSION} --hash=sha256:{upstream_sha256}\n" + if re.match(r"(?i)^semgrep==", line.lstrip()) + else line + for line in lines + ) + try: + with output.open("x", encoding="utf-8", newline="") as handle: + handle.write(rendered) + except OSError as exc: + raise AdmissionError("Cannot write separate audit input") from exc + return BoundAuditInput(output, MappingProxyType(inventory), _sha(rendered.encode()), authority) + + +@dataclass(frozen=True) +class BoundAuditInput: + """An upstream query identity bound to the complete actual dependency graph.""" + + requirements: Path + actual_inventory: Mapping[str, str] + _sha256: str + _authority: object + + def __post_init__(self) -> None: + _require( + self._authority is _AUTHORITY or self._authority is _CANDIDATE_AUTHORITY, + "Audit binding requires verified artifact evidence", + ) + + def assert_complete_result(self, proc: subprocess.CompletedProcess[str]) -> dict[str, object]: + """Reject every incomplete, failed or vulnerable strict audit response.""" + _require(_sha(_read(self.requirements)) == self._sha256, "Audit input changed after binding") + _require( + proc.returncode == 0 and proc.stderr.strip() in {"", "No known vulnerabilities found"}, + "Advisory lookup failed or emitted diagnostics", + ) + data = _json(proc.stdout) + dependencies = _list(data if isinstance(data, list) else _object(data).get("dependencies")) + expected = dict(self.actual_inventory) + expected["semgrep"] = UPSTREAM_VERSION + observed: dict[str, str] = {} + for value in dependencies: + dependency = _object(value) + _require("skip_reason" not in dependency, "Audit skipped a dependency") + name = re.sub(r"[-_.]+", "-", _text(dependency.get("name"))).lower() + version = _text(dependency.get("version")) + _require( + name not in observed and expected.get(name) == version, "Unexpected or duplicate advisory identity" + ) + _require(_list(dependency.get("vulns")) == [], "Vulnerability or malformed advisory reported") + observed[name] = version + _require(observed == expected, "Advisory coverage is incomplete") + return { + "status": "passed", + "actual_inventory": dict(self.actual_inventory), + "queried_inventory": observed, + "production_admission": "admitted" if self._authority is _AUTHORITY else "not_requested", + } diff --git a/scripts/verify_downstream_semgrep.py b/scripts/verify_downstream_semgrep.py new file mode 100644 index 00000000..e60bba8e --- /dev/null +++ b/scripts/verify_downstream_semgrep.py @@ -0,0 +1,609 @@ +#!/usr/bin/env python3 +"""Execute unprivileged downstream candidate evidence; never grant admission.""" + +from __future__ import annotations + +import argparse +import hashlib +import http.client +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import sysconfig +import tomllib +import urllib.parse +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + + +if TYPE_CHECKING: + from scripts.downstream_semgrep import CandidateWheel + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +RECIPE = ROOT / "ci/downstream-semgrep-recipe.json" +_TOOL_NAMES = ("pip-audit", "beartype", "icontract") +CHECKS = ("install", "pip_check", "sast", "positive_control", "negative_control", "mcp_stdio", "mcp_lifecycle") + + +def _object(value: object) -> dict[str, object]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise ValueError("expected string-keyed object") + return cast(dict[str, object], value) + + +def _list(value: object) -> list[object]: + if not isinstance(value, list): + raise ValueError("expected list") + return cast(list[object], value) + + +def _text(value: object) -> str: + if not isinstance(value, str) or not value: + raise ValueError("expected nonempty text") + return value + + +def _json_object(text: str) -> dict[str, object]: + return _object(cast(object, json.loads(text))) + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _name(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +class _Cell: + def __init__(self, output: Path, report: dict[str, object]): + self.output = output + self.report = report + self.commands: list[dict[str, object]] = [] + self.report["commands"] = self.commands + self.checks: dict[str, bool] = dict.fromkeys(CHECKS, False) + self.report["checks"] = self.checks + self.sequence = 0 + self.environment = dict(os.environ, SEMGREP_SEND_METRICS="off", SEMGREP_ENABLE_VERSION_CHECK="0") + self.environment["SEMGREP_SETTINGS_FILE"] = str(output / "semgrep-settings.yml") + + def _run( + self, label: str, command: list[str], expected: tuple[int, ...] = (0,), timeout: int = 120 + ) -> subprocess.CompletedProcess[str]: + self.sequence += 1 + stem = self.output / f"{self.sequence:02d}-{label}" + environment = dict(self.environment) + if Path(command[0]).name.lower() in {"python", "python.exe"}: + environment["PATH"] = str(Path(command[0]).parent) + os.pathsep + environment.get("PATH", "") + try: + proc = subprocess.run( + command, cwd=ROOT, env=environment, text=True, capture_output=True, timeout=timeout, check=False + ) + except subprocess.TimeoutExpired as error: + + def decoded(value: bytes | str | None) -> str: + return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or "" + + proc = subprocess.CompletedProcess( + command, 124, decoded(error.stdout), decoded(error.stderr) + "\nExternal process timeout\n" + ) + stdout, stderr = stem.with_suffix(".stdout"), stem.with_suffix(".stderr") + stdout.write_text(proc.stdout, encoding="utf-8") + stderr.write_text(proc.stderr, encoding="utf-8") + self.commands.append( + { + "argv": command, + "returncode": proc.returncode, + "stdout_sha256": _digest(stdout), + "stderr_sha256": _digest(stderr), + } + ) + if proc.returncode not in expected: + raise RuntimeError(f"{label} exited {proc.returncode}; see {stem}") + return proc + + +def _architecture_matches(tag: str, machine: str) -> bool: + suffix = "aarch64" if tag.startswith(("manylinux", "musllinux")) else "arm64" + expected = {"aarch64", "arm64"} if tag.endswith(suffix) else {"x86_64", "amd64"} + return machine.lower() in expected + + +def _musl_runtime() -> list[str]: + loaders = list(Path("/lib").glob("ld-musl-*.so.1")) + if len(loaders) != 1: + raise ValueError("native musl loader unavailable or ambiguous") + probe = subprocess.run([str(loaders[0])], capture_output=True, text=True, check=False, timeout=30) + match = re.search(r"Version (\d+)\.(\d+)", probe.stderr + probe.stdout) + if match is None or tuple(map(int, match.groups())) < (1, 2): + raise ValueError("musl >= 1.2 required") + return ["musl", match.group(0)] + + +def _linux_runtime(tag: str) -> list[str]: + if tag.startswith("musllinux_"): + return _musl_runtime() + libc, version = platform.libc_ver() + minimum = tuple(map(int, tag.split("_")[1:3])) + if libc != "glibc" or tuple(map(int, version.split(".")[:2])) < minimum: + raise ValueError("native glibc version does not satisfy wheel tag") + return [libc, version] + + +def _runtime(platform_tag: str, python_version: str) -> dict[str, object]: + system, machine = platform.system(), platform.machine() + if f"{sys.version_info.major}.{sys.version_info.minor}" != python_version: + raise ValueError("interpreter mismatch") + families = {"macosx": "Darwin", "win": "Windows", "manylinux": "Linux", "musllinux": "Linux"} + family = platform_tag.split("_")[0] + if families.get(family) != system or not _architecture_matches(platform_tag, machine): + raise ValueError(f"native platform mismatch: {platform_tag} vs {system}/{machine}") + libc = _linux_runtime(platform_tag) if system == "Linux" else list(platform.libc_ver()) + return { + "system": system, + "machine": machine, + "python_version": platform.python_version(), + "python_executable": sys.executable, + "sysconfig_platform": sysconfig.get_platform(), + "python_executable_sha256": _digest(Path(sys.executable)), + "libc": libc, + } + + +def _official_wheel_url(url: str, filename: str) -> urllib.parse.SplitResult: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "https" or parsed.netloc != "files.pythonhosted.org" or parsed.query or parsed.fragment: + raise ValueError("upstream URL must be an exact files.pythonhosted.org HTTPS artifact") + if ( + Path(filename).name != filename + or not filename.endswith(".whl") + or urllib.parse.unquote(parsed.path).split("/")[-1] != filename + ): + raise ValueError("upstream filename mismatch") + return parsed + + +def _download(record: dict[str, object], output: Path) -> Path: + filename = _text(record["filename"]) + parsed = _official_wheel_url(_text(record["upstream_url"]), filename) + target = output / filename + connection = http.client.HTTPSConnection("files.pythonhosted.org", timeout=120) + try: + connection.request("GET", parsed.path) + response = connection.getresponse() + if response.status != 200: + raise ValueError(f"official wheel HTTP status {response.status}; redirects forbidden") + with target.open("wb") as stream: + while chunk := response.read(1024 * 1024): + stream.write(chunk) + finally: + connection.close() + if _digest(target) != _text(record["upstream_sha256"]): + raise ValueError("official upstream wheel hash mismatch") + return target + + +def _locked_versions() -> dict[str, str]: + lock = _object(cast(object, tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8")))) + versions: dict[str, set[str]] = {} + for value in _list(lock["package"]): + package = _object(value) + key = _name(_text(package["name"])) + if _object(package.get("source", {})).get("registry") and key not in {"semgrep", "mcp"}: + versions.setdefault(key, set()).add(_text(package["version"])) + if any(len(values) != 1 for values in versions.values()): + raise ValueError("protected uv.lock contains multiple versions; explicit marker-aware constraints required") + return {key: next(iter(values)) for key, values in versions.items()} + + +def _constraints(target: Path) -> None: + versions = _locked_versions() + target.write_text("".join(f"{package}=={versions[package]}\n" for package in sorted(versions)), encoding="utf-8") + + +def _python_in(environment: Path) -> Path: + return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def _inventory(cell: _Cell, executable: Path, label: str) -> dict[str, str]: + program = "import importlib.metadata as m,json,re,sys; d={};\nfor p in m.distributions():\n n=re.sub(r'[-_.]+','-',p.metadata['Name']).lower()\n if n in d: raise RuntimeError('duplicate distribution '+n)\n d[n]=p.version\nsys.stdout.write(json.dumps(d,sort_keys=True))" + payload = _json_object(cell._run(label, [str(executable), "-c", program]).stdout) + return {key: _text(value) for key, value in payload.items()} + + +def _compiled_pins(compiled: Path) -> dict[str, tuple[str, list[str]]]: + blocks = re.sub(r"\\\r?\n", " ", compiled.read_text(encoding="utf-8")).splitlines() + pinned: dict[str, tuple[str, list[str]]] = {} + for line in blocks: + match = re.match(r"^([A-Za-z0-9_.-]+)==([^\s;]+)(.*)$", line) + if match is None: + continue + package, version, tail = match.groups() + key = _name(package) + if key in pinned: + raise ValueError(f"duplicate compiled identity {key}") + if ";" in tail: + raise ValueError("marker-bearing lock requires actual resolver materialization, not marker guessing") + pinned[key] = (version, re.findall(r"--hash=sha256:([a-f0-9]{64})", tail)) + return pinned + + +def _flat_lock(compiled: Path, installed: dict[str, str], wheel: Path, output: Path) -> None: + pinned = _compiled_pins(compiled) + result: list[str] = [] + for package, version in sorted(installed.items()): + hashes = [_digest(wheel)] if package == "semgrep" else pinned.get(package, ("", []))[1] + if package != "semgrep" and pinned.get(package, ("", []))[0] != version: + raise ValueError(f"compiled lock does not bind installed identity {package}=={version}") + if not hashes: + raise ValueError(f"compiled lock does not bind installed hash {package}=={version}") + result.append(f"{package}=={version} " + " ".join(f"--hash=sha256:{value}" for value in hashes)) + if set(pinned) - set(installed) - {"semgrep"}: + raise ValueError("compiled lock contains uninstalled identities") + output.write_text("\n".join(result) + "\n", encoding="utf-8") + + +def _prepare(cell: _Cell, label: str, wheel: Path, constraint: Path) -> tuple[Path, dict[str, str], Path]: + environment = cell.output / f"{label}-env" + executable = _python_in(environment) + source = cell.output / f"{label}.in" + versions = _locked_versions() + tools = [f"{tool}=={versions[tool]}" for tool in _TOOL_NAMES] + source.write_text(str(wheel) + "\n" + "\n".join(tools) + "\n", encoding="utf-8") + lock = cell.output / f"{label}.locked.txt" + cell._run(label + "-venv", ["uv", "venv", "--python", sys.executable, str(environment)]) + cell._run( + label + "-compile", + [ + "uv", + "pip", + "compile", + str(source), + "--python", + str(executable), + "--constraint", + str(constraint), + "--generate-hashes", + "--output-file", + str(lock), + ], + timeout=1200, + ) + cell._run( + label + "-install", + ["uv", "pip", "install", "--python", str(executable), "--require-hashes", "--requirement", str(lock)], + timeout=1200, + ) + cell._run(label + "-pip-check", ["uv", "pip", "check", "--python", str(executable)]) + installed = _inventory(cell, executable, label + "-inventory") + flat = cell.output / f"{label}.actual-hashed.txt" + _flat_lock(lock, installed, wheel, flat) + return executable, installed, flat + + +def _audit(cell: _Cell, executable: Path, requirements: Path, label: str) -> subprocess.CompletedProcess[str]: + return cell._run( + label, + [ + str(executable), + "-m", + "pip_audit", + "--strict", + "--disable-pip", + "--requirement", + str(requirements), + "--format", + "json", + ], + expected=(0, 1), + timeout=120, + ) + + +def _has_expected_cve(vulnerabilities: object) -> bool: + found = False + for value in _list(vulnerabilities): + vulnerability = _object(value) + identities = [_text(vulnerability["id"])] + [_text(alias) for alias in _list(vulnerability.get("aliases", []))] + found = found or "CVE-2026-59951" in identities + return found + + +def _control_dependency(value: object, installed: dict[str, str], seen: dict[str, str]) -> bool: + dependency = _object(value) + key = _name(_text(dependency["name"])) + version = _text(dependency.get("version")) + if key in seen or "skip_reason" in dependency or installed.get(key) != version: + raise ValueError("control audit coverage mismatch or skipped package") + seen[key] = version + has_cve = _has_expected_cve(dependency.get("vulns")) + return key == "mcp" and has_cve + + +def _assert_vulnerable_control(proc: subprocess.CompletedProcess[str], installed: dict[str, str]) -> None: + if proc.returncode != 1: + raise ValueError("control audit did not fail") + payload = _json_object(proc.stdout) + seen: dict[str, str] = {} + found = False + for dependency in _list(payload.get("dependencies")): + expected = _control_dependency(dependency, installed, seen) + found = found or expected + if seen != installed or not found: + raise ValueError("control audit lacks full graph or expected MCP CVE-2026-59951") + + +def _sast_view(proc: subprocess.CompletedProcess[str]) -> dict[str, object]: + payload = _json_object(proc.stdout) + if payload.get("errors"): + raise ValueError("SAST reported errors") + results = _list(payload.get("results")) + paths = _object(payload.get("paths")) + _list(paths.get("scanned")) + return {"results": results, "errors": payload.get("errors", []), "paths": paths} + + +def _scanners(cell: _Cell, executables: dict[str, Path]) -> None: + views: dict[str, dict[str, object]] = {} + rule = cell.output / "control-rule.yml" + rule.write_text( + "rules:\n- id: downstream-positive\n pattern: eval(...)\n message: positive control\n severity: ERROR\n languages: [python]\n", + encoding="utf-8", + ) + positive, negative = cell.output / "positive.py", cell.output / "negative.py" + positive.write_text("eval(user_input)\n", encoding="utf-8") + negative.write_text("print('safe')\n", encoding="utf-8") + for label, executable in executables.items(): + base = [ + str(executable.parent / ("semgrep.exe" if os.name == "nt" else "semgrep")), + "scan", + "--metrics=off", + "--disable-version-check", + "--json", + ] + proc = cell._run(label + "-sast", [*base, "--config", "tools/semgrep/sast.yml"], timeout=1200) + views[label] = _sast_view(proc) + results = cell.output / f"{label}.sast.json" + results.write_text(proc.stdout, encoding="utf-8") + cell._run( + label + "-sast-gate", + [ + str(executable), + "scripts/semgrep_sast_gate.py", + "--results", + str(results), + "--baseline", + "tools/semgrep/sast-baseline.json", + ], + ) + pos = cell._run(label + "-positive", [*base, "--error", "--config", str(rule), str(positive)], expected=(1,)) + neg = cell._run(label + "-negative", [*base, "--error", "--config", str(rule), str(negative)]) + if len(_list(_sast_view(pos)["results"])) != 1 or _sast_view(neg)["results"]: + raise ValueError("positive/negative scanner findings mismatch") + if views["candidate"] != views["control"]: + raise ValueError("candidate/control SAST findings/errors/scanned/skipped paths differ") + cell.report["sast_paths"] = len(_list(_object(views["candidate"]["paths"])["scanned"])) + cell.checks.update(sast=True, positive_control=True, negative_control=True) + + +_SOURCE_FILES = ( + "scripts/downstream_semgrep.py", + "scripts/verify_downstream_semgrep.py", + "ci/downstream-semgrep-recipe.json", + "tools/downstream_semgrep/test_mcp_regressions.py", + ".github/workflows/downstream-semgrep.yml", + "uv.lock", + "tools/semgrep/sast.yml", + "tools/semgrep/sast-baseline.json", + "scripts/semgrep_sast_gate.py", +) + + +@dataclass(frozen=True) +class _Environment: + python: Path + inventory: dict[str, str] + flat: Path + + +def _source_binding(cell: _Cell) -> None: + commit = cell._run("source-commit", ["git", "rev-parse", "HEAD"]).stdout.strip() + if not re.fullmatch(r"[a-f0-9]{40}", commit): + raise ValueError("source commit is not full SHA") + tracked = cell._run("source-status-tracked", ["git", "status", "--porcelain", "--untracked-files=no"]).stdout + untracked = cell._run( + "source-status-untracked", + [ + "git", + "ls-files", + "--others", + "--exclude-standard", + "--", + *_SOURCE_FILES, + "tests/unit/scripts/test_downstream_semgrep.py", + ], + ).stdout + diff = cell._run("source-diff", ["git", "diff", "--binary", "HEAD"]).stdout + cell.report.update( + source_commit=commit, + source_dirty=bool(tracked.strip() or untracked.strip()), + source_files={path: _digest(ROOT / path) for path in _SOURCE_FILES}, + source_diff_sha256=hashlib.sha256(diff.encode("utf-8")).hexdigest(), + ) + + +def _cell_runtime(cell: _Cell, platform_tag: str, python_version: str) -> None: + cell.report["runtime"] = _runtime(platform_tag, python_version) + cell.report["native"] = True + cell.report["uv_version"] = cell._run("uv-version", ["uv", "--version"]).stdout.strip() + uv_executable = shutil.which("uv") + if not uv_executable: + raise ValueError("uv executable unavailable") + cell.report["uv_executable_sha256"] = _digest(Path(uv_executable)) + _source_binding(cell) + + +def _cell_build(cell: _Cell, recipe_path: Path, platform_tag: str, python_version: str) -> tuple[CandidateWheel, Path]: + from scripts.downstream_semgrep import build_candidate + + recipe = _json_object(recipe_path.read_text(encoding="utf-8")) + records = [_object(value) for value in _list(recipe["wheels"]) if _object(value)["platform_tag"] == platform_tag] + if len(records) != 1 or python_version not in _list(recipe["python_versions"]): + raise ValueError("cell not in recipe") + upstream = _download(records[0], cell.output) + first = build_candidate(recipe_path, upstream, cell.output / "build-one") + second = build_candidate(recipe_path, upstream, cell.output / "build-two") + if first.sha256 != second.sha256 or first.path.read_bytes() != second.path.read_bytes(): + raise ValueError("independent candidate builds differ") + cell.report.update( + upstream_sha256=_digest(upstream), candidate_sha256=first.sha256, recipe_sha256=first.recipe_sha256 + ) + return first, upstream + + +def _compare_graphs(candidate: _Environment, control: _Environment) -> None: + expected = ((candidate.inventory, "1.30.0", "1.179.0+specfact.1"), (control.inventory, "1.29.0", "1.179.0")) + for graph, mcp, semgrep in expected: + if graph.get("mcp") != mcp or graph.get("semgrep") != semgrep: + raise ValueError("candidate/control reviewed identities mismatch") + + def remaining(graph: dict[str, str]) -> dict[str, str]: + return {key: value for key, value in graph.items() if key not in {"semgrep", "mcp"}} + + if remaining(candidate.inventory) != remaining(control.inventory): + raise ValueError("paired actual graphs differ beyond Semgrep/MCP") + + +def _cell_install(cell: _Cell, candidate: CandidateWheel, upstream: Path) -> tuple[_Environment, _Environment]: + constraint = cell.output / "protected-constraints.txt" + _constraints(constraint) + actual = _Environment(*_prepare(cell, "candidate", candidate.path, constraint)) + control = _Environment(*_prepare(cell, "control", upstream, constraint)) + _compare_graphs(actual, control) + cell.report.update(actual_inventory=actual.inventory, control_inventory=control.inventory) + cell.checks.update(install=True, pip_check=True) + bound = candidate.candidate_audit_input(actual.flat, cell.output / "candidate.audit.txt") + bound.assert_complete_result(_audit(cell, actual.python, bound.requirements, "candidate-audit")) + _assert_vulnerable_control(_audit(cell, control.python, control.flat, "control-audit"), control.inventory) + cell.report.update(candidate_audit_passed=True, control_expected_cve_confirmed=True) + return actual, control + + +def _mcp_regressions(cell: _Cell, candidate: _Environment, control: _Environment) -> None: + regression = ROOT / "tools/downstream_semgrep/test_mcp_regressions.py" + cell._run("candidate-mcp-regressions", [str(candidate.python), str(regression)], timeout=240) + cell.checks.update(mcp_stdio=True, mcp_lifecycle=True) + proc = cell._run( + "control-mcp-regression", [str(control.python), str(regression), "TestMcpLifecycle"], expected=(1,), timeout=60 + ) + required = ("FAILED (failures=8)", "DELETE retained a registered session") + if not all(message in proc.stderr for message in required) or "ERROR:" in proc.stderr: + raise ValueError("control failed for unexpected MCP regression reason") + cell.report["control_expected_regression_confirmed"] = True + + +def _execute_cell(recipe: Path, platform_tag: str, python_version: str, output: Path) -> int: + output.mkdir(parents=True, exist_ok=False) + report: dict[str, object] = { + "platform": platform_tag, + "python_version": python_version, + "status": "failed", + "native": False, + "candidate_only": True, + "production_admission": "not_requested", + } + cell = _Cell(output, report) + try: + _cell_runtime(cell, platform_tag, python_version) + candidate, upstream = _cell_build(cell, recipe, platform_tag, python_version) + actual, control = _cell_install(cell, candidate, upstream) + _scanners(cell, {"candidate": actual.python, "control": control.python}) + _mcp_regressions(cell, actual, control) + cell.report["status"] = "passed" + return 0 + except Exception as error: + cell.report["failure"] = f"{type(error).__name__}: {error}" + return 1 + finally: + (output / "report.json").write_text(json.dumps(cell.report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _matrix(recipe_path: Path) -> dict[str, object]: + from scripts.downstream_semgrep import required_matrix + + recipe = _json_object(recipe_path.read_text(encoding="utf-8")) + wheels = {_text(_object(value)["platform_tag"]): _object(value) for value in _list(recipe["wheels"])} + rows: list[dict[str, str]] = [] + for cell in required_matrix(recipe_path): + record = wheels[cell["platform"]] + image = _object(record.get("containers", {})).get(cell["python_version"], "") + if not isinstance(image, str): + raise ValueError("container image must be text") + rows.append(dict(cell, runner=_text(record["runner"]), container_image=image)) + return {"include": rows} + + +def _uv_wheel(record: dict[str, object], version: str) -> tuple[str, str]: + filename, sha = _text(record["filename"]), _text(record["sha256"]) + _official_wheel_url(_text(record["url"]), filename) + architecture = re.search(r"musllinux_\d+_\d+_(aarch64|x86_64)\.whl$", filename) + if not filename.startswith(f"uv-{version}-py3-none-") or architecture is None: + raise ValueError("invalid exact uv wheel identity") + if not re.fullmatch(r"[a-f0-9]{64}", sha): + raise ValueError("invalid uv wheel hash") + return sha, architecture.group(1) + + +def _uv_requirements(recipe_path: Path) -> str: + bootstrap = _object(_json_object(recipe_path.read_text(encoding="utf-8"))["uv_bootstrap"]) + version = _text(bootstrap["version"]) + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError("invalid exact uv version") + wheels = [_uv_wheel(_object(value), version) for value in _list(bootstrap["wheels"])] + if len(wheels) != 2 or {arch for _, arch in wheels} != {"aarch64", "x86_64"}: + raise ValueError("exactly two distinct native musl uv wheels required") + hashes = {sha for sha, _ in wheels} + if len(hashes) != 2: + raise ValueError("duplicate uv bootstrap wheel hash") + return f"uv=={version} " + " ".join(f"--hash=sha256:{sha}" for sha in sorted(hashes)) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--recipe", type=Path, default=RECIPE) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("matrix") + sub.add_parser("uv-requirements") + run = sub.add_parser("cell") + run.add_argument("--platform", required=True) + run.add_argument("--python-version", required=True) + run.add_argument("--output", required=True, type=Path) + aggregate = sub.add_parser("aggregate") + aggregate.add_argument("--reports", required=True, type=Path) + args = parser.parse_args() + recipe = Path(args.recipe).resolve() + if args.command == "matrix": + sys.stdout.write(json.dumps(_matrix(recipe), separators=(",", ":")) + "\n") + return 0 + if args.command == "uv-requirements": + sys.stdout.write(_uv_requirements(recipe)) + return 0 + if args.command == "cell": + return _execute_cell(recipe, str(args.platform), str(args.python_version), Path(args.output).resolve()) + from scripts.downstream_semgrep import aggregate_candidate_reports + + paths = sorted(Path(args.reports).rglob("report.json")) + reports = [_json_object(path.read_text(encoding="utf-8")) for path in paths] + sys.stdout.write(json.dumps(aggregate_candidate_reports(recipe, reports), sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/scripts/test_downstream_semgrep.py b/tests/unit/scripts/test_downstream_semgrep.py index 9ef7683d..a5636f9c 100644 --- a/tests/unit/scripts/test_downstream_semgrep.py +++ b/tests/unit/scripts/test_downstream_semgrep.py @@ -1,5 +1,3 @@ -"""Observable downstream wheel, admission and audit contracts with synthetic wheels.""" - from __future__ import annotations import base64 @@ -16,11 +14,100 @@ import zipfile from datetime import date, timedelta from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING, NotRequired, Protocol, TypedDict, cast from unittest.mock import patch import pytest +if TYPE_CHECKING: + from scripts.downstream_semgrep import AdmittedWheel, BoundAuditInput, CandidateWheel + + +class WheelEntry(TypedDict): + filename: str + platform_tag: str + upstream_sha256: str + upstream_url: str + + +class Recipe(TypedDict): + schema_version: int + package: str + upstream_version: str + downstream_version: str + mcp_from: str + mcp_to: str + python_versions: list[str] + build_runtime_digest: str + wheels: list[WheelEntry] + + +class MatrixReport(TypedDict): + source_commit: str + source_dirty: bool + platform: str + python_version: str + candidate_sha256: str + upstream_sha256: str + recipe_sha256: str + status: str + native: bool + checks: dict[str, bool] + + +class Artifact(TypedDict): + filename: str + candidate_sha256: str + upstream_sha256: str + platform_tag: str + + +class Approval(TypedDict): + recipe_sha256: str + matrix_sha256: str + source_commit: str + signer_commit: str + reviewed_on: str + expires_on: str + artifacts: list[Artifact] + + +class Policy(TypedDict): + schema_version: int + admissions: list[Approval] + + +class AuditDependency(TypedDict): + name: str + version: str + vulns: list[object] + skip_reason: NotRequired[str] + + +class AuditResult(TypedDict): + dependencies: list[AuditDependency] + + +class DownstreamModule(Protocol): + subprocess: ModuleType + + def build_candidate(self, recipe_path: Path, upstream_wheel: Path, output_directory: Path) -> CandidateWheel: ... + + def verify_candidate(self, recipe_path: Path, upstream_wheel: Path, candidate_wheel: Path) -> CandidateWheel: ... + + def required_matrix(self, recipe_path: Path) -> list[dict[str, str]]: ... + + def aggregate_candidate_reports(self, recipe_path: Path, reports: list[MatrixReport]) -> dict[str, object]: ... + + def admit_artifact(self, wheel: Path, evidence_bundle: Path) -> AdmittedWheel: ... + + +WheelRecipe = tuple[Path, Recipe, Path] +WheelMember = tuple[str, bytes, int] + + PLATFORMS = ( "macosx_10_14_x86_64", "macosx_11_0_arm64", @@ -35,7 +122,7 @@ TAGS = "cp310.cp311.cp312.cp313.cp314.py310.py311.py312.py313.py314-none" -def _module(): +def _module() -> DownstreamModule: root = Path(__file__).resolve().parents[3] path = root / "scripts/downstream_semgrep.py" assert path.is_file(), f"Downstream admission boundary is absent: {path}" @@ -44,7 +131,7 @@ def _module(): module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) - return module + return cast(DownstreamModule, module) def _sha(data: bytes) -> str: @@ -81,7 +168,7 @@ def _members(version: str = UPSTREAM) -> list[tuple[str, bytes, int]]: def _write_wheel(path: Path, members: list[tuple[str, bytes, int]], version: str = UPSTREAM) -> None: record_path = f"semgrep-{version}.dist-info/RECORD" - rows = [] + rows: list[tuple[str, str, str]] = [] for name, content, _mode in members: digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).decode().rstrip("=") rows.append((name, "sha256=" + digest, str(len(content)))) @@ -100,10 +187,10 @@ def _write_wheel(path: Path, members: list[tuple[str, bytes, int]], version: str @pytest.fixture -def wheel_recipe(tmp_path): +def wheel_recipe(tmp_path: Path) -> WheelRecipe: upstream = tmp_path / f"semgrep-{UPSTREAM}-{TAGS}-{PLATFORMS[0]}.whl" _write_wheel(upstream, _members()) - recipe = { + recipe: Recipe = { "schema_version": 1, "package": "semgrep", "upstream_version": UPSTREAM, @@ -142,16 +229,17 @@ def _assert_record(archive: zipfile.ZipFile) -> None: assert size == str(len(content)) -def test_build_is_reproducible_and_preserves_complete_runtime(wheel_recipe, tmp_path): - module = _module() - recipe_path, _recipe, upstream = wheel_recipe - one = module.build_candidate(recipe_path, upstream, tmp_path / "one") - two = module.build_candidate(recipe_path, upstream, tmp_path / "two") - assert one.path.read_bytes() == two.path.read_bytes() - assert one.sha256 == _sha(one.path.read_bytes()) - assert one.recipe_sha256 == _sha(recipe_path.read_bytes()) - assert one.platform == PLATFORMS[0] - with zipfile.ZipFile(upstream) as original, zipfile.ZipFile(one.path) as built: +def _assert_metadata_transform(original: zipfile.ZipFile, built: zipfile.ZipFile) -> None: + expected = ( + original.read(f"semgrep-{UPSTREAM}.dist-info/METADATA") + .replace(b"Version: 1.179.0\n", b"Version: 1.179.0+specfact.1\n") + .replace(b"Requires-Dist: mcp==1.29.0\n", b"Requires-Dist: mcp==1.30.0\n") + ) + assert built.read(f"semgrep-{DOWNSTREAM}.dist-info/METADATA") == expected + + +def _assert_runtime_preserved(upstream: Path, candidate: Path) -> None: + with zipfile.ZipFile(upstream) as original, zipfile.ZipFile(candidate) as built: _assert_record(built) expected_names = { name.replace(f"semgrep-{UPSTREAM}.", f"semgrep-{DOWNSTREAM}.", 1) for name in original.namelist() @@ -162,17 +250,24 @@ def test_build_is_reproducible_and_preserves_complete_runtime(wheel_recipe, tmp_ assert built.getinfo(renamed).external_attr == original.getinfo(name).external_attr if not name.endswith(("/METADATA", "/RECORD")): assert built.read(renamed) == original.read(name) - expected = ( - original.read(f"semgrep-{UPSTREAM}.dist-info/METADATA") - .replace(b"Version: 1.179.0\n", b"Version: 1.179.0+specfact.1\n") - .replace(b"Requires-Dist: mcp==1.29.0\n", b"Requires-Dist: mcp==1.30.0\n") - ) - assert built.read(f"semgrep-{DOWNSTREAM}.dist-info/METADATA") == expected + _assert_metadata_transform(original, built) + + +def test_build_is_reproducible_and_preserves_complete_runtime(wheel_recipe: WheelRecipe, tmp_path: Path) -> None: + module = _module() + recipe_path, _recipe, upstream = wheel_recipe + one = module.build_candidate(recipe_path, upstream, tmp_path / "one") + two = module.build_candidate(recipe_path, upstream, tmp_path / "two") + assert one.path.read_bytes() == two.path.read_bytes() + assert one.sha256 == _sha(one.path.read_bytes()) + assert one.recipe_sha256 == _sha(recipe_path.read_bytes()) + assert one.platform == PLATFORMS[0] + _assert_runtime_preserved(upstream, one.path) verified = module.verify_candidate(recipe_path, upstream, one.path) assert (verified.path, verified.sha256, verified.platform) == (one.path, one.sha256, one.platform) -def test_wrong_upstream_hash_cannot_write_candidate(wheel_recipe, tmp_path): +def test_wrong_upstream_hash_cannot_write_candidate(wheel_recipe: WheelRecipe, tmp_path: Path) -> None: module = _module() recipe_path, _recipe, upstream = wheel_recipe upstream.write_bytes(upstream.read_bytes() + b"tamper") @@ -182,31 +277,41 @@ def test_wrong_upstream_hash_cannot_write_candidate(wheel_recipe, tmp_path): assert not list(output.glob("*.whl")) -@pytest.mark.parametrize( - "damage", ["duplicate", "traversal", "symlink", "signature", "bad-record", "duplicate-mcp"], ids=str -) -def test_unsafe_upstream_archive_rejects_even_with_reviewed_hash(wheel_recipe, tmp_path, damage): - module = _module() - recipe_path, recipe, upstream = wheel_recipe +def _corrupt_record(upstream: Path) -> None: + with zipfile.ZipFile(upstream) as archive: + entries = [(item, archive.read(item)) for item in archive.infolist()] + with zipfile.ZipFile(upstream, "w") as archive: + for item, content in entries: + archive.writestr(item, b"wrong,sha256=bad,1\n" if item.filename.endswith("/RECORD") else content) + + +def _damage_upstream(upstream: Path, damage: str) -> None: members = _members() if damage == "duplicate": members.append(members[-1]) - elif damage == "traversal": + if damage == "traversal": members.append(("../escape", b"outside", 0o100644)) - elif damage == "symlink": + if damage == "symlink": members.append(("semgrep-link", b"/tmp/outside", stat.S_IFLNK | 0o777)) - elif damage == "signature": + if damage == "signature": members.append((f"semgrep-{UPSTREAM}.dist-info/RECORD.jws", b"stale-signature", 0o100644)) - elif damage == "duplicate-mcp": + if damage == "duplicate-mcp": name, content, mode = members[0] members[0] = (name, content.replace(b"Requires-Dist: mcp==1.29.0\n", b"Requires-Dist: mcp==1.29.0\n" * 2), mode) _write_wheel(upstream, members) if damage == "bad-record": - with zipfile.ZipFile(upstream) as archive: - entries = [(item, archive.read(item)) for item in archive.infolist()] - with zipfile.ZipFile(upstream, "w") as archive: - for item, content in entries: - archive.writestr(item, b"wrong,sha256=bad,1\n" if item.filename.endswith("/RECORD") else content) + _corrupt_record(upstream) + + +@pytest.mark.parametrize( + "damage", ["duplicate", "traversal", "symlink", "signature", "bad-record", "duplicate-mcp"], ids=str +) +def test_unsafe_upstream_archive_rejects_even_with_reviewed_hash( + wheel_recipe: WheelRecipe, tmp_path: Path, damage: str +) -> None: + module = _module() + recipe_path, recipe, upstream = wheel_recipe + _damage_upstream(upstream, damage) recipe["wheels"][0]["upstream_sha256"] = _sha(upstream.read_bytes()) _write_json(recipe_path, recipe) output = tmp_path / "rejected" @@ -216,27 +321,44 @@ def test_unsafe_upstream_archive_rejects_even_with_reviewed_hash(wheel_recipe, t assert not (tmp_path / "escape").exists() -@pytest.mark.parametrize("damage", ["runtime", "mode", "entrypoint", "other-metadata"], ids=str) -def test_candidate_rejects_unauthorized_change_with_valid_record(wheel_recipe, tmp_path, damage): - module = _module() - recipe_path, _recipe, upstream = wheel_recipe - candidate = module.build_candidate(recipe_path, upstream, tmp_path / "candidate") - with zipfile.ZipFile(candidate.path) as archive: +def _changed_member(member: WheelMember, damage: str) -> WheelMember: + name, content, mode = member + if damage == "runtime": + return name, b"raise RuntimeError('changed')\n", mode + if damage == "mode": + return name, content, 0o100644 + if damage == "entrypoint": + return name, b"[console_scripts]\nsemgrep = evil:main\n", mode + return name, content.replace(b"click>=8", b"click>=1"), mode + + +def _damage_candidate(candidate: Path, damage: str) -> None: + with zipfile.ZipFile(candidate) as archive: members = [ (item.filename, archive.read(item), item.external_attr >> 16) for item in archive.infolist() if not item.filename.endswith("/RECORD") ] - for index, (name, content, mode) in enumerate(members): - if damage == "runtime" and name.endswith("main.py"): - members[index] = (name, b"raise RuntimeError('changed')\n", mode) - elif damage == "mode" and name.endswith("semgrep-core"): - members[index] = (name, content, 0o100644) - elif damage == "entrypoint" and name.endswith("entry_points.txt"): - members[index] = (name, b"[console_scripts]\nsemgrep = evil:main\n", mode) - elif damage == "other-metadata" and name.endswith("/METADATA"): - members[index] = (name, content.replace(b"click>=8", b"click>=1"), mode) - _write_wheel(candidate.path, members, DOWNSTREAM) + target = { + "runtime": "main.py", + "mode": "semgrep-core", + "entrypoint": "entry_points.txt", + "other-metadata": "/METADATA", + }[damage] + for index, member in enumerate(members): + if member[0].endswith(target): + members[index] = _changed_member(member, damage) + _write_wheel(candidate, members, DOWNSTREAM) + + +@pytest.mark.parametrize("damage", ["runtime", "mode", "entrypoint", "other-metadata"], ids=str) +def test_candidate_rejects_unauthorized_change_with_valid_record( + wheel_recipe: WheelRecipe, tmp_path: Path, damage: str +) -> None: + module = _module() + recipe_path, _recipe, upstream = wheel_recipe + candidate = module.build_candidate(recipe_path, upstream, tmp_path / "candidate") + _damage_candidate(candidate.path, damage) with pytest.raises(ValueError): module.verify_candidate(recipe_path, upstream, candidate.path) @@ -246,7 +368,7 @@ def test_candidate_rejects_unauthorized_change_with_valid_record(wheel_recipe, t ["1.179.0+attacker.1", "1.179.0", "1.174.0+specfact.1"], ids=["unknown-local", "masquerade", "below-floor"], ) -def test_unreviewed_downstream_identity_rejects(wheel_recipe, tmp_path, version): +def test_unreviewed_downstream_identity_rejects(wheel_recipe: WheelRecipe, tmp_path: Path, version: str) -> None: module = _module() recipe_path, recipe, upstream = wheel_recipe recipe["downstream_version"] = version @@ -255,7 +377,7 @@ def test_unreviewed_downstream_identity_rejects(wheel_recipe, tmp_path, version) module.build_candidate(recipe_path, upstream, tmp_path / "output") -def test_matrix_requires_all_seven_platforms_and_three_interpreters(wheel_recipe): +def test_matrix_requires_all_seven_platforms_and_three_interpreters(wheel_recipe: WheelRecipe) -> None: module = _module() recipe_path, _recipe, _upstream = wheel_recipe matrix = module.required_matrix(recipe_path) @@ -265,7 +387,7 @@ def test_matrix_requires_all_seven_platforms_and_three_interpreters(wheel_recipe } -def _reports(recipe_path, recipe): +def _reports(recipe_path: Path, recipe: Recipe) -> list[MatrixReport]: return [ { "platform": wheel["platform_tag"], @@ -275,6 +397,8 @@ def _reports(recipe_path, recipe): "recipe_sha256": _sha(recipe_path.read_bytes()), "status": "passed", "native": True, + "source_commit": "d" * 40, + "source_dirty": False, "checks": dict.fromkeys( ("install", "pip_check", "sast", "positive_control", "negative_control", "mcp_stdio", "mcp_lifecycle"), True, @@ -285,34 +409,51 @@ def _reports(recipe_path, recipe): ] -@pytest.mark.parametrize("damage", ["missing", "duplicate", "failed", "emulated", "skipped-check"], ids=str) -def test_matrix_rejects_incomplete_or_non_native_evidence(wheel_recipe, damage): - module = _module() - recipe_path, recipe, _upstream = wheel_recipe - reports = _reports(recipe_path, recipe) +def _damage_matrix(reports: list[MatrixReport], damage: str) -> None: if damage == "missing": reports.pop() - elif damage == "duplicate": + return + if damage == "duplicate": reports[-1] = copy.deepcopy(reports[0]) - elif damage == "failed": + return + if damage == "failed": reports[-1]["status"] = "failed" - elif damage == "emulated": + return + if damage == "emulated": reports[-1]["native"] = False - else: - reports[-1]["checks"]["mcp_lifecycle"] = False + return + reports[-1]["checks"]["mcp_lifecycle"] = False + + +@pytest.mark.parametrize("damage", ["missing", "duplicate", "failed", "emulated", "skipped-check"], ids=str) +def test_matrix_rejects_incomplete_or_non_native_evidence(wheel_recipe: WheelRecipe, damage: str) -> None: + module = _module() + recipe_path, recipe, _upstream = wheel_recipe + reports = _reports(recipe_path, recipe) + _damage_matrix(reports, damage) with pytest.raises(ValueError): module.aggregate_candidate_reports(recipe_path, reports) -def test_complete_candidate_matrix_does_not_grant_production_admission(wheel_recipe): +def test_complete_candidate_matrix_does_not_grant_production_admission(wheel_recipe: WheelRecipe) -> None: module = _module() recipe_path, recipe, _upstream = wheel_recipe report = module.aggregate_candidate_reports(recipe_path, _reports(recipe_path, recipe)) assert report["production_admission"] == "not_requested" assert report["status"] == "passed" + reports = _reports(recipe_path, recipe) + reports[0]["source_dirty"] = True + with pytest.raises(ValueError): + module.aggregate_candidate_reports(recipe_path, reports) + reports[0]["source_dirty"] = False + reports[0]["source_commit"] = "e" * 40 + with pytest.raises(ValueError): + module.aggregate_candidate_reports(recipe_path, reports) -def _protected_fixture(module, wheel_recipe, tmp_path): +def _protected_fixture( + module: DownstreamModule, wheel_recipe: WheelRecipe, tmp_path: Path +) -> tuple[CandidateWheel, Path, Policy]: recipe_path, recipe, upstream = wheel_recipe candidate = module.build_candidate(recipe_path, upstream, tmp_path / "candidate") bundle = tmp_path / "evidence" @@ -325,7 +466,7 @@ def _protected_fixture(module, wheel_recipe, tmp_path): if report["platform"] == PLATFORMS[0]: report["candidate_sha256"] = candidate.sha256 matrix = _write_json(bundle / "matrix.json", reports) - policy = { + policy: Policy = { "schema_version": 1, "admissions": [ { @@ -350,7 +491,7 @@ def _protected_fixture(module, wheel_recipe, tmp_path): return candidate, bundle, policy -def test_exact_protected_boundary_admits_only_the_verified_wheel(wheel_recipe, tmp_path): +def test_exact_protected_boundary_admits_only_the_verified_wheel(wheel_recipe: WheelRecipe, tmp_path: Path) -> None: module = _module() candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) with ( @@ -360,10 +501,17 @@ def test_exact_protected_boundary_admits_only_the_verified_wheel(wheel_recipe, t admitted = module.admit_artifact(candidate.path, bundle) assert admitted.wheel == candidate.path verifier.assert_called_once_with(candidate.path, bundle / "provenance.jsonl", "d" * 40, "e" * 40) + policy["admissions"][0]["source_commit"] = "f" * 40 + with ( + patch.object(module, "_fetch_protected_policy", return_value=policy), + patch.object(module, "_verify_provenance", side_effect=AssertionError("Wrong source reached verifier")), + pytest.raises(ValueError, match="Approved source differs"), + ): + module.admit_artifact(candidate.path, bundle) @pytest.mark.parametrize("damage", ["empty-policy", "expired", "wrong-hash", "wrong-matrix"], ids=str) -def test_unapproved_artifact_rejects_before_verifier(wheel_recipe, tmp_path, damage): +def test_unapproved_artifact_rejects_before_verifier(wheel_recipe: WheelRecipe, tmp_path: Path, damage: str) -> None: module = _module() candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) if damage == "empty-policy": @@ -385,7 +533,9 @@ def test_unapproved_artifact_rejects_before_verifier(wheel_recipe, tmp_path, dam @pytest.mark.parametrize("failure", ["pr-origin", "missing-verifier", "timeout"], ids=str) -def test_verifier_failure_cannot_turn_claims_into_admission(wheel_recipe, tmp_path, failure): +def test_verifier_failure_cannot_turn_claims_into_admission( + wheel_recipe: WheelRecipe, tmp_path: Path, failure: str +) -> None: module = _module() candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) _write_json( @@ -412,7 +562,9 @@ def test_verifier_failure_cannot_turn_claims_into_admission(wheel_recipe, tmp_pa assert "--deny-self-hosted-runners" in argv -def _bound_audit(module, wheel_recipe, tmp_path): +def _bound_audit( + module: DownstreamModule, wheel_recipe: WheelRecipe, tmp_path: Path +) -> tuple[AdmittedWheel, Path, CandidateWheel]: candidate, bundle, policy = _protected_fixture(module, wheel_recipe, tmp_path) actual = tmp_path / "actual.txt" actual.write_text( @@ -429,7 +581,7 @@ def _bound_audit(module, wheel_recipe, tmp_path): return admitted, actual, candidate -def _audit_result(): +def _audit_result() -> AuditResult: return { "dependencies": [ {"name": "semgrep", "version": UPSTREAM, "vulns": []}, @@ -439,21 +591,39 @@ def _audit_result(): } -def test_bound_audit_preserves_real_graph_and_reports_local_identity(wheel_recipe, tmp_path): - module = _module() - admitted, actual, candidate = _bound_audit(module, wheel_recipe, tmp_path) - output = tmp_path / "audit.txt" - original = actual.read_bytes() - bound = admitted.audit_input(actual, output) +def _assert_audit_view( + actual: Path, output: Path, original: bytes, candidate_sha256: str, upstream_sha256: str +) -> None: rendered = output.read_text() - assert bound.requirements == output assert actual.read_bytes() == original assert f"semgrep=={UPSTREAM}" in rendered assert DOWNSTREAM not in rendered - assert candidate.sha256 not in rendered - assert wheel_recipe[1]["wheels"][0]["upstream_sha256"] in rendered + assert candidate_sha256 not in rendered + assert upstream_sha256 in rendered for line in actual.read_text().splitlines()[1:]: assert line in rendered + + +def _assert_audit_status_output(bound: BoundAuditInput, production_admission: str) -> None: + report = bound.assert_complete_result( + subprocess.CompletedProcess(["pip-audit"], 0, json.dumps(_audit_result()), "No known vulnerabilities found\n") + ) + assert report["actual_inventory"] == bound.actual_inventory + assert report["production_admission"] == production_admission + with pytest.raises(ValueError): + bound.assert_complete_result( + subprocess.CompletedProcess(["pip-audit"], 0, json.dumps(_audit_result()), "HTTP503 advisory backend\n") + ) + + +def test_bound_audit_preserves_real_graph_and_reports_local_identity(wheel_recipe: WheelRecipe, tmp_path: Path) -> None: + module = _module() + admitted, actual, candidate = _bound_audit(module, wheel_recipe, tmp_path) + output = tmp_path / "audit.txt" + original = actual.read_bytes() + bound = admitted.audit_input(actual, output) + assert bound.requirements == output + _assert_audit_view(actual, output, original, candidate.sha256, wheel_recipe[1]["wheels"][0]["upstream_sha256"]) assert bound.actual_inventory == {"semgrep": DOWNSTREAM, "mcp": "1.30.0", "click": "8.3.0"} report = bound.assert_complete_result( subprocess.CompletedProcess(["pip-audit"], 0, json.dumps(_audit_result()), "") @@ -468,37 +638,54 @@ def test_bound_audit_preserves_real_graph_and_reports_local_identity(wheel_recip ) assert candidate_report["actual_inventory"] == bound.actual_inventory assert candidate_report["production_admission"] == "not_requested" + _assert_audit_status_output(bound, "admitted") + _assert_audit_status_output(candidate_bound, "not_requested") -@pytest.mark.parametrize( - "damage", ["empty-skip", "missing", "duplicate", "wrong-version", "malformed-vuln", "advisory-and-error"], ids=str -) -def test_bound_audit_rejects_incomplete_or_unsafe_responses(wheel_recipe, tmp_path, damage): - module = _module() - admitted, actual, _candidate = _bound_audit(module, wheel_recipe, tmp_path) - bound = admitted.audit_input(actual, tmp_path / "audit.txt") +def _damaged_audit_response(damage: str) -> tuple[AuditResult, int, str]: result = _audit_result() deps = result["dependencies"] returncode, stderr = 0, "" if damage == "empty-skip": deps[-1]["skip_reason"] = "" - elif damage == "missing": + if damage == "missing": deps.pop() - elif damage == "duplicate": + if damage == "duplicate": deps.append(copy.deepcopy(deps[0])) - elif damage == "wrong-version": + if damage == "wrong-version": deps[1]["version"] = "1.29.0" - elif damage == "malformed-vuln": + if damage == "malformed-vuln": deps[0]["vulns"] = [None] - else: + if damage == "advisory-and-error": deps[1]["vulns"] = [{"id": "CVE-2026-59951"}] returncode, stderr = 2, "HTTP 503 fetching another dependency" + return result, returncode, stderr + + +@pytest.mark.parametrize( + "damage", ["empty-skip", "missing", "duplicate", "wrong-version", "malformed-vuln", "advisory-and-error"], ids=str +) +def test_bound_audit_rejects_incomplete_or_unsafe_responses( + wheel_recipe: WheelRecipe, tmp_path: Path, damage: str +) -> None: + module = _module() + admitted, actual, _candidate = _bound_audit(module, wheel_recipe, tmp_path) + bound = admitted.audit_input(actual, tmp_path / "audit.txt") + result, returncode, stderr = _damaged_audit_response(damage) with pytest.raises(ValueError): bound.assert_complete_result(subprocess.CompletedProcess(["pip-audit"], returncode, json.dumps(result), stderr)) + if damage == "malformed-vuln": + for constant in ("NaN", "Infinity", "-Infinity"): + invalid_json = json.dumps(_audit_result())[:-1] + ', "unknown":' + constant + "}" + with pytest.raises(ValueError): + bound.assert_complete_result(subprocess.CompletedProcess(["pip-audit"], 0, invalid_json, "")) + @pytest.mark.parametrize("damage", ["vulnerable-mcp", "wrong-local-hash", "marker"], ids=str) -def test_bound_audit_rejects_unbound_inventory_before_writing_view(wheel_recipe, tmp_path, damage): +def test_bound_audit_rejects_unbound_inventory_before_writing_view( + wheel_recipe: WheelRecipe, tmp_path: Path, damage: str +) -> None: module = _module() admitted, actual, candidate = _bound_audit(module, wheel_recipe, tmp_path) contents = actual.read_text() diff --git a/tools/downstream_semgrep/test_mcp_regressions.py b/tools/downstream_semgrep/test_mcp_regressions.py new file mode 100644 index 00000000..86f5bf1b --- /dev/null +++ b/tools/downstream_semgrep/test_mcp_regressions.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +import inspect +import os +import secrets +import shutil +import tempfile +import time +import unittest +from collections.abc import Callable +from pathlib import Path +from typing import Protocol, cast +from unittest.mock import patch + +import httpx +from icontract import ensure +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.server.fastmcp import FastMCP +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + + +HEADERS = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", +} +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "downstream-regression", "version": "1"}, + }, +} + + +class _FixedManagerDefaults(Protocol): + max_sessions: int + session_idle_timeout: float | None + + +class _FixedManagerFactory(Protocol): + def __call__( + self, + app: object, + *, + json_response: bool, + session_idle_timeout: float | None, + max_sessions: int | None, + max_request_body_size: int, + ) -> StreamableHTTPSessionManager: ... + + +async def _await_reclaimed(manager: StreamableHTTPSessionManager, session: str) -> None: + async with asyncio.timeout(5): + while session in manager._server_instances: + await asyncio.sleep(0.02) + + +class TestMcpLifecycle(unittest.IsolatedAsyncioTestCase): + def _manager( + self, + *, + session_idle_timeout: float | None = 1800, + max_sessions: int | None = 10000, + max_request_body_size: int = 4194304, + ) -> StreamableHTTPSessionManager: + parameters = inspect.signature(StreamableHTTPSessionManager).parameters + self.assertIn("session_idle_timeout", parameters) + self.assertIn("max_sessions", parameters) + factory = cast(_FixedManagerFactory, StreamableHTTPSessionManager) + return factory( + Server("downstream-regression"), + json_response=True, + session_idle_timeout=session_idle_timeout, + max_sessions=max_sessions, + max_request_body_size=max_request_body_size, + ) + + @ensure(lambda result: result is None) + async def test_fixed_defaults(self) -> None: + manager = self._manager() + self.assertEqual(manager.session_idle_timeout, 1800) + self.assertEqual(cast(_FixedManagerDefaults, manager).max_sessions, 10000) + + @ensure(lambda result: result is None) + async def test_delete_discards_default_manager_registration(self) -> None: + manager = StreamableHTTPSessionManager(Server("delete-control"), json_response=True) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + first = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(first.status_code, 200, first.text) + deleted = await client.delete("/mcp", headers={"mcp-session-id": first.headers["mcp-session-id"]}) + self.assertEqual(deleted.status_code, 200, deleted.text) + retained = len(manager._server_instances) + self.assertEqual(retained, 0, "DELETE retained a registered session") + + @ensure(lambda result: result is None) + async def test_refused_opening_discards_default_manager_registration(self) -> None: + manager = StreamableHTTPSessionManager(Server("refusal-control"), json_response=True) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + refused = await client.post("/mcp", content="invalid json") + self.assertEqual(refused.status_code, 400, refused.text) + retained = len(manager._server_instances) + self.assertEqual(retained, 0, "Refused opening retained a registered session") + + @ensure(lambda result: result is None) + async def test_delete_reclaims_capacity_and_old_id(self) -> None: + manager = self._manager(max_sessions=1) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + first = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(first.status_code, 200, first.text) + session = first.headers["mcp-session-id"] + full = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(full.status_code, 503) + deleted = await client.delete("/mcp", headers={"mcp-session-id": session}) + self.assertEqual(deleted.status_code, 200, deleted.text) + stale = await client.post("/mcp", headers={"mcp-session-id": session}, json=INITIALIZE) + self.assertEqual(stale.status_code, 404) + replacement = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(replacement.status_code, 200, replacement.text) + + @ensure(lambda result: result is None) + async def test_idle_expiry_reclaims_capacity(self) -> None: + manager = self._manager(session_idle_timeout=0.05, max_sessions=1) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + first = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(first.status_code, 200, first.text) + session = first.headers["mcp-session-id"] + await _await_reclaimed(manager, session) + expired = await client.delete("/mcp", headers={"mcp-session-id": session}) + self.assertEqual(expired.status_code, 404, expired.text) + replacement = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(replacement.status_code, 200, replacement.text) + + @ensure(lambda result: result is None) + async def test_refused_opening_does_not_consume_capacity(self) -> None: + manager = self._manager(max_sessions=1) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + for _ in range(3): + refused = await client.post("/mcp", content="invalid json") + self.assertEqual(refused.status_code, 400, refused.text) + self.assertEqual(len(manager._server_instances), 0) + legitimate = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(legitimate.status_code, 200, legitimate.text) + + @ensure(lambda result: result is None) + async def test_concurrent_openings_cannot_exceed_capacity(self) -> None: + manager = self._manager(max_sessions=2) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + responses = await asyncio.gather(*(client.post("/mcp", json=INITIALIZE) for _ in range(5))) + self.assertEqual(sorted(r.status_code for r in responses), [200, 200, 503, 503, 503]) + self.assertEqual(len(manager._server_instances), 0) + + @ensure(lambda result: result is None) + async def test_oversized_opening_is_rejected_without_retention(self) -> None: + manager = self._manager(max_sessions=1, max_request_body_size=512) + async with ( + manager.run(), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.handle_request), + base_url="http://localhost", + headers=HEADERS, + ) as client, + ): + response = await client.post("/mcp", content="x" * 513) + self.assertEqual(response.status_code, 413) + legitimate = await client.post("/mcp", json=INITIALIZE) + self.assertEqual(legitimate.status_code, 200, legitimate.text) + + +class TestSemgrepStdio(unittest.IsolatedAsyncioTestCase): + @ensure(lambda result: result is None) + async def test_initialize_list_scan_and_shutdown(self) -> None: + executable = shutil.which("semgrep") + self.assertIsNotNone(executable, "The matrix must install the actual downstream CLI") + with tempfile.TemporaryDirectory(prefix="semgrep-mcp-regression-") as directory: + environment = { + "PATH": os.environ.get("PATH", ""), + "HOME": directory, + "USERPROFILE": directory, + "SEMGREP_SETTINGS_FILE": str(Path(directory) / "settings.yml"), + "SEMGREP_SEND_METRICS": "off", + "SEMGREP_ENABLE_VERSION_CHECK": "0", + } + parameters = StdioServerParameters( + command=str(executable), args=["mcp", "--transport", "stdio"], env=environment + ) + async with ( + asyncio.timeout(60), + stdio_client(parameters) as streams, + ClientSession(*streams) as session, + ): + initialized = await session.initialize() + self.assertEqual(initialized.serverInfo.name, "Semgrep") + tools = await session.list_tools() + self.assertIn("semgrep_scan_with_custom_rule", {tool.name for tool in tools.tools}) + result = await session.call_tool( + "semgrep_scan_with_custom_rule", + { + "code_files": [{"path": "unsafe.py", "content": "eval(user_input)\n"}], + "rule": ( + "rules:\n- id: downstream-positive\n pattern: eval(...)\n" + " message: positive control\n severity: ERROR\n languages: [python]\n" + ), + }, + ) + self.assertFalse(result.isError, str(result)) + self.assertIn("downstream-positive", str(result)) + + +class TestSemgrepHttpAuthentication(unittest.IsolatedAsyncioTestCase): + @ensure(lambda result: result is None) + async def test_missing_and_malformed_credentials_remain_rejected(self) -> None: + from semgrep.commands.mcp import setup_mcp_server + + discovery = { + "issuer": "https://issuer.invalid", + "jwks_uri": "https://issuer.invalid/jwks", + "introspection_endpoint": "https://issuer.invalid/introspect", + } + with patch( + "semgrep.mcp.utilities.utils.get_oauth_authorization_server_metadata", + return_value=discovery, + ): + server = setup_mcp_server("localhost", 8000) + app = server.streamable_http_app() + self.assertEqual(server.session_manager.session_idle_timeout, 1800) + self.assertEqual(cast(_FixedManagerDefaults, server.session_manager).max_sessions, 10000) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://localhost:8000", + headers=HEADERS, + ) as client, + ): + missing = await client.post("/mcp", json=INITIALIZE) + malformed = await client.post("/mcp", json=INITIALIZE, headers={"Authorization": "Bearer not-a-jwt"}) + self.assertEqual(missing.status_code, 401, missing.text) + self.assertEqual(malformed.status_code, 401, malformed.text) + self.assertIn("resource_metadata", missing.headers["www-authenticate"]) + + +def _authenticated_fixture() -> tuple[FastMCP, Callable[[str], str]]: + import jwt + + key = secrets.token_bytes(32) + from mcp.server.auth.provider import AccessToken, TokenVerifier + from mcp.server.auth.settings import AuthSettings + from mcp.server.fastmcp import FastMCP + + assert "validate_token_resource" in AuthSettings.model_fields + + class FixtureVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + try: + claims = jwt.decode(token, key, algorithms=["HS256"], audience="http://localhost:8000") + except jwt.InvalidTokenError: + return None + return AccessToken( + token=token, + client_id=claims["client_id"], + scopes=[], + expires_at=claims["exp"], + resource=claims["aud"], + subject=claims["sub"], + claims=claims, + ) + + def credential(principal: str) -> str: + return jwt.encode( + { + "client_id": principal, + "sub": principal, + "iss": "https://issuer.invalid", + "aud": "http://localhost:8000", + "exp": int(time.time()) + 60, + }, + key, + algorithm="HS256", + ) + + server = FastMCP( + "authenticated-sdk-regression", + json_response=True, + auth=AuthSettings.model_validate( + { + "issuer_url": "https://issuer.invalid", + "resource_server_url": "http://localhost:8000", + "required_scopes": [], + "validate_token_resource": True, + } + ), + token_verifier=FixtureVerifier(), + ) + return server, credential + + +class TestSdkAuthenticatedSessions(unittest.IsolatedAsyncioTestCase): + @ensure(lambda result: result is None) + async def test_other_principal_cannot_delete_a_legitimate_session(self) -> None: + server, credential = _authenticated_fixture() + app = server.streamable_http_app() + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000", headers=HEADERS + ) as client, + ): + owner = {"Authorization": "Bearer " + credential("owner")} + created = await client.post("/mcp", json=INITIALIZE, headers=owner) + self.assertEqual(created.status_code, 200, created.text) + session = created.headers["mcp-session-id"] + hostile = await client.delete( + "/mcp", + headers={"Authorization": "Bearer " + credential("other"), "mcp-session-id": session}, + ) + self.assertEqual(hostile.status_code, 404, hostile.text) + deleted = await client.delete("/mcp", headers={**owner, "mcp-session-id": session}) + self.assertEqual(deleted.status_code, 200, deleted.text) + + +if __name__ == "__main__": + unittest.main()