diff --git a/.github/workflows/online-bridge.yml b/.github/workflows/online-bridge.yml index c5fc9701..e17a7b01 100644 --- a/.github/workflows/online-bridge.yml +++ b/.github/workflows/online-bridge.yml @@ -6,6 +6,12 @@ on: - 'tests/lichess/**' - 'scripts/qualify-online-bridge.py' - 'scripts/prepare-online3b-wheels.py' + - 'scripts/sanitize_qualification_evidence.py' + - 'scripts/http_evidence_verdict.py' + - 'scripts/qualify-online-recovery.py' + - 'scripts/verify_source_closure.py' + - 'qualification/http-evidence-contract-v1.json' + - 'qualification/online-recovery-v1.json' - 'qualification/online-bridge-v1.json' - '.github/workflows/online-bridge.yml' - 'Makefile' diff --git a/.github/workflows/online-recovery-v2.yml b/.github/workflows/online-recovery-v2.yml index 1c90cade..ec68f150 100644 --- a/.github/workflows/online-recovery-v2.yml +++ b/.github/workflows/online-recovery-v2.yml @@ -6,6 +6,23 @@ on: - 'tests/lichess/**' - 'tests/fixtures/lichess/**' - 'scripts/qualify-online-recovery-v2.py' + - 'scripts/qualify-online-recovery.py' + - 'scripts/http_evidence_verdict.py' + - 'scripts/sanitize_qualification_evidence.py' + - 'scripts/verify_assurance_contract.py' + - 'scripts/verify_source_closure.py' + - 'qualification/assurance-contract-v1.json' + - 'qualification/http-evidence-contract-v1.json' + - 'qualification/schemas/**' + - 'scripts/release-manifest.py' + - 'scripts/package-online.py' + - 'scripts/build-online-engine-opt-v2.sh' + - 'scripts/qualify-engine-opt-v2.py' + - 'scripts/prepare-online3b-wheels.py' + - 'scripts/ci-install-apt-deps.sh' + - 'Makefile' + - 'deploy/bin/**' + - 'qualification/online-recovery-v1.json' - 'qualification/online-recovery-v2.json' - '.github/workflows/online-recovery-v2.yml' - 'docs/ONLINE_4B_RECOVERY.md' @@ -136,6 +153,7 @@ jobs: independent-guarded-qualification: needs: guarded-integration + if: ${{ always() }} runs-on: ubuntu-24.04 timeout-minutes: 16 steps: @@ -147,17 +165,29 @@ jobs: - name: Install independent chess oracle run: | bash scripts/ci-install-apt-deps.sh python3-pip python3-venv - python3 -m pip install --break-system-packages 'chess==1.11.2' + python3 -m pip install --break-system-packages 'chess==1.11.2' 'PyYAML==6.0.3' + - name: Verify independent exact-source worktree before importing recovery code + run: | + set -euo pipefail + VERIFIED="$RUNNER_TEMP/online4b-verified-${GITHUB_RUN_ID}" + git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA" + test "$(git -C "$VERIFIED" rev-parse HEAD)" = "$ALLFATHER_SOURCE_SHA" + git -C "$VERIFIED" diff --exit-code + python3 "$VERIFIED/scripts/verify_source_closure.py" \ + --profile online-recovery-v2 --source-sha "$ALLFATHER_SOURCE_SHA" + python3 "$VERIFIED/deploy/lichess/fetch_bridge.py" + echo "ONLINE4B_VERIFIED_ROOT=$VERIFIED" >> "$GITHUB_ENV" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + continue-on-error: true with: name: online4b-guarded-raw-evidence path: build/test-results/online-recovery-v2 - name: Independently qualify guarded safety and restart continuation run: | - python3 scripts/qualify-online-recovery-v2.py \ - --evidence build/test-results/online-recovery-v2 \ + python3 "$ONLINE4B_VERIFIED_ROOT/scripts/qualify-online-recovery-v2.py" \ + --evidence "$GITHUB_WORKSPACE/build/test-results/online-recovery-v2" \ --source-sha "$ALLFATHER_SOURCE_SHA" \ - --output build/test-results/online4b-verdict/report.json + --output "$GITHUB_WORKSPACE/build/test-results/online4b-verdict/report.json" - name: Summarize containment versus recovery claims if: always() run: | diff --git a/.github/workflows/online-recovery.yml b/.github/workflows/online-recovery.yml index cd0ed05e..cc4088ff 100644 --- a/.github/workflows/online-recovery.yml +++ b/.github/workflows/online-recovery.yml @@ -6,6 +6,20 @@ on: - 'tests/lichess/**' - 'tests/fixtures/lichess/**' - 'scripts/qualify-online-recovery.py' + - 'scripts/http_evidence_verdict.py' + - 'scripts/sanitize_qualification_evidence.py' + - 'scripts/verify_assurance_contract.py' + - 'scripts/verify_source_closure.py' + - 'qualification/assurance-contract-v1.json' + - 'qualification/http-evidence-contract-v1.json' + - 'qualification/schemas/**' + - 'scripts/release-manifest.py' + - 'scripts/package-online.py' + - 'scripts/build-online-engine-opt-v2.sh' + - 'scripts/qualify-engine-opt-v2.py' + - 'scripts/ci-install-apt-deps.sh' + - 'Makefile' + - 'deploy/bin/**' - 'qualification/online-recovery-v1.json' - '.github/workflows/online-recovery.yml' - 'docs/ONLINE_4_RECOVERY.md' @@ -133,6 +147,7 @@ jobs: independent-recovery-evidence: needs: offline-fault-integration + if: ${{ always() }} runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -146,15 +161,26 @@ jobs: bash scripts/ci-install-apt-deps.sh python3-pip python3-venv python3 -m pip install --break-system-packages 'chess==1.11.2' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + continue-on-error: true with: name: online4a-raw-evidence path: build/test-results/online-recovery-v1 + - name: Establish Git-committed independent verifier source before imports + run: | + set -euo pipefail + VERIFIED="$RUNNER_TEMP/online4a-verified-${GITHUB_RUN_ID}" + git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA" + test "$(git -C "$VERIFIED" rev-parse HEAD)" = "$ALLFATHER_SOURCE_SHA" + git -C "$VERIFIED" diff --exit-code + python3 "$VERIFIED/scripts/verify_source_closure.py" \ + --profile online-recovery-v1 --source-sha "$ALLFATHER_SOURCE_SHA" + echo "ONLINE4A_VERIFIED_ROOT=$VERIFIED" >> "$GITHUB_ENV" - name: Independently reconstruct commits and fault witnesses run: | - python3 scripts/qualify-online-recovery.py \ - --evidence build/test-results/online-recovery-v1 \ + python3 "$ONLINE4A_VERIFIED_ROOT/scripts/qualify-online-recovery.py" \ + --evidence "$GITHUB_WORKSPACE/build/test-results/online-recovery-v1" \ --source-sha "$ALLFATHER_SOURCE_SHA" \ - --output build/test-results/online4a-verdict/report.json + --output "$GITHUB_WORKSPACE/build/test-results/online4a-verdict/report.json" - name: Publish diagnostic qualification summary if: always() run: | diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 108a6217..4cbe5aea 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -1,25 +1,8 @@ name: RELEASE-1 immutable offline OCI candidate on: pull_request: - paths: - - 'deploy/release/**' - - 'deploy/lichess/**' - - 'scripts/build-release-candidate.py' - - 'scripts/verify-release-candidate.py' - - 'scripts/verify-release-oci.py' - - 'scripts/qualify-release-host.py' - - 'scripts/qualify-release-aggregate.py' - - 'scripts/prepare-online3b-wheels.py' - - 'qualification/release-*.json' - - 'qualification/license-compliance-v1.json' - - 'tests/release/**' - - '.github/workflows/release-candidate.yml' - - 'controller/**' - - 'common/**' - - 'adapters/**' - - 'engines/**' - - 'config/**' - - 'vendor.lock.json' + push: + branches: [main] workflow_dispatch: permissions: contents: read @@ -54,9 +37,105 @@ jobs: path: build/test-results/release1/promotion-policy.json if-no-files-found: warn retention-days: 30 - release-oci-integration: + scanner-compatibility: needs: release-contracts runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.SOURCE_SHA }} + - name: Verify scanner and SPDX source against exact committed Git bytes + run: | + set -euo pipefail + python3 - <<'PY' + import os, subprocess + from pathlib import Path + source=os.environ["SOURCE_SHA"] + critical=( + "scripts/check-release-scanner.py", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "qualification/release-image-v1.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", + ) + for relative in critical: + local=Path(relative) + if not local.is_file() or local.is_symlink(): + raise SystemExit("VERDICT_SOURCE_FILE_MISSING_OR_LINKED") + original=subprocess.run( + ["git","show",source+":"+relative], + check=False,capture_output=True,timeout=12) + if original.returncode or original.stdout!=local.read_bytes(): + raise SystemExit("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT") + PY + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + with: + go-version: '1.25.8' + cache: false + - name: Prepare pinned scanner and isolated BuildKit registry + env: + RELEASE1_SCANNER_BUILDER: release1-scanner-probe + run: python3 scripts/ci-release-scanner.py setup + - name: Independently qualify pinned scanner protocol without engine build + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + BASE="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" + SCANNER="$RELEASE_SCANNER_REF" + mkdir -p build/test-results/release1-scanner + cat > "$RUNNER_TEMP/Scanner.Dockerfile" <<'EOF' + ARG BASE_IMAGE + FROM --platform=linux/amd64 ${BASE_IMAGE} + EOF + docker buildx build --platform linux/amd64 \ + -f "$RUNNER_TEMP/Scanner.Dockerfile" --build-arg "BASE_IMAGE=$BASE" \ + --tag allfather/scanner-probe:offline \ + --attest type=provenance,mode=min,builder-id=https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml \ + --attest "type=sbom,generator=$SCANNER" \ + --output type=oci,dest=build/test-results/release1-scanner/scanner.oci.tar . + python3 scripts/check-release-scanner.py \ + --archive build/test-results/release1-scanner/scanner.oci.tar \ + --output build/test-results/release1-scanner/scanner-contract.json + - name: Verify actual SHA1 and SHA256 against pinned Ubuntu regular-file bytes + run: | + set -euo pipefail + BASE="$(python3 -c 'import json;print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" + REF_FILE="$RUNNER_TEMP/release1-reference-dpkg" + CONTAINER="$(docker create "$BASE" /bin/true)" + trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; rm -f "$REF_FILE"' EXIT + docker cp "$CONTAINER:/usr/bin/dpkg" "$REF_FILE" + python3 scripts/check-release-scanner-content.py \ + --archive build/test-results/release1-scanner/scanner.oci.tar \ + --reference-file "$REF_FILE" \ + --output build/test-results/release1-scanner/scanner-file-content.json + - name: Require independently reviewed scanner manifest and binary + run: python3 scripts/ci-release-scanner.py verify-approval + - name: Tear down isolated scanner test builder + if: always() + env: + RELEASE1_SCANNER_BUILDER: release1-scanner-probe + run: python3 scripts/ci-release-scanner.py cleanup + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: release1-scanner-contract + path: | + build/test-results/release1-scanner/scanner-contract.json + build/test-results/release1-scanner/scanner-file-content.json + build/test-results/release1-scanner/scanner-candidate.json + build/test-results/release1-scanner/scanner-source.json + if-no-files-found: warn + retention-days: 30 + release-oci-integration: + needs: [release-contracts, scanner-compatibility] + runs-on: ubuntu-24.04 timeout-minutes: 115 env: JOBS: '2' @@ -82,10 +161,17 @@ jobs: assert bootstrap.count('Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg') == 2 assert 'trusted=yes' not in bootstrap and 'allow-unauthenticated' not in bootstrap PY + - name: Early signed-snapshot availability preflight + timeout-minutes: 3 + run: | + python3 scripts/check-release-snapshot.py \ + --policy qualification/release-image-v1.json \ + --sources deploy/release/ubuntu.sources \ + --output build/test-results/release1/snapshot-availability.json - name: Install exact engine and wheel preparation toolchain run: | bash scripts/ci-install-apt-deps.sh build-essential meson ninja-build pkg-config libprotobuf-dev protobuf-compiler zlib1g-dev libopenblas-dev python3-pip python3-venv - python3 -m pip install --break-system-packages 'pip-tools==7.5.2' + python3 -m pip install --break-system-packages 'pip-tools==7.5.2' 'chess==1.11.2' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | @@ -118,18 +204,60 @@ jobs: python3 scripts/verify-release-candidate.py \ --stage build/release1-stage \ --output build/test-results/release1/stage-report.json + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + with: + go-version: '1.25.8' + cache: false + - name: Prepare identical source-locked scanner for OCI builder + env: + RELEASE1_SCANNER_BUILDER: release1-builder + run: python3 scripts/ci-release-scanner.py setup - name: Build and independently test actual offline Docker image timeout-minutes: 20 run: | set -euo pipefail IMAGE_BASE="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" mkdir -p build/test-results/release1 - docker buildx create --driver docker-container --name release1-builder --use - docker buildx inspect --bootstrap - docker buildx build --builder release1-builder --platform linux/amd64 \ + docker buildx inspect --builder release1-builder --bootstrap + BUILD_LOG="$RUNNER_TEMP/release1-offline-image-build.log" + if docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ --metadata-file build/test-results/release1/docker-build-metadata.json \ - --provenance=false --sbom=false --tag allfather/release1:offline --load . + --provenance=false --sbom=false --tag allfather/release1:offline --load . \ + > "$BUILD_LOG" 2>&1; then + tail -n 16 "$BUILD_LOG" + else + # Store only bounded public package-failure metadata, not raw logs, + # images, credentials, or a rewritten/unpinned dependency source. + python3 - "$BUILD_LOG" <<'PY' + import json, os, re, sys + from pathlib import Path + lines = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() + snapshot = json.loads(Path("qualification/release-image-v1.json").read_text())["apt_snapshot"] + error_lines = [line for line in lines + if any(word in line for word in ( + "503 Service Unavailable", "Failed to fetch", + "Unable to fetch", "failed to solve", "ERROR:"))] + report = { + "schema_version": 1, + "stage": "offline-docker-image-build", + "source_sha": os.environ["SOURCE_SHA"], + "pinned_apt_snapshot": snapshot, + "failure_class": ("ubuntu_snapshot_http_503" + if any("503 Service Unavailable" in l for l in lines) + else "docker_build_failure_unclassified"), + "diagnostics": [ + re.sub(r"\x1b\[[0-9;]*m", "", line)[-400:] + for line in error_lines[-12:]], + "candidate_qualified": False, + "public_bot_release": False, + } + output = Path("build/test-results/release1/snapshot-acquisition-failure.json") + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + PY + tail -n 45 "$BUILD_LOG" + exit 1 + fi docker image inspect allfather/release1:offline \ > build/test-results/release1/docker-inspect.json docker run --rm --network none --read-only \ @@ -168,42 +296,130 @@ jobs: | tee "build/test-results/release1/$CASE.log" done sudo chmod -R a+rX build/test-results/release1-volume + - name: Checkout isolated exact-commit source for every independent RELEASE-1 verifier + run: | + set -euo pipefail + VERIFIED="$RUNNER_TEMP/release1-verified-${GITHUB_RUN_ID}" + git worktree add --detach "$VERIFIED" "$SOURCE_SHA" + test "$(git -C "$VERIFIED" rev-parse HEAD)" = "$SOURCE_SHA" + git -C "$VERIFIED" diff --exit-code + echo "RELEASE1_VERIFIED_ROOT=$VERIFIED" >> "$GITHUB_ENV" + - name: Independently reconstruct all packaged chess and HTTP requests + run: | + python3 "$RELEASE1_VERIFIED_ROOT/scripts/qualify-release-smoke.py" \ + --smoke-dir "$GITHUB_WORKSPACE/build/test-results/release1-volume" \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/smoke-verdict.json" - name: Export standalone OCI archive with SBOM/provenance attestations timeout-minutes: 20 run: | + set -euo pipefail IMAGE_BASE="$(python3 -c 'import json; print(json.load(open("qualification/release-image-v1.json"))["base_image"])')" + SCANNER="$RELEASE_SCANNER_REF" + # The job-generated digest alone cannot authorize a scanner image. + python3 scripts/ci-release-scanner.py verify-approval docker buildx build --builder release1-builder --platform linux/amd64 \ --file deploy/release/Dockerfile --build-arg "BASE_IMAGE=$IMAGE_BASE" \ + --tag allfather/release1:offline \ --metadata-file build/test-results/release1/oci-build-metadata.json \ - --provenance=mode=min --sbom=true \ + --attest type=provenance,mode=min,builder-id=https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml \ + --attest "type=sbom,generator=$SCANNER" \ --output type=oci,dest=build/release1-offline.oci.tar . - python3 scripts/verify-release-oci.py \ - --archive build/release1-offline.oci.tar \ - --metadata build/test-results/release1/oci-build-metadata.json \ - --docker-inspect build/test-results/release1/docker-inspect.json \ - --output build/test-results/release1/oci-report.json + # Both executables and the SPDX parser/catalogue come from the + # isolated Git checkout created after all mutable build steps. + test "$(git -C "$RELEASE1_VERIFIED_ROOT" rev-parse HEAD)" = "$SOURCE_SHA" + git -C "$RELEASE1_VERIFIED_ROOT" diff --exit-code + python3 "$RELEASE1_VERIFIED_ROOT/scripts/inspect-release-oci.py" \ + --archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar" \ + --metadata "$GITHUB_WORKSPACE/build/test-results/release1/oci-build-metadata.json" \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/oci-attestation-structure.json" + python3 "$RELEASE1_VERIFIED_ROOT/scripts/verify-release-oci.py" \ + --archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar" \ + --metadata "$GITHUB_WORKSPACE/build/test-results/release1/oci-build-metadata.json" \ + --docker-inspect "$GITHUB_WORKSPACE/build/test-results/release1/docker-inspect.json" \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/oci-report.json" + - name: Verify every offline verdict module before aggregate execution + run: | + set -euo pipefail + python3 - <<'PY' + import os, subprocess + from pathlib import Path + source=os.environ["SOURCE_SHA"] + critical=( + "scripts/qualify-release-aggregate.py", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "tests/lichess/http_target_contract.py", + "qualification/assurance-contract-v1.json", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "scripts/check-release-scanner.py", + "qualification/release-image-v1.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", + ) + for relative in critical: + local=Path(relative) + if not local.is_file() or local.is_symlink(): + raise SystemExit("VERDICT_SOURCE_FILE_MISSING_OR_LINKED") + original=subprocess.run( + ["git","show",source+":"+relative], + check=False,capture_output=True,timeout=12) + if original.returncode or original.stdout!=local.read_bytes(): + raise SystemExit("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT") + PY - name: Observe test-runner capacity and aggregate evidence without promoting release run: | - python3 scripts/qualify-release-host.py \ - --root . --persistent build/test-results/release1-volume \ - --runtime /tmp --output build/test-results/release1/host-report.json - python3 scripts/qualify-release-aggregate.py \ - --stage build/release1-stage \ - --oci build/test-results/release1/oci-report.json \ - --host build/test-results/release1/host-report.json \ - --smoke-dir build/test-results/release1-volume \ - --os-inventory build/test-results/release1/os-packages.txt \ - --wheel-inventory build/release1-stage/wheels/wheel-attestation.json \ + python3 "$RELEASE1_VERIFIED_ROOT/scripts/qualify-release-host.py" \ + --root "$RELEASE1_VERIFIED_ROOT" \ + --persistent "$GITHUB_WORKSPACE/build/test-results/release1-volume" \ + --runtime /tmp \ + --output "$GITHUB_WORKSPACE/build/test-results/release1/host-report.json" + python3 "$RELEASE1_VERIFIED_ROOT/scripts/qualify-release-aggregate.py" \ + --stage "$GITHUB_WORKSPACE/build/release1-stage" \ + --oci "$GITHUB_WORKSPACE/build/test-results/release1/oci-report.json" \ + --oci-archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar" \ + --host "$GITHUB_WORKSPACE/build/test-results/release1/host-report.json" \ + --smoke-dir "$GITHUB_WORKSPACE/build/test-results/release1-volume" \ + --os-inventory "$GITHUB_WORKSPACE/build/test-results/release1/os-packages.txt" \ + --wheel-inventory "$GITHUB_WORKSPACE/build/release1-stage/wheels/wheel-attestation.json" \ --source-sha "$SOURCE_SHA" \ - --output build/test-results/release1/aggregate.json - - name: Do not publish combined image; retain only non-binary evidence + --output "$GITHUB_WORKSPACE/build/test-results/release1/aggregate.json" + - name: Tear down isolated release scanner registry and builder + if: always() + env: + RELEASE1_SCANNER_BUILDER: release1-builder + run: python3 scripts/ci-release-scanner.py cleanup + - name: Do not publish combined image; retain only approved evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: release1-offline-evidence + # Allowlisted diagnostic summaries; never upload raw BuildKit + # provenance, GitHub event payload, Docker inspection, OCI tar or SBOM. path: | - build/test-results/release1/*.json - build/test-results/release1/*.txt - build/test-results/release1/*.log + build/test-results/release1/stage-report.json + build/test-results/release1/verify-output.json + build/test-results/release1/snapshot-acquisition-failure.json + build/test-results/release1/snapshot-availability.json + build/test-results/release1/smoke-verdict.json + build/test-results/release1/oci-attestation-structure.json + build/test-results/release1/oci-report.json + build/test-results/release1/host-report.json + build/test-results/release1/aggregate.json + build/test-results/release1-scanner/scanner-candidate.json + build/test-results/release1-scanner/scanner-source.json + build/test-results/release1/os-packages.txt + build/test-results/release1/python-packages.txt + build/test-results/release1/r00-white.log + build/test-results/release1/r00-black.log + build/test-results/release1/r02-white.log + build/test-results/release1/r10-white.log if-no-files-found: warn retention-days: 30 diff --git a/deploy/lichess/fetch_bridge.py b/deploy/lichess/fetch_bridge.py index dc7f9261..0eda9cf5 100644 --- a/deploy/lichess/fetch_bridge.py +++ b/deploy/lichess/fetch_bridge.py @@ -2,6 +2,8 @@ """Retrieve only the locked lichess-bot Git revision; no submodules or live API.""" from __future__ import annotations import argparse +import shutil +from pathlib import PurePosixPath import json import os import subprocess @@ -22,8 +24,9 @@ def verify(location: Path, lock: dict) -> None: raise ValueError("pinned bridge Git checkout missing") if cmd("git", "rev-parse", "HEAD", cwd=location) != lock["commit"]: raise ValueError("bridge HEAD differs from locked commit") - if cmd("git", "status", "--porcelain", "--untracked-files=no", cwd=location): - raise ValueError("pinned bridge checkout has modified tracked files") + if cmd("git", "status", "--porcelain", "--untracked-files=all", + "--ignored=matching", cwd=location): + raise ValueError("pinned bridge checkout has modified, untracked, or ignored files") if cmd("git", "remote", "get-url", "origin", cwd=location) != lock["origin"]: raise ValueError("bridge remote identity differs from lock") for path, oid in lock["tracked_blobs"].items(): @@ -38,6 +41,60 @@ def verify(location: Path, lock: dict) -> None: raise ValueError("bridge scope is not offline-only") +def copy_tracked(source: Path, destination: Path, *, verify_lock=True) -> None: + """Export only HEAD-tracked regular bytes, never ignored or transient code. + + Exact checkout verification is mandatory before export; caller-supplied + untracked files (including ignored sitecustomize.py) cannot enter staging. + """ + if verify_lock: + lock = json.loads(LOCK.read_text(encoding="utf-8")) + verify(source, lock) + raw = subprocess.check_output( + ["git", "-C", str(source), "ls-files", "--stage", "-z"], stderr=subprocess.PIPE + ) + rows = [item for item in raw.split(b"\0") if item] + if not rows: + raise ValueError("pinned bridge HEAD has no tracked files") + if destination.exists() or destination.is_symlink(): + raise ValueError("bridge export destination must be absent") + destination.mkdir(parents=True, exist_ok=False) + try: + seen = set() + for row in rows: + mode_oid, sep, name_raw = row.partition(b"\t") + if not sep: + raise ValueError("malformed tracked Git entry") + metadata = mode_oid.split() + if len(metadata) != 3 or metadata[0] not in (b"100644", b"100755") or metadata[2] != b"0": + raise ValueError("nonregular tracked Git entry") + name = name_raw.decode("utf-8") + posix = PurePosixPath(name) + if (not name or posix.is_absolute() or ".." in posix.parts or + "\\" in name or str(posix) != name or name in seen): + raise ValueError("noncanonical tracked path: " + name) + seen.add(name) + source_file = source / name + if (not source_file.is_file() or source_file.is_symlink() or + not source_file.resolve().is_relative_to(source.resolve())): + raise ValueError("missing, linked or escaped tracked source: " + name) + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + # Export Git's exact indexed blob, not working-tree bytes. Safe + # checkout filters (e.g. CRLF) may transform a clean path, and + # git hash-object without --path would reject that legitimate file. + # Reading by immutable object ID also prevents an export-time TOCTOU. + blob = subprocess.check_output( + ["git", "-C", str(source), "cat-file", "blob", + metadata[1].decode("ascii")], stderr=subprocess.PIPE) + target.write_bytes(blob) + if metadata[0] == b"100755": + target.chmod(0o755) + except BaseException: + shutil.rmtree(destination) + raise + + def fetch(location: Path, lock: dict) -> None: if location.exists(): verify(location, lock) diff --git a/deploy/lichess/offline_session.py b/deploy/lichess/offline_session.py index 766b901a..8427cba1 100644 --- a/deploy/lichess/offline_session.py +++ b/deploy/lichess/offline_session.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import hashlib import shutil import subprocess import sys @@ -25,10 +26,17 @@ def stage(*, release: Path, bridge: Path, workspace: Path, server_url: str) -> d runtime = workspace / "release" shutil.copytree(release, runtime) upstream = workspace / "bridge" - shutil.copytree(bridge, upstream, - ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc")) - shutil.copy2(ROOT / "deploy/lichess/extra_game_handlers.py", - upstream / "extra_game_handlers.py") + fetch_bridge.copy_tracked(bridge, upstream) + extra = ROOT / "deploy/lichess/extra_game_handlers.py" + frozen = subprocess.check_output([ + "git", "-C", str(ROOT), "show", + "HEAD:deploy/lichess/extra_game_handlers.py" + ]) + if hashlib.sha256(extra.read_bytes()).digest() != hashlib.sha256(frozen).digest(): + raise ValueError("local challenge overlay differs from HEAD") + shutil.copyfile(extra, upstream / "extra_game_handlers.py") + if hashlib.sha256((upstream / "extra_game_handlers.py").read_bytes()).digest() != hashlib.sha256(frozen).digest(): + raise ValueError("executed challenge overlay copy differs from HEAD") config_path = workspace / "config.yml" effective = bridge_policy.generate(upstream, runtime, server_url, config_path) if effective["url"] != server_url: diff --git a/deploy/lichess/run_offline.py b/deploy/lichess/run_offline.py index dab85e1b..42890411 100644 --- a/deploy/lichess/run_offline.py +++ b/deploy/lichess/run_offline.py @@ -48,7 +48,7 @@ def game(*, color: str, release: Path, bridge: Path, output: Path, timeout: int shutil.copytree(release, copied_release) release = copied_release staged = work / "bridge" - shutil.copytree(bridge, staged, ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc")) + fetch_bridge.copy_tracked(bridge, staged) shutil.copy2(ROOT / "deploy/lichess/extra_game_handlers.py", staged / "extra_game_handlers.py") cfg = bridge_policy.generate(staged, release, server.url, work / "config.yml") diff --git a/deploy/lichess/run_recovery.py b/deploy/lichess/run_recovery.py index e4fa4c48..c30672e8 100644 --- a/deploy/lichess/run_recovery.py +++ b/deploy/lichess/run_recovery.py @@ -140,6 +140,7 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, state["observation"] = "game_stream_started" end = time.monotonic() + timeout activated_at = None + state["postfault_observed_seconds"] = None while time.monotonic() < end: if server.finished.is_set(): state["observation"] = "server_game_complete" @@ -151,6 +152,9 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, activation = server.fault.evidence()["activations"] if activation and activated_at is None: activated_at = time.monotonic() + hold = 61 if scenario["action"] == "rate_limit_429" else 12 + # Complete a late fault's declared observation window. + end = max(end, activated_at + hold + 1) if (scenario["action"] == "terminate_bridge" and activation and proc.poll() is None): os.killpg(proc.pid, signal.SIGKILL) @@ -160,7 +164,11 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, hold = 61 if scenario["action"] == "rate_limit_429" else 12 if time.monotonic() - activated_at >= hold: state["observation"] = "bounded_postfault_observation" - state["postfault_observed_seconds"] = round(hold, 3) + state["postfault_observed_seconds"] = round( + time.monotonic() - activated_at, 3) + server.trace.record( + "postfault_window_complete", + action=scenario["action"]) break time.sleep(0.05) else: @@ -236,6 +244,7 @@ def experiment(*, scenario: dict, color: str, bridge: Path, release: Path, "observation": state["observation"], "activations": len((evidence.get("fault") or {}).get("activations", [])), "errors": errors}, sort_keys=True)) + return not errors def main() -> int: @@ -251,11 +260,11 @@ def main() -> int: rows = load_scenarios(CASES) if args.scenario not in rows: p.error("unknown frozen scenario") - experiment(scenario=rows[args.scenario], color=args.color, - bridge=args.bridge.resolve(), release=args.release.resolve(), - output=args.output.resolve(), source_sha=args.source_sha, - timeout=args.timeout) - return 0 + success = experiment(scenario=rows[args.scenario], color=args.color, + bridge=args.bridge.resolve(), release=args.release.resolve(), + output=args.output.resolve(), source_sha=args.source_sha, + timeout=args.timeout) + return 0 if success else 2 if __name__ == "__main__": diff --git a/deploy/release/Dockerfile b/deploy/release/Dockerfile index de94a6e9..1fd2926f 100644 --- a/deploy/release/Dockerfile +++ b/deploy/release/Dockerfile @@ -27,11 +27,10 @@ RUN set -eu; \ # Once CA trust has been established, ALL remaining APT inputs use the # fixed signed HTTPS Ubuntu snapshot; no bootstrap source survives. COPY deploy/release/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources -RUN apt-get update -o APT::Update::Error-Mode=any && \ - apt-get install -y --no-install-recommends \ - python3 python3-venv python3-pip ca-certificates procps \ - libopenblas0-pthread libprotobuf32t64 libstdc++6 libgomp1 zlib1g && \ - rm -rf /var/lib/apt/lists/* +# Only the pinned signed snapshot may be queried; no floating fallback. +COPY deploy/release/install-pinned-apt.sh /usr/local/bin/install-pinned-apt +RUN /bin/sh /usr/local/bin/install-pinned-apt && \ + rm /usr/local/bin/install-pinned-apt COPY build/release1-stage/allfather/ /opt/allfather/ COPY build/release1-stage/bridge/ /opt/bridge/ diff --git a/deploy/release/Dockerfile.dockerignore b/deploy/release/Dockerfile.dockerignore index 46d98674..5a07e14d 100644 --- a/deploy/release/Dockerfile.dockerignore +++ b/deploy/release/Dockerfile.dockerignore @@ -7,3 +7,4 @@ !build/ !build/release1-stage/ !build/release1-stage/** +!deploy/release/install-pinned-apt.sh diff --git a/deploy/release/install-pinned-apt.sh b/deploy/release/install-pinned-apt.sh new file mode 100644 index 00000000..aab015bc --- /dev/null +++ b/deploy/release/install-pinned-apt.sh @@ -0,0 +1,115 @@ +#!/bin/sh +# APT acquisition from the immutable, GPG-signed Ubuntu snapshot configured +# in ubuntu.sources. Only transient 429/502/503/504 responses may be retried. +# All signature, hash and certificate errors fail immediately. +set -eu +APT_LISTS_DIR="${APT_LISTS_DIR:-/var/lib/apt/lists}" +APT_RETRY_BACKOFF_SECONDS="${APT_RETRY_BACKOFF_SECONDS:-4}" +APT_LOG="${APT_LOG:-/tmp/allfather-release-apt-$$.log}" +trap 'rm -f "$APT_LOG"' 0 HUP INT TERM + +only_transient_acquisition_failures() { + # APT writes one Err: plus its indented reason, and often repeats + # it as "E: Failed to fetch". Every recorded failure must have an + # explicitly allowed transient HTTP status. One unrelated DNS/TLS/hash/ + # signature/unclassified error vetoes retry even alongside a valid 503. + LC_ALL=C awk ' + function transient_reason(s, x) { + x=s + sub(/^[[:space:]]+/, "", x) + return x ~ /^(429|502|503|504)[[:space:]]/ + } + function transient_fetch(s) { + # Status immediately after the URL, not a three-digit token in + # an [IP: address port] suffix. Port 443 must never be parsed + # as an HTTP 443 error. + return s ~ /^E: Failed to fetch [^[:space:]]+[[:space:]]+(429|502|503|504)[[:space:]]/ + } + function transient_header(s, x) { + x=s + gsub(/\[IP:[^]]+\]/, "", x) + return x ~ /[[:space:]](429|502|503|504)[[:space:]]/ + } + function nontransient(s) { + return s ~ /NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|Hash Sum mismatch|certificate|Certificate|TLS|SSL|signatures could not|not signed|does not have a Release file|clearsigned|unexpected size|Temporary failure resolving|Could not resolve|Name or service not known|Connection refused|Network is unreachable|Unable to locate package|Could not get lock|Permission denied|401 Unauthorized|403 Forbidden|404 Not Found/ + } + function finish_err() { + if (in_err && !err_retryable) bad=1 + in_err=0 + err_retryable=0 + } + { + line=$0 + if (nontransient(line)) bad=1 + if (line ~ /^Err:[0-9]+[[:space:]]/) { + finish_err() + in_err=1 + err_retryable=transient_header(line) + seen++ + next + } + if (in_err && line ~ /^[[:space:]]+/) { + if (transient_reason(line)) err_retryable=1 + else if (line !~ /^[[:space:]]*\[IP:/) bad=1 + next + } + finish_err() + if (line ~ /^E: Failed to fetch /) { + seen++ + if (!transient_fetch(line)) bad=1 + next + } + if (line ~ /^E:/) { + # This APT summary follows individually classified errors. + if (line !~ /^E: Some index files failed to download/ && + line != "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?") + bad=1 + next + } + if (line ~ /^(error:|Error:|fatal:)/) bad=1 + } + END { + finish_err() + exit !(seen>0 && bad==0) + } + ' "$APT_LOG" +} + +apt_attempt() { + phase="$1" + shift + attempt=1 + while [ "$attempt" -le 4 ]; do + if [ "$phase" = "update" ]; then + # Every failure destroys partial/stale lists before retrying. + rm -rf "$APT_LISTS_DIR"/* + mkdir -p "$APT_LISTS_DIR/partial" + fi + if apt-get \ + -o "Dir::State::lists=$APT_LISTS_DIR" \ + -o Acquire::Retries=1 \ + -o Acquire::https::Timeout=25 \ + "$phase" "$@" > "$APT_LOG" 2>&1; then + echo "Pinned signed APT $phase completed on attempt $attempt" + return 0 + fi + cat "$APT_LOG" >&2 + if ! only_transient_acquisition_failures; then + echo "Pinned signed APT $phase unclassified/integrity failure" >&2 + return 100 + fi + if [ "$attempt" -eq 4 ]; then + echo "Pinned signed APT $phase exhausted four bounded attempts" >&2 + return 100 + fi + sleep $((attempt * APT_RETRY_BACKOFF_SECONDS)) + attempt=$((attempt + 1)) + done + return 100 +} + +apt_attempt update -o APT::Update::Error-Mode=any +apt_attempt install -y --no-install-recommends \ + python3 python3-venv python3-pip ca-certificates procps \ + libopenblas0-pthread libprotobuf32t64 libstdc++6 libgomp1 zlib1g +rm -rf "$APT_LISTS_DIR"/* diff --git a/deploy/release/offline_smoke.py b/deploy/release/offline_smoke.py index 853aa15b..dd7feef9 100644 --- a/deploy/release/offline_smoke.py +++ b/deploy/release/offline_smoke.py @@ -7,6 +7,7 @@ """ from __future__ import annotations import argparse +import hashlib import json import os import re @@ -98,6 +99,51 @@ def _start(cfg, owner, logpath): return proc, fd +def rejected_http_posts(records: list[dict], scenario_id: str) -> list[dict]: + """Producer-side fail-closed POST gate; host qualification is independent.""" + allowed = {"/api/token/test", + "/api/challenge/off3b001/accept", + "/api/challenge/off3b001/decline"} + prefix = "/api/bot/game/off3b001/move/" + expected_move_status = (200, 429) if scenario_id == "R10" else (200,) + rejected = [] + for record in records: + if not isinstance(record, dict): + rejected.append({"malformed": True}) + continue + if record.get("method") != "POST": + continue + path, status = record.get("path"), record.get("status") + authorized_nonmove = isinstance(path, str) and path in allowed + move_endpoint = isinstance(path, str) and path.startswith(prefix) + if (type(status) is not int or + (authorized_nonmove and status != 200) or + (move_endpoint and + (path[len(prefix):] in ("", "0000") or + status not in expected_move_status)) or + not (authorized_nonmove or move_endpoint)): + rejected.append(record) + return rejected + + +R10_POST_RESPONSE_HOLD_NS = 61_250_000_000 # min 61 sec + scheduler margin + + +def completed_rate_limit_response(trace: list[dict], request_id: str): + """Return only the single request-correlated HTTP 429 completion.""" + found = [r for r in trace if r.get("event") == "response_write_completed" + and r.get("request_id") == request_id + and r.get("status") == 429] + return found[0] if len(found) == 1 else None + + +def r10_hold_ready(trace: list[dict], request_id: str, now_ns: int) -> bool: + response = completed_rate_limit_response(trace, request_id) + return bool(response is not None and + type(response.get("monotonic_ns")) is int and + now_ns - response["monotonic_ns"] >= R10_POST_RESPONSE_HOLD_NS) + + def run(*, color: str, scenario_id: str, run_id: str): if os.getpid() != 1: raise RuntimeError("image smoke supervisor must be PID 1; no unrestricted signals") @@ -120,7 +166,7 @@ def run(*, color: str, scenario_id: str, run_id: str): if output.exists() or output.is_symlink(): raise RuntimeError("image smoke refuses to reuse a prior game journal") output.mkdir(mode=0o700) - outcome = {"schema_version": 1, "run_id": run_id, "scenario": scenario_id, + outcome = {"schema_version": 2, "run_id": run_id, "scenario": scenario_id, "color": color, "observations": [], "runner_errors": [], "offline": True, "network_recovery_qualified": False, "public_bot_release": False, "ready": False} @@ -181,9 +227,19 @@ def run(*, color: str, scenario_id: str, run_id: str): observation = "restart_after_lost_ack" break if scenario_id == "R10" and activated is not None: - if time.monotonic()-activated > 61: - observation = "rate_limited_suspension" - break + activations = game.fault.evidence()["activations"] + request_ids = [entry.get("request_id") + for entry in activations + if entry.get("action") == "rate_limit_429"] + if len(request_ids) == 1 and isinstance(request_ids[0], str): + rid = request_ids[0] + if r10_hold_ready(game.trace.snapshot(), rid, + time.monotonic_ns()): + observation = "rate_limited_suspension" + game.trace.record("rate_limit_hold_completed", + action="rate_limit_429", + request_id=rid) + break if proc.poll() is not None: observation = "bridge_exited" break @@ -213,6 +269,11 @@ def run(*, color: str, scenario_id: str, run_id: str): outcome["guard_events"] = [] except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: outcome["runner_errors"].append(type(exc).__name__ + ": " + str(exc)) + except BaseException as exc: + # Retain negative evidence, but never silently turn an unexpected + # test-supervisor failure into a successful qualification result. + outcome["runner_errors"].append(type(exc).__name__ + ": " + str(exc)) + raise finally: if started: game.close() @@ -223,7 +284,13 @@ def run(*, color: str, scenario_id: str, run_id: str): } outcome["fault"] = evidence.get("fault") outcome["http"] = evidence.get("requests") - outcome["commits"] = [r for r in evidence.get("transport_trace", []) + trace = evidence.get("transport_trace") + outcome["transport_trace"] = trace + if isinstance(trace, list): + raw_trace = json.dumps(trace, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode() + outcome["transport_trace_sha256"] = hashlib.sha256(raw_trace).hexdigest() + outcome["commits"] = [r for r in trace or [] if r.get("event") == "board_commit"] board = chess.Board() for move in outcome["game"].get("moves") or []: @@ -263,8 +330,9 @@ def run(*, color: str, scenario_id: str, run_id: str): else: confirmed = (evidence.get("complete") and outcome["observations"][-1]["observation"] == "complete_confirmed") - bad_http = [r for r in records if r["path"].endswith( - ("/abort", "/resign", "/upgrade", "/takeback")) or "/move/0000" in r["path"]] + bad_http = rejected_http_posts(records, scenario_id) + if not isinstance(outcome["game"].get("forbidden_attempts"), list) or outcome["game"]["forbidden_attempts"]: + outcome["runner_errors"].append("server recorded forbidden HTTP attempts") outcome["rate_cooldown_verified"] = rate_clean if scenario_id == "R10" else None outcome["fresh_second_epoch_commits"] = len(second_epoch_commits) if scenario_id == "R02" else None outcome["ready"] = bool(confirmed and not outcome["runner_errors"] and @@ -277,7 +345,8 @@ def run(*, color: str, scenario_id: str, run_id: str): "errors": outcome["runner_errors"], "observations": outcome["observations"], "public_bot_release": False}, sort_keys=True)) - return 0 if outcome["ready"] else 2 + # NEVER return from finally: doing so suppresses an active exception. + return 0 if outcome["ready"] else 2 def main(): diff --git a/deploy/release/scanner/Dockerfile b/deploy/release/scanner/Dockerfile new file mode 100644 index 00000000..a8981c8e --- /dev/null +++ b/deploy/release/scanner/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch +COPY --chmod=0555 syft-scanner /bin/syft-scanner +ENV LOG_LEVEL=warn +ENTRYPOINT ["/bin/syft-scanner"] diff --git a/deploy/release/scanner/target.go.patch b/deploy/release/scanner/target.go.patch new file mode 100644 index 00000000..866986bc --- /dev/null +++ b/deploy/release/scanner/target.go.patch @@ -0,0 +1,28 @@ +diff --git a/internal/target.go b/internal/target.go +--- a/internal/target.go ++++ b/internal/target.go +@@ -15,7 +15,10 @@ + package internal + + import ( + "context" ++ "crypto" ++ _ "crypto/sha1" ++ _ "crypto/sha256" + "fmt" + "os" + "path/filepath" +@@ -59,10 +62,13 @@ + sr = pkgcataloging.NewSelectionRequest().WithExpression(strings.Split(v, ",")...) + } + ++ fileHashes := filecataloging.DefaultConfig().WithHashers(crypto.SHA1, crypto.SHA256) ++ + result, err := syft.CreateSBOM( + ctx, + src, + syft.DefaultCreateSBOMConfig(). ++ WithFilesConfig(fileHashes). + WithCatalogerSelection(sr)) + if err != nil { + return sbom.SBOM{}, err diff --git a/docs/CODEX_POSTMERGE_CLOSURE.md b/docs/CODEX_POSTMERGE_CLOSURE.md new file mode 100644 index 00000000..3ead0975 --- /dev/null +++ b/docs/CODEX_POSTMERGE_CLOSURE.md @@ -0,0 +1,229 @@ +# AllfatherChess — Post-Merge Codex Debt Ledger (draft PR #71) + +Scope: 18 code-review findings from #66–#69, plus two independently discovered admission holes. +Base main commit: 449ff7eb1d4728b6d2a6ec8d6e84a85d3bf4b685. +Status: code implementation in progress; not evidence of green CI or completed reviewer approval. + +## Review comments and remedy + +| PR | Original Codex review | Root problem | Repair | +|---|---|---|---| +| #66 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/66#discussion_r4242842043) | Untracked and ignored bridge code | Canonical tracked-only export for all bridge consumers; poisoning tests | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620547) | State fault labels without semantic effect | R05 delay and R07 predecessor witness; negative trace mutations | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620550) | Missing executed bridge overlay in attestation | Executed source inventory and HEAD-bound extra_game_handlers export | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620555) | No independent diagnostic after fault job failure | Always-run downstream verdict; missing evidence fails | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620556) | Incomplete postfault observation window | Bounded deadline extension and server-side monotonic window witness | +| #67 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/67#discussion_r4243620558) | Fenced chunked stream remains open | Normal termination chunk/EOF; R08 malformed chunk remains deliberate | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997490) | Forbidden API requests overlooked | Full POST allowlist and server-forbidden audit, including OCI smoke | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997492) | Overlay identities replaced with counts | Independently rebuild expected patched manifest from frozen upstream | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997494) | Duplicate HTTP submissions not rejected | Bijection between trace, request audit, durable intents and board ply | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997497) | Illegal chess moves accepted as syntactically valid | Reconstruct board in trace order and reject turn/legality violations | +| #68 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/68#discussion_r4243997499) | Intent history not bound to server prefix | Exact game ID/ply/prior-history verification | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576126) | Bridge commit is self-attested | Bundle and verify independent pinned bridge lock and source manifest | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576132) | Release OCI workflow misses input changes | All-PR and main-push RELEASE-1 with input-aware ONLINE-4A/4B triggers | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576137) | Example misses required replay bind | Executable shell syntax and required mount contract tests | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576147) | Smoke finally return can swallow exceptions | Return after finally and propagate unexpected teardown failures | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576153) | SBOM/provenance not bound to actual image | Validate descriptor reference and each in-toto subject against exact image | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576159) | Release policy leaves two flags unchecked | Explicit fail-closed host/publication prohibition mutations | +| #69 | [Codex comment](https://github.com/HMarcusWH/AllfatherChess/pull/69#discussion_r4252576166) | OCI compressed layers not bound to diff_ids | Streaming decompression and config.rootfs.diff_ids match with attack fixture | + +## Two additional admission failures + +- ONLINE-4A runner could exit with code zero after recorded harness errors, while the verifier accepted a complete-looking run. The runner and qualifier now deny that condition without relabeling a legitimately unsafe experiment as a broken harness. +- RELEASE-1 packaged smoke and aggregate originally had incomplete prohibited-POST checking. Both now check the server forbidden-attempt record, legal endpoint scope, 429 cooldown evidence and restart confirmation instead of merely trusting a positive producer status. + +## Merge and release conditions + +Keep draft until all exact-head fast/real-process checks pass and final Codex code/security reviews finish. Preserve failing and incomplete evidence. Do not reduce any chess authority or runtime-budget requirement. Remain offline-only: no license approval, OS/wheel dependency promotion, public image upload, destination-host authorization, real credentials, BOT account conversion, live transport, or playing-strength claim. +On squash merge, verify new main SHA through the explicit RELEASE-1 main-push workflow. + +## PR #71 Codex review and final qualification follow-up + +- [P1: image-level in-toto subject](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078059): now accepted only when every subject digest equals the selected application-image manifest, not its config or individual layer. Added config-only/layer-only/empty-subject destructive tests. +- [P2: BuildKit metadata digest](https://github.com/HMarcusWH/AllfatherChess/pull/71#pullrequestreview-5496871107): only the verified OCI index or selected application manifest can match `containerimage.digest`; an otherwise-valid attestation manifest cannot substitute for the image. +- [P2: packaged illegal HTTP move attempt](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078070): the fake server now correlates every move request ID with its HTTP status. The image retains its ordered trace and a separate host verifier reconstructs board legality, no-blind-retry, durable intent, R02 and R10 witnesses before aggregate admission. + +CI discovered that the untagged OCI archive export lacked BuildKit default image subjects even though the standalone Docker image had an image name. The OCI export now specifies an explicit **local** image tag and records sanitized attestation structure before strict verification. Missing subject evidence is NEVER accepted as a workaround. + +The RELEASE-1 host aggregate now also recomputes the actual OCI archive SHA-256 and requires all independent OCI-verifier booleans. Smoke evidence schema 2 replaces incomplete schema 1 for qualification; no producer readiness bit alone can authorize a verdict. + +**Status:** this entry records implemented source changes, not successful exact-head OCI integration or an approved code/security review. Fresh final-head tests/reviews and disposition of any new feedback are mandatory before merging. Offline-only/publication-prohibited policy remains unchanged. + +## Final PR #71 follow-up: six fresh P1/P2 findings + +**Implemented source and destructive fixtures; actual exact-head CI and Codex review still required.** + +| Finding | Source repair and test evidence | +|---|---| +| [P1 nested OCI root](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714605) | Select only the BuildKit-reported root descriptor from the OCI layout; compare its digest, media type and byte size to BuildKit metadata. Nested and flat layouts, wrong-root and unrelated-attestation fixtures. | +| [P2 OCI descriptor size](https://github.com/HMarcusWH/AllfatherChess/pull/71#pullrequestreview-5497647677) | Validate every embedded index, manifest, config, image/attestation layer descriptor with exact actual bytes; reject boolean, absent, negative or wrong sizes. Outer index.json is the layout entrypoint, not an embedded descriptor. | +| [P2 nested diagnostics](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714615) | Bounded recursive diagnostic inspector exposes nested indexes, manifest identities, sizes and summarized in-toto subjects; never grants integrity_verified. | +| [P2 SLSA predicate](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714599) | Version-specific v1/v0.2 nested structure, buildType URI, parameters, and builder.id URI; workflow requests a meaningful builder ID; synthetic empty-schema and malformed-ID attacks fail. | +| [P2 persisted journal](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714591) | Independent host-side read-only SQLite transaction, integrity_check, owner and all four tables exact snapshot match. Real MoveJournal-schema fixtures verify WAL, corruption, missing file, symlinks and forged snapshots. | +| [P2 R02 response](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714587) | Trace response and close events carry per-request IDs with HTTP/1.1 reset. Target lost-ACK case rejects completed response, requires correct close, first-epoch unknown-ACK and no same-intent successful ACK. | + +The prior illegal POST and image-manifest subject protections remain enforced. These changes are local qualification and provenance controls, not move-search or HYBRID authorization changes. + +**Merge hold:** Do not merge PR #71 until final-head fast contracts, RELEASE-1 real OCI and packaged games, all ONLINE/LOCAL/LC0 checks, and fresh code/security review are green; no public deployment or licensing grant. + +## PR #71 final follow-up — four new P2 findings and external build blocker + +The Codex review of `d75f10e976decd80c4c0f0c8e74296fbb65751cc` identified four actionable P2 evidence gaps. The following remedies and destructive fixtures were staged in the existing draft branch; **do not mark these findings closed until final-head tests, actual packaged-OCI evidence and review succeed**. + +| Codex finding | Source repair | Negative proof fixture | +|---|---|---| +| [Application platform](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102073) | `scripts/verify-release-oci.py` requires a runnable nested-index descriptor to advertise `linux/amd64` consistent with its config. An optional explicit flat-manifest platform must also match. | Wrong OS, wrong architecture, absent nested platform, malformed platform; legitimate flat omission remains supported. | +| [Attestation subject size](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102056) | A present OCI attestation manifest `subject` must equal the independently verified application-manifest descriptor's digest, media type and integer byte size. | Missing, boolean, floating, negative, incorrect sizes and wrong media type, with rehashed parent blobs; valid optional subject accepted. | +| [R02 unknown ACK endpoint](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102049) | First-epoch unique `http_unknown_ack` must explicitly have `endpoint="move"` and match the game/owner. | Missing/wrong endpoint and duplicate conflicting unknown ACK. | +| [R10 client-observed HTTP 429](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102061) | Require the targeted completed 429 response (request/path/status/order), exactly one first-epoch `global_rate_limit_block` at endpoint `move`, and the persisted `UNKNOWN_ACK` intent with `http429:move` cooldown. | Missing, unrelated, duplicate, premature or wrong-status response; absent/misowned/wrong-endpoint guard and wrong durable intent. | + +An older [illegal-POST review](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078070) receives a direct producer-side negative test alongside existing host-side chess legality checks: an otherwise completed game with a 400 illegal-move response cannot set `ready` to true. + +**RELEASE-1 previous exact-head failure:** Docker's pinned Ubuntu snapshot `20261001T000000Z` returned HTTP 503 while downloading native packages, before OCI export or packaged smoke testing. The Dockerfile now uses bounded APT acquisition retries/timeouts against the **same GPG-verified snapshot**. The workflow retains a small, failure-only structured report and exits nonzero if the build remains unavailable. No fallback mirror, floating repository, unsigned package or publication permission was added. + +The proof obligation remains eleven required exact-head workflows green, genuine standalone OCI SBOM/provenance verified, all four installed-image scenarios qualified against real SQLite, final Codex code/security review with no actionable findings, and explicit disposition of the remaining prior review threads. No online play or public distribution grant is part of this PR. + +## PR #71 follow-up — SQLite read-only WAL, nested OCI, SPDX and R10 + +Latest Codex review identified three additional P2 admission issues. The real RELEASE-1 packaged-run integration also exposed a host-side SQLite WAL sidecar permission error. These source repairs and destructive fixtures are implemented in the existing draft branch, but successful final-head integration and review are mandatory before closure. + +| Area | Repair | +| --- | --- | +| OCI nested-platform path | The recursive verifier tracks whether each index descendant is the selected runnable image; every advertised platform on this path must be linux/amd64. Tests mutate root and intermediate index platforms with rehashed manifests; attestation-only paths remain separate. | +| SPDX document identity | SPDX 2.2/2.3 predicate now requires SPDXRef-DOCUMENT, CC0-1.0 data license, nonempty document name, URI namespace, valid UTC creation date, creator identification and nonempty identified package/file inventory. Empty schema-shaped SBOMs fail. This is not publisher authentication. | +| R10 cooldown validity | Finite numeric SQLite deadline, exact corresponding single durable cooldown event/cause/deadline, valid owner epoch and event timestamp, 60–86400 second contract with bounded write-delay tolerance, and temporally compatible client guard audit. Coherently corrupted SQLite+JSON fail. | +| SQLite UID boundary | Independent verifier stages the stopped producer's bounded regular database and WAL bytes in a private writable directory before a read-only SQLite transaction; never uses immutable=1 or producer SHM; original source remains untouched. Tests reproduce read-only permissions and uncheckpointed WAL. | + +Run https://github.com/HMarcusWH/AllfatherChess/actions/runs/38486478700 built the candidate image and completed R00 white/black, R02 white and R10 white packaged smoke, but independent qualification failed for all four at SQLite read-only/WAL initialization. OCI export was not yet exercised. + +All changes preserve the original no-publication, no-licensing, no-live-transport and no-search/authority-change constraints. Do not claim complete qualification or resolve review threads until exact-head OCI, packaged smoke, and final Codex review pass. + +## PR #71 final repair wave: SPDX, OCI, 429 response-time hold, immutable scanner + +Grounded in exact-head aaa63f76cce91579d6092c99833b858cad6018c5; RELEASE-1 built the candidate, independently qualified all four packaged games and SQLite, exported an OCI index, then rejected the real SPDX predicate for a supposedly missing usable package/file inventory. The prior verifier incorrectly required `name` on SPDX `files[]` instead of the SPDX 2.3 `fileName` field. Actual inventory counts were not present in retained diagnostics, so success on real BuildKit output remains a mandatory new CI gate. + +| Review / finding | Implementation and falsifiers | +| --- | --- | +| [SPDX fragment namespace](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635092) | Reject any literal `#` in `documentNamespace`, including a trailing `#`. Negative fixtures test both. | +| [R10 response-relative hold](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635097) | Packaged supervisor waits at least 61.25 seconds after targeted `response_write_completed(429)`, then records request-bound hold. Host checks at least 61 seconds after the same response. Negative tests falsify delayed response, wrong hold request ID and premature producer deadline. Existing guard and durable SQLite checks stay mandatory. | +| [Nested OCI platform extras](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635102) | One strict linux/amd64 baseline-v1 validator applies to every advertised index/leaf application platform; unsupported optional OS/features/variant requirements fail. Nested and leaf synthetic falsifiers are rehashed to the root. | +| [Nested OCI document media types](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635112) | Every reachable OCI index/image-manifest document must omit embedded `mediaType` or match its parent descriptor. Tests cover both index-as-manifest and manifest-as-index with recomputed digest graph. | +| SPDX package/file validity | Validate `SPDXID` uniqueness, packages (`name`, `downloadLocation`) and files (`fileName`, nonempty checksums) separately. Require a nonempty combined inventory and fail closed for malformed records; diagnostic-only inspection emits bounded counts and field keys without file or package names. | +| Frozen SBOM scanner | `qualification/release-image-v1.json` now pins the historically published Docker BuildKit Syft scanner multi-platform OCI index (`sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68`). The workflow explicitly requests that digest rather than the mutable `stable-1` tag. Validate actual availability and generated SPDX content through CI. This is not a publisher signature or distribution clearance. | +| Evidence minimization | The RELEASE-1 upload explicitly allowlists sanitized reports and scenario logs. It no longer retains raw `oci-build-metadata.json`, `docker-build-metadata.json`, `docker-inspect.json`, OCI tarball, or unfiltered PR event payloads. Real verifier still receives full metadata during execution. | + +These repairs have been staged as sequential Git commit objects. Source/fast tests, all eleven workflows, actual BuildKit SBOM/SLSA verification, host aggregate and subsequent final-head Codex code/security review must all pass before these findings can be disposed as closed. No changes to engine search, move authority, offline isolation, budgets, licensing, OS/wheel lock completion, public-bot release or deployment permissions. + +## PR #71 — upstream/downstream assurance follow-up (October 14, 2026) + +This entry documents implementation intent and staged source changes, **not** approval or a green exact-head CI result. The previous head e90fc938 had 97/97 RELEASE-1 fast tests green but the actual OCI integration failed during APT acquisition due to repeated HTTP 503 responses at the pinned 20261001T000000Z Ubuntu snapshot. Other ONLINE and controller checks were green; this was an external acquisition blocker before image execution, SPDX or SLSA export. + +| Finding / boundary | Implemented repair and regression | +| --- | --- | +| [Codex P2 SPDX download location](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701069) | Support uppercase NONE/NOASSERTION or explicitly supported URL/VCS URI forms with valid authority, no embedded passwords/control characters. Reject arbitrary strings, invalid host/port, malformed syntax. Validation is syntax/policy, **not** a claim the package is actually downloadable. | +| [Codex P2 SPDX checksums](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701077) | Validate supported SPDX 2.x checksum algorithms and fixed canonical hex widths on file checksums and optional package checksums. Unsupported variable-width MD6 deliberately fails closed; duplicate algorithm and malformed/short/upper-case values rejected. | +| [Codex P2 OCI image-config platform](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701092) | Project image config platform-only fields into the already reviewed linux/amd64 baseline checker without rejecting legitimate rootfs, history and config keys. Rehashed archive/config/BuildKit metadata fixtures reject contradictory variant, OS features/version, wrong OS and architecture. Loaded Docker image OS is also checked if present. | +| Pinned Ubuntu snapshot acquisition | Dockerfile uses new deploy/release/install-pinned-apt.sh (included in build context and source attestation) for four bounded APT attempts. It clears prior index lists between failed attempts, retries only classified 429/502/503/504 conditions, and immediately rejects signature, checksum, certificate and untrusted-source errors. Early scripts/check-release-snapshot.py preflight checks reachability of all four fixed suites *before* native engine compilation. APT still validates signed indexes; no unlocked archive fallback. Fake APT tests exercise 503 recovery/permanent outage/signature/hash/404 cases. | +| Downstream RELEASE-1 aggregate | Require independent persisted SQLite verification, explicit blocked disposition, correct case/scenario/color, exact selected BuildKit OCI root digest, and status-specific allowable move POSTs. Existing host verifier remains authoritative for full chess legality and request/intent correlation. Destructive tests include forged green bits. | +| Pinned scanner | Added a standalone scanner-compatibility CI job that exports an OCI SBOM from the pinned Ubuntu **base image only**, with the same fixed Syft scanner OCI digest, before the costly three-engine build. It validates the genuine in-toto SPDX predicate using the same host verifier, but cannot grant release or publisher authority. Only a minimal sanitized scanner-count report is retained, never its OCI bytes. | +| Diagnostic artifact privacy | Diagnostic inspector now allowlists OCI annotations and SPDX field keys, summarizes only subject digests/counts and strips untrusted subject names/annotations and raw predicate contents. Negative tests assert no raw subject names or package inventory values appear in retained output. | + +**Unmodified non-grants:** move/search authority, runtime budgets, external network permissions, runtime dependency locks, licensing, publisher signing, destination-host admission, real Lichess credentials, public release, Elo or strength claims. All 11 exact-head workflows and final Codex code/security reviews must complete before merging draft PR #71. New scanner-compatibility is an additional job inside RELEASE-1, not a replacement for the real packaged image, OCI and smoke qualifications. + +## PR #71 October 17 — newest Codex P2 closure campaign (source staged; CI unverified) + +Current 2026-10-17 repair scope is source-linked against PR head f2e067ec. The previous exact-head workflow had 10/11 green, RELEASE-1 failed during `scripts/check-release-snapshot.py` HTTP body read with an unhandled TimeoutError; 113/113 fast contracts and the real digest-pinned scanner (one SPDX document: 93 packages, 2,265 files) passed. The full packaged image, OCI/SLSA export and aggregate did **not** execute at that head. All rows below mean IMPLEMENTED / AWAITING FINAL-HEAD CI unless independent evidence later changes the status. + +| Codex thread | Source change / test mapping | +| --- | --- | +| [GET allowlist / status](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459243) | `scripts/release_http_contract.py` is the single pure GET/POST admission function, dynamically reused by both `scripts/qualify-release-smoke.py` and `scripts/qualify-release-aggregate.py`. Exact six fake-server GET endpoints and status 200 required; no wildcard GET. `tests/release/test_release_http_contract.py`, `tests/release/test_release_smoke_evidence.py`, `tests/release/test_release_aggregate.py` challenge fabricated 200/401/429/403 and query-like endpoints. New helper is source-attested through `scripts/build-release-candidate.py`. | +| [OCI retained statement types](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459258) | `scripts/inspect-release-oci.py` now allowlists `_type`, `predicateType`, descriptor and config mediaType, sanitizes exported BuildKit digests and strips exception-derived freeform strings. Canary injection in `tests/release/test_release_oci_integrity.py` covers statement, subject name, annotation, config type and BuildKit metadata with an otherwise traversable archive. Diagnostic-only claim remains `integrity_verified=False`. | +| [SPDX malformed percent escape](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459266) | `scripts/verify-release-oci.py` requires well-formed `%HH`, no percent-escaped controls or encoded authorities; checks valid host labels, bracketed IPv6, port, and limited SSH username. `tests/release/test_release_oci_integrity.py` includes direct and fully rehashed attestation cases for malformed authority/percent/port and valid encoded path. | +| [ONLINE-4B actual URL vs UCI](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459279) | `scripts/qualify-online-recovery-v2.py` matches canonical URL `prefix+uci` separately for HTTP and trace before legal board/intent admission, rejects percent-encoded alternate move spellings, and preserves the existing counter/request journal checks. Also tightened `scripts/qualify-online-recovery.py` cross-generation trace witness. Adversarial controls in `tests/lichess/test_recovery_v2_evidence.py` and `tests/lichess/test_recovery_evidence.py`. | +| [Empty/truncated snapshot preflight](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459292) | `scripts/check-release-snapshot.py` consumes a bounded complete response, verifies a plausible PGP clear-signed Ubuntu InRelease envelope and exact suite/URL/source identity, rejects redirect/empty/truncated bodies, catches network/read timeout and reports structured failure with a global deadline. `tests/release/test_release_snapshot_preflight.py` covers 503, timeout at open/read, malformed signed envelope, unsupported suite, wrong content length, source-stanza alteration, slow drip, and artifact-on-failure. This is availability-only, never a signature-verification grant. | +| [Mixed APT failures](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459302) | `deploy/release/install-pinned-apt.sh` classifies each `Err:` / `E: Failed to fetch` acquisition, permits retry only for 429/502/503/504 and refuses concurrent DNS, signature, hash, TLS, 401, 404 or unknown errors. `tests/release/test_release_snapshot_retry.py` exercises multi-suite 503 and mixed-failure refusal. Snapshot, signing keys, package versions and old-index cleanup remain unchanged. | + +The adjacent source-attestation and runtime contracts remain unchanged in authority. This is **not** OS/wheel lock promotion, proof of a publisher signature, live Lichess authority, software distribution approval, a claimed Elo/playing-strength result, or merge approval. Final gate: all 11 exact-head workflows green, true offline packaged R00/R02/R10 and independent SQLite/OCI/SLSA/aggregate, then fresh Codex code and security reviews. Do not resolve review threads without tested evidence. + +## October 17 — six P2 cross-boundary repair commits (pending exact-head validation) + +**Previous verified exact head:** `43d76fee940635e6abfbc31d19f5ba6bcec7c65d`. +The full RELEASE-1 OCI + packaged R00/R02/R10 and independent SQLite/HTTP/OCI/SPDX/SLSA qualification **passed at this prior head**. Its aggregate still correctly set `offline_candidate_qualified=false` because OS/wheel locks and legal/operator/host/live-transport gates remain closed. ONLINE-3A/3B/4A/4B, baseline, LC0, controller, merge and publication jobs also passed; LOCAL-1 was still in progress at planning time. + +The following six source-linked Codex findings were found against that exact head. The implementation uses a single branch update after staging; **none is closed by documentation alone**. + +| Review thread | Repair | Adversarial tests | +| --- | --- | --- | +| [r4303673549](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673549) SPDX namespace URI | `scripts/verify-release-oci.py`: the same strict percent encoding / control / authority / hostname / port / credentials grammar now underlies SPDX namespace, package download and SLSA identities, with distinct schemes and fragment policies | `tests/release/test_release_oci_integrity.py` rehashed namespace mutations plus real pinned Syft scanner positive | +| [r4303673561](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673561) full GET request-target | `tests/lichess/http_target_contract.py` is source-attested, staged for packaged fake server, used by `tests/lichess/fake_lichess_api.py` and delegated to by `scripts/release_http_contract.py` for RELEASE-1 smoke and aggregate. Exact approved upstream `GET /api/users/status?ids=allfatherfixture` and `POST move?offeringDraw=true|false` are allowed; arbitrary queries, aliases, secrets and duplicate parameters rejected. Only approved complete targets enter retained records, rejected target values replaced with safe digest/disposition | `tests/lichess/test_fake_api.py`, `tests/release/test_release_http_contract.py`, smoke and aggregate GET/POST forgery controls | +| [r4303673565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673565) ONLINE-4A missing HTTP attempts | `scripts/qualify-online-recovery.py` checks every move POST against the matching `request-{trace.seq}` using an exact multiset rather than subset membership; canonical traced UCI/URL and chess/legal journal requirements remain | `tests/lichess/test_recovery_evidence.py` extra independently legal request, duplicate, swapped/missing ID, and mismatched trace URL | +| [r4303673570](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673570) unsafe OCI diagnostics | `scripts/inspect-release-oci.py` validates nested manifest/config/layer/statement types before traversal; output remains diagnostic-only and sanitized even on malformed input | `tests/release/test_release_oci_integrity.py` fully rehashed invalid config/list/null/scalar fixture, sanitized report and independent strict refusal | +| [r4303673578](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673578) archive pathname TOCTOU | `scripts/verify-release-oci.py` opens OCI archive with nofollow on one descriptor and hashes the same verified open inode. Initial/final fstat and final lstat identity are compared; aggregate still independently hashes its supplied archive | `tests/release/test_release_oci_integrity.py` atomic pathname replacement + in-place append during verification; `tests/release/test_release_aggregate.py` downstream replacement rejection | +| [r4303673584](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673584) HTTP-443 APT parser | `deploy/release/install-pinned-apt.sh` classifies statuses only in `Err:` reason and `E: Failed to fetch` context. Ports/IPs are never treated as HTTP status. Mixed DNS/signature/hash/unclassified failures remain immediate fatal errors. Snapshot and GPG trust pins unchanged | `tests/release/test_release_snapshot_retry.py` realistic 503/504 and `[IP: ... 443]` plus mixed errors | + +**Do not resolve any thread until exact-head CI and fresh code/security reviewer evidence are available.** No merge, live bot tokens, public artifact, licensure approval, OS/wheel locks, deployment rights or playing-strength claim is implied. + +## PR #71 — October 17 second Codex round, exact-head requalification pending + +**Baseline reviewed SHA:** `66e56b898f254a53cbb0bbb8a36ca7f2574174dd`. At this SHA nine supporting workflows were green and LOCAL-1 was running; RELEASE-1 real packaged producer runs (R00 white/black, R02, R10) individually passed, but the **independent host smoke verifier did not execute** because the standalone CLI import failed with `ModuleNotFoundError: No module named 'tests'`. Earlier RELEASE-1 fast tests (136/136) and Syft scanner compatibility passed. This is a *known failed qualification*, not release evidence. + +| Exact Codex finding / observed blocker | Source repair | Falsification/regression | +| --- | --- | --- | +| RELEASE-1 CLI import crash (run [39055671445](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39055671445)) | `scripts/release_http_contract.py` loads `tests/lichess/http_target_contract.py` by exact repository-relative file with `importlib.util`, without relying on the execution CWD or a top-level `tests` package. | `tests/release/test_release_http_contract.py` launches both real CLI entrypoints from unrelated temporary CWD with `PYTHONPATH` unset (preserving normal user-installed runtime dependencies); fast tests must pass before expensive build. | +| [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) imported HTTP target policy not source-attested | Both `qualification/online-recovery-v1.json` (`attested_source_paths`) and `qualification/online-recovery-v2.json` (`attested_paths`) now include `tests/lichess/http_target_contract.py` exactly once. Existing producer and independent source hash checks require identical bytes. | `tests/lichess/test_recovery_evidence.py` and `test_recovery_v2_evidence.py`: full correct source manifests pass; mutating only that helper's declared digest fails. | +| [r4303841564](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841564) leading `//` path normalized by CPython | `FakeLichess.Handler.parse_request` captures the originally submitted HTTP origin-form request target from `raw_requestline` *before* `BaseHTTPRequestHandler` normalizes leading slashes. `record`, `auth` and canonical GET/POST admission use only the original target; disallowed requests retain SHA256 fingerprint and safe path, never secret-like raw values. | Raw TCP socket tests send `GET //api/account`, `GET ///api/account`, `GET //api/users/status?ids=allfatherfixture` and require refused (403), noncanonical audit with no exposed query; legitimate `ids=allfatherfixture` GET remains admitted. | +| [r4303841560](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841560) present null in-toto statements wrapper | Both `scripts/verify-release-oci.py` and `scripts/inspect-release-oci.py` select singleton format **only when `statements` is absent**. A present null/object/string/empty array is refused. | `tests/release/test_release_oci_integrity.py` builds fully rehashed malicious OCI statement blobs and asserts independent negative OCI/diagnostic reports; real singleton and nonempty array remain positive. | +| [r4303841565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841565) raw non-RFC URI characters | Common URI validator uses RFC3986 ASCII unreserved/reserved grammar plus validated percent escaping. Raw Unicode, braces, pipes and carets cannot support SPDX namespace, download location or SLSA identity. | URI positive `%E2%98%83`, real pinned scanner; rehashed bad namespace/download fixtures, invalid Unicode/brace/pipe/percent direct tests. | +| [r4303841567](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841567) position-illegal ONLINE-4A attempt hidden by legal completed game | `scripts/qualify-online-recovery.py` replays **all** trace events in sequence with `python-chess`, checking each attempt at `ply_at_receipt`, game, bot side and board legality as well as canonical path/request ID. A coherent position-illegal attempt becomes an `UNSAFE_WITNESS` rather than `SAFE_CONTINUATION`. A stale retry gets a separate witness only after a prior legal *committed* request and request-correlated lost-ACK event; unproven repeats are unsafe. | `tests/lichess/test_recovery_evidence.py`: legal completed move with preceding syntactically valid position-illegal `e7e5` HTTP attempt, incorrect receipt ply, proven and unproven stale retry cases. | +| Adjacent ONLINE-4B correlation | `scripts/qualify-online-recovery-v2.py` matches exact path and server-generated `request-{seq}` ID multisets rather than paths only. | `tests/lichess/test_recovery_v2_evidence.py`: swapped/missing request-ID attacks. | + +**Claim boundary:** These fixes certify neither a deployed public bot nor live-network recovery, publisher signing, host qualification, OS/wheel dependency locks, licensing, public distribution, operator approval, or Elo/strength. All 11 exact-head workflows, actual installed-image/SQLite/OCI/SBOM/SLSA aggregate, and final Codex code/security reviews must be separately proven on the new pushed head. Do not resolve review threads based solely on this planned closure record. + +### Scanner-only upstream consistency follow-up + +`scripts/check-release-scanner.py` now makes the same precise distinction between a missing `statements` field (valid BuildKit singleton in-toto format) and an explicitly present null/scalar/object/empty `statements` wrapper (invalid). `tests/release/test_release_scanner_contract.py` applies rehashed negative wrapper fixtures to this generator-specific preflight in addition to the independently qualified OCI verifier and sanitized diagnostic reader. The scanner preflight remains non-authoritative for artifact distribution, signing, or full release qualification. **This is source implementation, not a passing exact-head CI claim until workflows run.** + +## October 17 — post-review corrections following exact-head `376b4c3c` + +**Observed CI, not a source claim:** exact-head `376b4c3c749bd6581afd8ee2e14776724d0dd872` passed 138/138 RELEASE-1 fast tests, pinned BuildKit/Syft preflight, all four packaged offline R00/R02/R10 games, independent persisted SQLite/chess/HTTP, and full OCI/SPDX/SLSA aggregate with `image_integrity_qualified=true`; `offline_candidate_qualified=false` and distribution authorization remained prohibited. ONLINE-4A independent evidence failed **only R01** at this head because the second position-legal submission after a dropped-before-commit first attempt was wrongly treated as ineligible to be the legally committed request. R02/R03 were preserved as observed unsafe stale retry witnesses; R10 exposed repeated forbidden abort requests as unsafe evidence. The merge gate initially hit an intermittent controller deadline timing failure but passed its unchanged-head job rerun, and the other supporting workflows were green except long-running LOCAL-1. + +| Finding / integration blocker | New source repair / negative regression | +| --- | --- | +| Real ONLINE-4A R01 independent failure | `scripts/qualify-online-recovery.py` keeps **every position-legal HTTP attempt** in the legal commit-correlation ledger while separately marking a second attempt at the same ply `repeated_move_submission_same_ply` unsafe. A true second legal server commit no longer becomes evidence corruption. `tests/lichess/test_recovery_evidence.py` simulates a dropped first request and legally committed second request. | +| [r4304... ONLINE-4B canonical raw target](https://github.com/HMarcusWH/AllfatherChess/pull/71) | `scripts/qualify-online-recovery-v2.py` applies the existing **source-attested** canonical target helper to every GET/POST raw request, requires `target_canonical=true` and exact raw-to-normalized path linkage before independent board/intent verification. `tests/lichess/test_recovery_v2_evidence.py` forges hidden query, leading double slash, false canonical bit, and retains a real positive `ids=allfatherfixture` GET. | +| Codex RFC3986 component delimiters | `scripts/verify-release-oci.py` validates URI path `pchar / /`, query/fragment `pchar / / / ?`, and separately validated authority with IPv6 brackets confined to host; raw brackets in path/query/URN NSS now fail. `tests/release/test_release_oci_integrity.py` checks direct and rehashed bad URI cases and IPv6/encoded-bracket positives. | +| Codex SLSA optional-standard-field containers | `scripts/verify-release-oci.py` now validates present recognized SLSA v1 and v0.2 buildDefinition/runDetails/builder/metadata fields, strings/maps, resource descriptors and lists, while treating absent and null optional fields as equivalent in accordance with SLSA parsing guidance. Negative rehashed v1 fixtures and v0.2 direct tests cover malformed dependencies, metadata, byproducts, builder version and resources; positive valid-optionals fixture preserved. | +| Scanner-only preflight alignment | `scripts/check-release-scanner.py` rejects present null/object/scalar/empty in-toto statement wrappers, while continuing to accept BuildKit singleton and array formats. `tests/release/test_release_scanner_contract.py` covers coherent rehashed negatives. | + +**Do not infer an exact-head passing result for these newly staged edits.** Run all RELEASE-1/ONLINE-4A/4B/controller/LOCAL-1 checks and fresh code/security reviews on the new pushed commit. Offline-only and all non-grant policies remain unchanged. + +## Bottom-up / top-down assurance consolidation (October 17, PR #71) + +This repair pass addresses the eight actionable Codex P2s from review of `32b45ba3` and broadens the contract beyond those comments. + +- **Trust roots:** `qualification/assurance-contract-v1.json` enumerates positive claims and required evidence; `scripts/verify_source_closure.py` uses Python AST imports rooted at executed producer and verifier entrypoints, including inherited v1 dependencies, build inputs and explicit dynamic imports. Both ONLINE-4 source policies now cover `fault_plan.py`, `log_capture.py`, `run_recovery.py`, and `Dockerfile.offline`. Before verdict formation, source files are independently compared against actual `git show exact_sha:path` bytes, not just producer-generated hash entries. +- **Transport and behavioral identity:** `qualification/http-evidence-contract-v1.json` and `scripts/http_evidence_verdict.py` distinguish canonical path from successful status and allow fault-specific HTTP 0/429/504 **only** on the trace-correlated request. ONLINE-4A classifies unexpected statuses as unsafe evidence; ONLINE-4B refuses them for guarded qualification. RELEASE-1 now requires exact actor/turn correspondence on every authoritative commit, not merely a legal PGN. +- **External specs:** In-toto singleton/array handling is shared in meaning across OCI, scanner and inspector; scanner refuses **all malformed array entries**, not only absent or null wrappers. RFC3986 admits valid IPvFuture/IPv6 authority forms and refuses invalid path/query delimiters. The in-toto ResourceDescriptor requirement of `uri`/`digest`/`content` is enforced with meaningful URI syntax, digest content and base64 validation. SLSA v1/v0.2 timestamps have RFC3339+chronological validation. Local versioned profiles under `qualification/schemas/` document our stricter typed semantic gates. +- **Retained evidence:** `scripts/sanitize_qualification_evidence.py` provides a centrally defined deny-by-default recursive report serializer, used by ONLINE-4A/4B and RELEASE-1 host reports. It removes freeform error, query, request-target and filename values, normalizes paths and preserves only validated identifiers/enumerations. +- **Claim checks:** `scripts/verify_assurance_contract.py` rechecks claim dependencies at the report boundary. RELEASE-1 no longer permits filling OS/wheel locks by itself to set `offline_candidate_qualified=true` while legal, host, network and operator gates are closed. Synthetic claims that omit required packaged cases are rejected. +- **Adversarial suite:** tests independently mutate transitive source imports, request IDs/statuses, bot actor origin, in-toto statement elements, SLSA descriptor identity/timestamps, claim flags and nested canary-bearing retained diagnostic records. + +All source changes are **pending a fresh exact-head real BuildKit/Syft, packaged R00/R02/R10, ONLINE-4A/4B and aggregate run**. The prior green head is not transferable qualification. Keep PR draft, no license, host, operator, public release or online bot authority. + +## Codex 2026-10-17T20:35 UTC — nine nested findings and independent real-build gate + +**Reviewed source head:** `fa8f457437b62e8a8ac043406de2a9330366ab52`. A follow-up `72a2c5` had fast 153/153 RELEASE-1 unit cases and the real pinned scanner green, while ONLINE-4A/4B/3B fast suites each failed the same obsolete invalid-UCI expectation (a newly redacted value) and the full RELEASE-1 built, played and independently qualified four packaged R00/R02/R10 cases but **failed at OCI SLSA provenance** because a legitimate BuildKit `pkg:docker/ubuntu@` opaque package URL was not admitted. These are confirmed CI blockers. The repair source below is staged for **fresh exact-head proof**, not yet independently qualified. + +| Codex finding | Root-level corrective action / regression | +| --- | --- | +| [P1 dynamically read files outside source closure](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304) | `scripts/verify_source_closure.py`: both independent expected-source sets now explicitly include their own policy file, scenario JSON, bridge source lock and HTTP status policy. Tests assert dynamic imports/inputs cannot drop out of computed closure. The producer's hash list remains insufficient without equality to `git show exact_sha:path`. | +| P1 BuildKit ResourceDescriptor `pkg:` identifiers | `scripts/verify-release-oci.py` recognizes valid RFC3986 opaque `pkg:docker/ubuntu@...` as ResourceURI in addition to authority-form HTTP/Git URI, with direct unit positive and required real pinned BuildKit provenance integration. | +| P2 HTTP fault-targeted status substitution | `scripts/http_evidence_verdict.py` checks correlated `fault_activated` request IDs before accepting HTTP 200; 0/429/504 are admitted only with expected scenario and target, and targeted requests cannot report normal 200 instead. Negative tests for all three fault types. | +| P2 image integrity graph incomplete | `scripts/qualify-release-aggregate.py` includes independently verified OCI integrity/layers/diffID/SBOM/SLSA/subject flags in a dedicated `oci_evidence` graph, and `scripts/verify_assurance_contract.py` requires all flags and exact archive/image digest fields before granting `image_integrity_qualified`, beyond just four packaged-game rows. | +| P2 CI path triggers omit assurance dependencies | `.github/workflows/online-recovery.yml` and `online-recovery-v2.yml` now trigger on all imported assurance scripts and `qualification/schemas/**`; tests check source closure and new dynamic policy paths. | +| P2 scanner skips malformed nested in-toto rows | `scripts/check-release-scanner.py` requires every row to have supported `_type`, predicateType, fully typed subject digest and predicate object; validates all supported SLSA predicates rather than scanning only SPDX; rehashed arrays with null, unsupported type, malformed subject or absent predicate are refused. | +| P2 regex enums permit safe-looking canaries | `scripts/sanitize_qualification_evidence.py` now admits only explicit per-field fixed enums; reason/origin/status/event and other producer fields containing `PRIVATE_CANARY` are redacted, not admitted by a character-pattern check. | +| P2 APT install transient summary misclassified | `deploy/release/install-pinned-apt.sh` recognizes the exact standard install summary only alongside individually classifiable 429/502/503/504 acquisition failures; mixed 401, DNS, hash and GPG errors still fail immediately. Simulated install-only tests exercise both cases. | +| P2 SPDX incomplete package records | `scripts/verify-release-oci.py` now validates required nonempty copyrightText, boolean filesAnalyzed if present, conditional SHA1 packageVerificationCode when filesAnalyzed true/omitted, and omission of verification code/file license info when false; optional licenseConcluded/licenseDeclared validate if present, consistent with SPDX 2.3 fields being optional and defaulting to NOASSERTION. Fully rehashed package variants test each condition; genuine pinned Syft remains the positive gate. | + +The source-closure, typed status and report graph remain **offline-only claims**. No OS/wheel lock, licensing, publisher-signature, host, network-play, operator or public-distribution authorization follows from successful negative tests or real OCI image integrity. Update this ledger with the final exact-head source SHA and CI links only after a completed run, and retain unresolved Codex threads until demonstrated dispositions are supported. diff --git a/docs/PR71_ASSURANCE_REPAIR_LEDGER.md b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md new file mode 100644 index 00000000..5843b29c --- /dev/null +++ b/docs/PR71_ASSURANCE_REPAIR_LEDGER.md @@ -0,0 +1,184 @@ +# PR #71 — Assurance Repair and Review Closure Ledger + +**Scope:** source-confirmed repair chain for PR #71, based on original head `887acbb2b1f33c65a9a322eae32fa1327ce3d203`. This ledger is historical and must be updated against the final pushed SHA and latest CI evidence before merger. + +**Authority:** Offline assurance only. No public bot, real credentials, production host qualification, publisher signature, licensing approval, artifact distribution, Elo or deployment authority. + +## Existing qualification blockers (observed October 17, 2026) + +| Issue | Evidence | Targeted repair | Exit condition | +| --- | --- | --- | --- | +| Invalid move path retained | ONLINE-3B/4A/4B reject `/api/bot/game/off3b001/move/garbage` retained after UCI redaction | Strict complete-endpoint publication at fake server/trace and report serializer; adversarial tests | Three real suites green, final artifacts contain no malformed path canaries | +| Scanner builder identity | RELEASE-1 pinned Syft/BuildKit scanner fails `SLSA v1 builder identity or field shape invalid` | Pin declared provenance builder ID for the real scanner probe; separate builder object/id/version/dependency diagnostics | Pinned scanner and full OCI integration green without weakening the validator | + +## Non-outdated Codex threads requiring evidence review + +These threads are **not** mechanically closed by source presence. For each, retain test/CI evidence, determine whether the original counterexample is rejected or valid input is admitted, and only then resolve or explain its disposition. + +| Comment | Source | Finding | Status | +| --- | --- | --- | --- | +| [r4263102049](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102049) | `scripts/qualify-release-smoke.py` | Bind the R02 unknown ACK to the move endpoint | Source regression/CI review pending | +| [r4263102056](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102056) | `scripts/verify-release-oci.py` | Validate the attestation subject descriptor size | Source regression/CI review pending | +| [r4263102061](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102061) | `scripts/qualify-release-smoke.py` | Require client-side evidence of the R10 response | Source regression/CI review pending | +| [r4265369283](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369283) | `scripts/qualify-release-smoke.py` | Validate the persisted cooldown deadline | Source regression/CI review pending | +| [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) | `tests/lichess/fake_lichess_api.py` | Attest the imported HTTP target policy | Source regression/CI review pending | +| [r4304011435](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011435) | `scripts/qualify-online-recovery-v2.py` | Validate raw targets in ONLINE-4B request evidence | Source regression/CI review pending | +| [r4304011445](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011445) | `scripts/verify-release-oci.py` | Enforce URI delimiters per component | Source regression/CI review pending | +| [r4304111685](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111685) | `qualification/online-recovery-v2.json` | Attest every direct recovery dependency | Source regression/CI review pending | +| [r4304111688](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111688) | `scripts/qualify-online-recovery-v2.py` | Reject unsuccessful canonical requests in ONLINE-4B | Source regression/CI review pending | +| [r4304111692](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111692) | `scripts/check-release-scanner.py` | Reject non-object rows in scanner statement arrays | Source regression/CI review pending | +| [r4304111718](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111718) | `scripts/qualify-release-smoke.py` | Bind every committed origin to the side to move | Source regression/CI review pending | +| [r4304394201](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394201) | `scripts/verify_assurance_contract.py` | Enforce the declared image-integrity prerequisites | Source regression/CI review pending | +| [r4304394202](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394202) | `.github/workflows/online-recovery-v2.yml` | Trigger recovery CI for every new verifier dependency | Source regression/CI review pending | +| [r4304394206](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394206) | `scripts/check-release-scanner.py` | Validate complete in-toto objects in every scanner row | Source regression/CI review pending | +| [r4304394212](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394212) | `deploy/release/install-pinned-apt.sh` | Permit the standard transient install summary | Source regression/CI review pending | +| [r4304394221](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394221) | `scripts/verify-release-oci.py` | Validate required SPDX package fields | Source regression/CI review pending | + +## Full historical inline-review inventory + +GitHub recorded 58 threads at the time of ledger construction: 42 outdated diff contexts, 16 non-outdated, and 0 formally resolved. "Outdated" means changed diff location, **not** independent proof of repair. + +| Comment | Date (UTC) | Path | Diff context | +| --- | --- | --- | --- | +| [r4253078059](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078059) | 2026-10-12 23:00 | `scripts/verify-release-oci.py` | Outdated | +| [r4253078070](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253078070) | 2026-10-12 23:00 | `deploy/release/offline_smoke.py` | Outdated | +| [r4253714587](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714587) | 2026-10-13 01:00 | `scripts/qualify-release-smoke.py` | Outdated | +| [r4253714591](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714591) | 2026-10-13 01:00 | `scripts/qualify-release-smoke.py` | Outdated | +| [r4253714599](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714599) | 2026-10-13 01:00 | `scripts/verify-release-oci.py` | Outdated | +| [r4253714605](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714605) | 2026-10-13 01:00 | `scripts/verify-release-oci.py` | Outdated | +| [r4253714615](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4253714615) | 2026-10-13 01:00 | `scripts/inspect-release-oci.py` | Outdated | +| [r4263102049](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102049) | 2026-10-13 19:30 | `scripts/qualify-release-smoke.py` | Current | +| [r4263102056](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102056) | 2026-10-13 19:30 | `scripts/verify-release-oci.py` | Current | +| [r4263102061](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102061) | 2026-10-13 19:30 | `scripts/qualify-release-smoke.py` | Current | +| [r4263102073](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4263102073) | 2026-10-13 19:30 | `scripts/verify-release-oci.py` | Outdated | +| [r4265369277](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369277) | 2026-10-14 00:02 | `scripts/verify-release-oci.py` | Outdated | +| [r4265369279](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369279) | 2026-10-14 00:02 | `scripts/verify-release-oci.py` | Outdated | +| [r4265369283](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265369283) | 2026-10-14 00:02 | `scripts/qualify-release-smoke.py` | Current | +| [r4265635092](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635092) | 2026-10-14 00:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4265635097](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635097) | 2026-10-14 00:50 | `scripts/qualify-release-smoke.py` | Outdated | +| [r4265635102](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635102) | 2026-10-14 00:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4265635112](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4265635112) | 2026-10-14 00:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4271701069](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701069) | 2026-10-14 13:54 | `scripts/verify-release-oci.py` | Outdated | +| [r4271701077](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701077) | 2026-10-14 13:54 | `scripts/verify-release-oci.py` | Outdated | +| [r4271701092](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4271701092) | 2026-10-14 13:54 | `scripts/verify-release-oci.py` | Outdated | +| [r4272459243](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459243) | 2026-10-14 14:58 | `scripts/qualify-release-aggregate.py` | Outdated | +| [r4272459258](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459258) | 2026-10-14 14:58 | `scripts/inspect-release-oci.py` | Outdated | +| [r4272459266](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459266) | 2026-10-14 14:58 | `scripts/verify-release-oci.py` | Outdated | +| [r4272459279](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459279) | 2026-10-14 14:58 | `scripts/qualify-online-recovery-v2.py` | Outdated | +| [r4272459292](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459292) | 2026-10-14 14:58 | `scripts/check-release-snapshot.py` | Outdated | +| [r4272459302](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4272459302) | 2026-10-14 14:58 | `deploy/release/install-pinned-apt.sh` | Outdated | +| [r4303673549](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673549) | 2026-10-17 17:14 | `scripts/verify-release-oci.py` | Outdated | +| [r4303673561](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673561) | 2026-10-17 17:14 | `scripts/release_http_contract.py` | Outdated | +| [r4303673565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673565) | 2026-10-17 17:14 | `scripts/qualify-online-recovery.py` | Outdated | +| [r4303673570](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673570) | 2026-10-17 17:14 | `scripts/inspect-release-oci.py` | Outdated | +| [r4303673578](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673578) | 2026-10-17 17:14 | `scripts/verify-release-oci.py` | Outdated | +| [r4303673584](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303673584) | 2026-10-17 17:14 | `deploy/release/install-pinned-apt.sh` | Outdated | +| [r4303841556](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841556) | 2026-10-17 18:01 | `tests/lichess/fake_lichess_api.py` | Current | +| [r4303841560](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841560) | 2026-10-17 18:01 | `scripts/verify-release-oci.py` | Outdated | +| [r4303841564](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841564) | 2026-10-17 18:01 | `tests/lichess/fake_lichess_api.py` | Outdated | +| [r4303841565](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841565) | 2026-10-17 18:01 | `scripts/verify-release-oci.py` | Outdated | +| [r4303841567](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4303841567) | 2026-10-17 18:01 | `scripts/qualify-online-recovery.py` | Outdated | +| [r4304011435](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011435) | 2026-10-17 18:50 | `scripts/qualify-online-recovery-v2.py` | Current | +| [r4304011445](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011445) | 2026-10-17 18:50 | `scripts/verify-release-oci.py` | Current | +| [r4304011453](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304011453) | 2026-10-17 18:50 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111685](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111685) | 2026-10-17 19:15 | `qualification/online-recovery-v2.json` | Current | +| [r4304111688](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111688) | 2026-10-17 19:15 | `scripts/qualify-online-recovery-v2.py` | Current | +| [r4304111692](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111692) | 2026-10-17 19:15 | `scripts/check-release-scanner.py` | Current | +| [r4304111694](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111694) | 2026-10-17 19:15 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111700](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111700) | 2026-10-17 19:15 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111708](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111708) | 2026-10-17 19:15 | `scripts/qualify-online-recovery.py` | Outdated | +| [r4304111713](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111713) | 2026-10-17 19:15 | `scripts/verify-release-oci.py` | Outdated | +| [r4304111718](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304111718) | 2026-10-17 19:15 | `scripts/qualify-release-smoke.py` | Current | +| [r4304394181](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394181) | 2026-10-17 20:35 | `scripts/verify_source_closure.py` | Outdated | +| [r4304394188](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394188) | 2026-10-17 20:35 | `scripts/verify-release-oci.py` | Outdated | +| [r4304394194](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394194) | 2026-10-17 20:35 | `scripts/http_evidence_verdict.py` | Outdated | +| [r4304394201](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394201) | 2026-10-17 20:35 | `scripts/verify_assurance_contract.py` | Current | +| [r4304394202](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394202) | 2026-10-17 20:35 | `.github/workflows/online-recovery-v2.yml` | Current | +| [r4304394206](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394206) | 2026-10-17 20:35 | `scripts/check-release-scanner.py` | Current | +| [r4304394209](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394209) | 2026-10-17 20:35 | `scripts/sanitize_qualification_evidence.py` | Outdated | +| [r4304394212](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394212) | 2026-10-17 20:35 | `deploy/release/install-pinned-apt.sh` | Current | +| [r4304394221](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4304394221) | 2026-10-17 20:35 | `scripts/verify-release-oci.py` | Current | + +## Closure rules + +1. Before any push, run compile/fast unit suites, counterexamples for repaired endpoints and SLSA/OCI generated fixtures. +2. Test the real pinned scanner and the genuine ONLINE-3B/4A/4B + RELEASE-1 integrations; a skipped producer or missing download artifact is not green. +3. Require all eleven exact-head workflow conclusions success, including the seven previously green workflow groups. +4. Preserve independently verified source byte closure, chess legality, actor attribution, HTTP statuses, SQLite replay, OCI archive identity and all safe-output boundaries. +5. Request fresh exact-head Codex code and security review. Reconcile every actionable new finding with source/test/CI evidence. +6. Leave all source availability, distribution, licensing, operator, host, live transport, and publication gates closed. + +## October 18 Codex repair wave (source-implemented, exact-head CI pending) + +The five fresh [Codex findings](https://github.com/HMarcusWH/AllfatherChess/pull/71) +from review of `16faccaba5` remain actionable until their source, negative +counterexamples, **real pinned scanner**, packaged OCI integration and new +exact-head review are reconciled. No finding is declared closed by this +ledger alone. + +| Finding | Source repair | Required negative oracle | +| --- | --- | --- | +| [r4305073147](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073147) SPDX 2.2/2.3 | Version-specific package fields, strict 2.2 analyzed package code, valid 2.3 optional semantics | Rehashed 2.2 missing fields reject; 2.3 valid optional cases accept | +| [r4305073150](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073150) SLSA probe | Require exactly one v1 provenance statement with pinned builder | Missing, duplicate, wrong-version, wrong-builder all reject | +| [r4305073152](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073152) License expressions | Offline SPDX 2.x parser and pinned 740-license/86-exception catalogue v3.29.0 | Unknown IDs, malformed operators and injected free text reject | +| [r4305073153](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073153) Verifier source | Compare stage/Git/executed bytes and import from private verified snapshot | Modified, rehashed, linked and post-snapshot mutations reject | +| [r4305073154](https://github.com/HMarcusWH/AllfatherChess/pull/71#discussion_r4305073154) SPDX file checksums | Valid SHA-1 mandatory, others permitted if well-formed | SHA256-only, malformed and duplicate SHA-1 reject | + +**Stop rule:** all eleven exact-head workflow groups complete successfully, +including real RELEASE-1 OCI/smoke, guarded recovery, LC0 and LOCAL-1; +then a fresh exact-head Codex code/security review with no outstanding +unaddressed findings. Offline test authority never becomes online authority. + +## October 18 — pinned SHA1 scanner follow-up (candidate-only) + +Real RELEASE-1 run [39087354015](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39087354015) passed all 181 fast contracts and failed only at pinned scanner real SPDX files: `SPDX file requires a valid SHA1 checksum`. Source analysis of upstream v1.11.0 (commit `d88056b4e5b61d0ca037340df91be47d343b4386`) traced the default file hasher to SHA256 alone. + +Implementation now sources a single SHA1+SHA256 patch in `deploy/release/scanner/target.go.patch`, compiles it twice using vendored dependencies, and routes it through an isolated job-local OCI registry. A candidate scanner digest is **not an approved image**. Full qualification remains blocked until both `approved_binary_sha256` and `approved_manifest_digest` are independently reviewed, frozen and rerun at one SHA. Only after real scanner/OCI positives and new Codex review may the PR be considered for merge; all public distribution/live-play grants remain false. + +## Scanner reproducibility positive, approval lock frozen + +Source-bound [RELEASE-1 run 39130571069](https://github.com/HMarcusWH/AllfatherChess/actions/runs/39130571069): +Go 1.25.8 produced two identical binary hashes, +`a1043ee5fa8285740548b600fd24d106b4e68211327238b4edbbe70765bd67ca`; +two BuildKit scratch exports produced the identical OCI manifest +`sha256:fe9efbc9d42e6ddbbe44f0f35e2daa45299123891076c1700414a74978a51d2a`. +The pinned Syft v1.11.0 patch then passed strict SPDX (93 packages, 2265 files) +and SLSA v1 builder checks. The run correctly refused final compatibility at +the independent **unapproved digest** gate, which now has these reviewed +identities frozen in `qualification/release-scanner-v1.json`. + +The following exact-head run must independently remeasure those hashes and +check actual base-image `/usr/bin/dpkg` contents against scanner SHA1/SHA256 +before any full RELEASE-1 image qualification. Code review, distribution and +host/live authorizations remain separate, closed gates. + +## October 18 — independent manual review follow-up after green 11/11 + +Independent exact-head [manual review](https://github.com/HMarcusWH/AllfatherChess/pull/71#pullrequestreview-5564010246) +identified five source-admission and evidence-integrity counterexamples on +`18b5e1f2fd65`: full-image SLSA builder/version not pinned; OCI verifier +executed before committed-source check; ONLINE-4B phase log re-use and +path escapes; ONLINE-4A replay route/manifest symlink escapes; ONLINE-4A/4B +mutable imports executing before committed-byte verification. + +The source repair wave: +- Full OCI verifies exactly one SLSA **v1** statement with the same pinned + builder ID as the actual release policy (not merely the scanner probe). +- Rehashed image fixtures with v0.2, wrong builder, or duplicate provenance + fail even when all descriptor hashes match. +- Independent RELEASE-1 smoke, OCI diagnostics, OCI assurance and aggregate + execute in a fresh detached `git worktree` at the exact source commit, + created after mutable build steps. +- ONLINE-4A/4B separate independent verifier jobs construct their own + fresh detached exact-commit worktree and verify full attested source closure + **before any local recovery Python imports**; ONLINE-4B fetches independently + pinned bridge source inside that worktree. +- ONLINE-4B binds sanitized bridge logs to exact phase index and + `bridge-phase-N.log` with file containment, symlink refusal and byte digest. +- ONLINE-4A replay manifest and route files refuse symlink escapes, + nonregular files and lexical traversal. + +All five fixes have regression or source-runner contract tests. No item is +declared qualified until the **new** exact-head real CI matrix and independent +review pass. Prior green runs and historical comment resolution do not satisfy +the new head's review gate. diff --git a/docs/RELEASE_1_OPERATIONS.md b/docs/RELEASE_1_OPERATIONS.md index cd8d7f0b..937b3954 100644 --- a/docs/RELEASE_1_OPERATIONS.md +++ b/docs/RELEASE_1_OPERATIONS.md @@ -78,12 +78,14 @@ docker run --rm --network none --read-only \ --tmpfs /run/allfather:rw,nosuid,size=64m,uid=10001,gid=10001 \ --tmpfs /tmp:rw,nosuid,size=128m,uid=10001,gid=10001 \ --volume "$PWD/test-persistent:/var/lib/allfather:rw" \ + --volume "$PWD/test-persistent/replays:/opt/allfather/build/replays-online-hybrid-v2:rw" \ -e ONLINE_RELEASEx_ISOLATED=1 -e ONLINE4B_ISOLATED_CONTAINER=1 \ allfather/release1:offline offline-smoke \ --scenario R00 --color white --run-id smoke-white ``` -Prepare the host test-persistent directory for UID/GID 10001 before use. +Prepare `test-persistent/replays` and the parent directory for UID/GID 10001 +before use (for example `mkdir -p test-persistent/replays && sudo chown -R 10001:10001 test-persistent`). The distinct bound persistent mount contains the SQLite WAL and epoch fence. A separate persistent replay bind mount is mandatory because the sealed UCI config writes to build/replays-online-hybrid-v2 relative to /opt/allfather. @@ -98,6 +100,33 @@ bridge restart and R10 proves 60-second account-wide cooldown containment. The frozen ONLINE-4A and ONLINE-4B source harnesses remain separately qualified in their own CI workflows. +## SLSA and SPDX assurance contract + +The scanner-only compatibility job uses the same explicit reviewed BuildKit +`builder-id` as the full OCI export, with a separate SBOM attestation. The +independent scanner verifier validates all recognized in-toto statement +siblings and SLSA v1 builder fields. The builder ID is a declared build +platform identity, **not** a publisher signature or distribution permission. +Field-specific failures must be reported using fixed classifications, never +by echoing arbitrary provenance strings into public CI logs. + +SPDX 2.2 and SPDX 2.3 have different package-field requirements. In the +strict SPDX 2.2 profile, package copyright and concluded/declared license +fields are mandatory, and analyzed packages require a verification code. +SPDX 2.3 permits those package metadata fields and the verification code to +be absent, with NOASSERTION semantics where specified. In both versions, +`filesAnalyzed` defaults to true; a supplied verification code must be +valid, and `filesAnalyzed=false` prohibits verification codes and +file-license arrays. Every SPDX file record must contain a valid SHA-1 +checksum, with optional additional supported digests. + +Every supplied license field is checked by the source-attested offline SPDX +2.x expression parser against the frozen SPDX License List v3.29.0 +(`qualification/spdx-license-identifiers-v3.29.0.json`); syntactically +valid licenses do not establish actual copyright permissions. The OCI verifier +independently checks application manifest subjects, the full layer graph, +archive bytes and provenance, and never authorizes distribution. + ## Image, host, licensing and governance checks scripts/verify-release-candidate.py checks all packaged files against an @@ -174,3 +203,265 @@ RELEASE-1 also exports only Git-tracked files from the verified lichess-bot checkout. Untracked files such as a local `.env` may not enter the image regardless of their contents. The CA trust-root bootstrap source is bound into the source-attestation inventory. + +## Post-merge Codex hardening: source-bound OCI and independent move evidence + +PR #71 introduces an explicitly named **local OCI export** (`--tag allfather/release1:offline`). +The tag is an attestation identity input; the workflow uses `type=oci,dest=...`, +not a registry push, and never publishes the image or OCI tarball. +`scripts/inspect-release-oci.py` writes bounded diagnostic-only JSON before +`scripts/verify-release-oci.py` evaluates the archive. The diagnostic report is +not an integrity verdict. Image-level in-toto subjects and Docker descriptor +references must identify the exact selected application manifest, while the +OCI layers must still match the ordered expanded `config.rootfs.diff_ids`. +The final aggregate independently binds the generated OCI archive SHA-256. + +Each installed-image smoke run now retains evidence schema 2: the ordered +server transport trace with a canonical SHA-256 digest, request-ID-correlated +HTTP responses, authoritative board commits, durable SQLite journal and +guard submission audit. On the CI host, `scripts/qualify-release-smoke.py` +independently reconstructs move legality, HTTP-attempt uniqueness, accepted +board history and journal/epoch identities. It also checks R02's real +lost-ACK restart and R10's complete post-429 observation window and +account-wide 60-second API cooldown. A producer `ready: true` without those +independent witnesses is insufficient for qualification. + +The host-side report `build/test-results/release1/smoke-verdict.json` and +bounded OCI descriptor report `oci-attestation-structure.json` are retained +as non-binary CI diagnostics. No raw OCI archive, executable, private log or +persistent SQLite replay volume is uploaded by the RELEASE-1 workflow. + +**No new release permissions are implied.** OS/wheel reproducibility locks, +combined license review, destination-host qualification, real Lichess +transport, public BOT account play and operator approval remain blocked. + +### Final PR #71 evidence-integrity controls + +BuildKit's OCI metadata identifies the exported root descriptor, usually a nested image index. The outer layout index.json, selected nested image index, runnable application manifest, and attestation manifest must be kept distinct. The exact exported descriptor digest, media type and size must match BuildKit metadata. Every embedded OCI descriptor is checked against its actual archive member length and hash. + +The workflow requests minimal SLSA provenance with an explicit builder-id URI. A schema-shaped empty predicate or blank builder identity fails the version-correct validator; actual BuildKit output must still pass the real OCI qualification job. The bounded recursive inspection report is diagnostic-only. + +The packaged smoke verifier now opens the real state/move-journal.sqlite3 read-only and checks integrity, owner, games, intents, cooldown, events and WAL-visible state before accepting result.json. It rejects linked or missing state paths and mismatching snapshots. SQLite/JSON consistency is not a signature and does not protect against a writer that rewrites both records coherently. + +For R02 the fake server records move request IDs in response_write_completed and connection_close_requested, resetting the identifier at every new HTTP/1.1 request. The independent host verifier rejects a completed response for the targeted move and requires the correlated close, first-epoch unknown ACK and subsequent recovery evidence. A server-side 200 status alone does not establish that the client received the response. + +The pure verify_smoke() function remains useful for synthetic trace-unit tests. Only verify_file() and qualify_directory() verify persisted SQLite durability. The release permissions, source/engine configurations, and outward move authority remain unchanged. + +## Final PR #71 repair contracts (October 2026) + +The independent OCI verifier now checks that the *nested-index runnable-image descriptor* advertises `linux/amd64`, agreeing with the image configuration; a supported flat manifest without a platform descriptor remains permitted, but a declared platform must agree. Any present OCI 1.1 attestation manifest subject is validated against the application's already-hashed manifest descriptor (digest, media type and exact nonboolean integer byte length). The recursive inspector exposes declared and actual subject sizes as **diagnostic-only** data. + +R02 lost acknowledgment requires a unique first-epoch `http_unknown_ack` audit with `endpoint="move"`; a non-move or missing endpoint cannot stand in for that evidence. R10 requires a matching completed HTTP 429 response, a matching first-epoch `global_rate_limit_block` guard audit, an on-disk `UNKNOWN_ACK` intent and `http429:move` cooldown, plus the existing account-wide API freeze and 61-second observation. Each is independently verified; a producer `ready=true` is never sufficient. + +The Dockerfile's snapshot-based APT update/install uses bounded `Acquire::Retries=2` and HTTPS timeouts of 30 seconds. Persistent failure is not ignored: the workflow preserves a bounded failure-only JSON report at `build/test-results/release1/snapshot-acquisition-failure.json`, prints the tail of the temporary build log, and exits unsuccessfully. Package sources, signatures, pinned base and snapshot, and release restrictions remain unchanged. A successful retry is not proof of pinned OS/wheel inventory or release permission. + +## PR #71 further integrity hardening + +Stopped packaged smoke containers write the persistent recovery SQLite journal as UID 10001. The host runner may read files but cannot create or update WAL shared-memory sidecars there. The independent verifier now copies only the regular SQLite main file and optional WAL bytes into a private host-owned temporary directory, omits producer SHM, and opens the copy through normal SQLite mode=ro, consistent transaction and integrity check. This retains WAL-visible committed rows while leaving original evidence untouched. immutable=1 is never used. Directory/sidecar symlinks, corrupt bytes and JSON mismatches fail qualification. + +For OCI platform selection, every *advertised* platform on a nested-index descriptor leading to the runnable application must match linux/amd64, in addition to the leaf manifest and image configuration checks. Synthetic nested root/intermediate platform falsifiers are covered. Attestation-only index branches are not mistaken for runnable images. + +SPDX 2.x documentation predicates must include the real document-creation identity and a structurally identified nonempty package/file inventory: CC0-1.0 data license, SPDXRef-DOCUMENT, document name and URI namespace, UTC creation timestamp and creator records. Such shape validation never establishes publisher authenticity, licensing or redistribution permission. + +R10 requires a finite numeric SQLite cooldown deadline consistent with the single same-epoch durable cooldown event's recorded deadline, cause and timestamp. The original 60–86400-second duration is checked with a narrow transaction-timestamp tolerance and against the client's 429 guard audit. A matching but semantically forged JSON+SQLite deadline is not sufficient. + +All final-head real-process workflows, standalone OCI attestation verification, additional Codex code/security review, dependency locks and public-release gates remain independently enforceable. + +## Final PR #71 SPDX, OCI, observation-window and evidence workflow + +SBOM validation: in-toto SPDX predicate document must have supported 2.2/2.3 creation identity, legal namespace without `#`, identified file/package records, unique SPDX IDs, valid package `name`+`downloadLocation`, and file `fileName`+nonempty checksum inventory. The offline release contract additionally requires nonempty combined inventory. The diagnostic-only inspector reports bounded counts and SPDX field names, never arbitrary file names or full predicate contents. A genuine nonempty SPDX file-only SBOM is valid; an absent or malformed inventory cannot be admitted. + +OCI runtime selection: declared platforms on every selected runnable index/manifest path must match the qualified linux/amd64 baseline; unqualified nonempty OS/CPU requirements, invalid field types, unsupported variants and additional unknown platform requirements are rejected. Each embedded index/manifest `mediaType`, if present, must match the referencing descriptor; all child descriptors remain digest/size verified. + +R10: the `rate_limit_hold_completed` event must be request-correlated and recorded after 61.25 seconds have elapsed since the targeted real HTTP 429 response completion. The independent host verifier rejects a completed hold less than 61 seconds after this event. A server write does not by itself prove client receipt: the same first-epoch `global_rate_limit_block` guard audit and persistent SQLite cooldown evidence are independently required. The producer's fixed 100-second scenario deadline remains a bounded fail-closed timeout. + +SBOM generator: RELEASE-1 no longer uses the historical SHA256-only Docker Hub scanner. The scanner-only source lock is `qualification/release-scanner-v1.json`, bound to docker/buildkit-syft-scanner v1.11.0 at commit `d88056b4`, Go 1.25.8, vendored Syft 1.42.3 and the SHA256-approved `deploy/release/scanner/target.go.patch`. The patch adds real SHA1 hashing beside SHA256; it does not modify SPDX evidence after scanning. The scanner is compiled twice with vendored-only dependencies and builds two disposable scratch images to test binary and image reproducibility. Its manifest is resolved in a loopback-bound, runner-local registry and passed to BuildKit by immutable digest, never a mutable tag. CI still computes full BuildKit metadata to verify attestation but uploads only allowlisted sanitized reports, OS inventory and four local smoke logs; full scanner binaries, OCI archives, raw SBOM/provenance and private event payloads are never uploaded. + +The green supporting workflows and full image smoke testing remain necessary but insufficient: standalone OCI attestation verification, final host aggregate, dependency qualification and a fresh review must pass. The publication/licensing/live-game prohibitions are unchanged. + +## RELEASE-1 snapshot, SBOM and downstream evidence qualification + +**Pinned snapshot acquisition:** The image still starts from the digest-pinned Ubuntu base and two native APT sources with GPG Signed-By and the frozen Snapshot field. The minimal base bootstraps CA trust from signed native HTTP indexes as before. Remaining packages are installed with deploy/release/install-pinned-apt.sh: up to four bounded attempts for transient HTTP 429/502/503/504 responses, with old index lists discarded after failure. Hash, signature, certificate or unrecognized download failures are fatal; there is no mirror or unsigned fallback. An availability-only snapshot preflight runs before expensive engine compilation and emits a sanitized JSON diagnostic. It does not replace APT signature validation. + +**Scanner preflight:** The scanner-compatibility job builds the v1.11.0 +source-locked generator with SHA1 + SHA256, starts a disposable local registry +and builds a minimal test OCI image without chess engines. It requests one +SPDX SBOM and one minimal SLSA v1 provenance statement, then independently +verifies the exact builder ID frozen in `qualification/release-image-v1.json`. +The actual manifest digest and binary SHA256 are recorded as **candidates**. +They are never admitted just because the job produced them: the source lock's +`approved_manifest_digest` and `approved_binary_sha256` must be populated +through a separate review of clean reproducible builds. Until then the +scanner-compatibility job deliberately fails after reporting the bounded +candidate identities; the full OCI integration remains skipped. The completed +image identity still cannot grant publisher signature, licensing, network or +distribution authority. + +**SPDX source rules:** Package downloadLocation may be NONE, NOASSERTION, or a supported syntactically valid URL/VCS location without embedded secrets. File and optional package checksums require supported exact algorithm and canonical lowercase fixed-width digest. Variable-width MD6 is explicitly unsupported in the release profile. A structurally valid SPDX document is not evidence of actual package-download provenance. The SPDX schema and release-profile policy are separate claims. + +**Image runtime platform:** Both every selected runnable descriptor platform and the OCI image configuration platform fields must match the qualified linux/amd64 baseline. The image config retains its legitimate rootfs, history, and runtime config values; strict descriptor-only requirements are not applied indiscriminately to the entire image JSON. The full graph is hashed and linked through BuildKit metadata and the loaded Docker image inspection. + +**Admission and evidence:** scripts/qualify-release-aggregate.py now requires an independent host smoke verdict with persisted SQLite matching actual disk, exact case identity, no publisher/public grant, and release_blocked=True. The aggregate also checks the selected exported OCI root digest against BuildKit's declared digest. Allowed HTTP statuses and paths are rechecked as defense in depth; legal move authorization and no-blind-retry stay exclusively with the strict host verifier. Generated diagnostic artifacts are allowlisted, free of raw BuildKit metadata, image layers, subject names and unreviewed annotations. + +**Stop condition:** An exact-head test runner must pass the complete RELEASE-1 fast contracts, scanner compatibility, pinned signed APT, installed Docker image, R00/R02/R10 persisted real-process cases, OCI attestation and SLSA validation, and final aggregate; all other workflow gates plus fresh Codex review must be green before draft PR merge. The OS/wheel locks remain incomplete and public distribution remains prohibited. + +## PR #71 October 17: fault-evidence admission and snapshot/diagnostic follow-up + +The authoritative RELEASE-1 HTTP request contract is now `scripts/release_http_contract.py` (host-only, source-attested). Exact GET endpoints are `/api/stream/event`, `/api/bot/game/stream/off3b001`, `/api/account`, `/api/account/playing`, `/api/users/status`, and `/game/export/off3b001`, status 200, with no move request ID. No wildcard GET or query variant is admitted. The independent packaged-smoke verifier and downstream aggregate both call this same contract, while the independent verifier additionally validates legal UCI/board, durable SQLite rows, ACK handling and hold windows. This is evidence admission, not authorization for live external services. + +ONLINE-4A/4B source evidence now refuses a recorded legal UCI that differs from the actually submitted canonical move URL; percent-encoded alias paths are not accepted as a substitute for the canonical path. Counter matching alone does not establish move/intent identity. Real fake-server request and immutable trace evidence remain the inputs; no chess engine move-selection behavior is changed. + +Snapshot availability preflight reads the complete bounded signed-cleartext InRelease document, checks required beginning/end delimiters and suite identity, prohibits unexpected redirects, checks exact Deb822 source stanzas, handles TimeoutError and socket read failures, enforces a global budget, and writes a sanitized failure report. This preflight remains diagnostic only: cryptographic GPG verification is still performed by pinned Ubuntu APT during image building. The APT retry classifier permits retry only if **all** acquisition failures are among transient HTTP 429/502/503/504; unknown and mixed errors fail closed. + +The OCI diagnostic inspector runs before strict verification and may be uploaded on failure. It therefore now allowlists statement `_type`, `predicateType`, descriptor/config media types, accepted annotations and subject digests, and does not echo arbitrary error text or source metadata. Validation itself still requires the complete application config/layer/SPDX/SLSA subject graph; sanitized diagnostics do not establish qualification. + +Do not mark these changes proven until exact-head CI passes. Retain the frozen no-publication, no-credential, no-host-admission, no-live-transport, no-license-promotion and incomplete OS/wheel-lock requirements. Run original RELEASE-1, ONLINE-4A/4B, baseline/LOCAL-1/LC0 and policy workflows, then request final Codex reviews. + +## October 17 — raw HTTP targets, exact move evidence and OCI archive continuity + +Retained offline HTTP evidence now represents the raw request-target when explicitly approved by `tests/lichess/http_target_contract.py`, staged alongside the `FakeLichess` server and source-attested. Rejected arbitrary targets are recorded as disapproved plus a SHA-256 fingerprint and a safe path, not as unredacted query values. The pinned upstream Lichess bot sends `GET /api/users/status?ids=allfatherfixture` and `POST /api/bot/game/off3b001/move/{uci}?offeringDraw=false|true`; both documented forms are admitted, but unrelated/duplicated/query-bearing paths are not. `scripts/release_http_contract.py` uses the exact same classification for independent smoke and downstream aggregate. Live tokens remain forbidden. + +ONLINE-4A evidence matching is two-way: every actual move HTTP POST (including an accepted, failed or fault-injected request) must be matched to an exactly one-to-one move trace entry by method, path and request ID; the trace still must match a canonical UCI and authoritative board/legal intent. A legal but untraced POST is an evidence failure, not a valid extra submission. + +RELEASE-1 OCI verification now reads and hashes its archive through the same opened inode, requires stable inode/size/mtime/ctime and rejects an atomically replaced file before reporting `oci_archive_sha256`. The later aggregate independently rehashes the pathname and will reject a different archive. OCI structure inspection is diagnostic-only, tolerant of malformed JSON container types with a safe, bounded failure report, never an integrity or publisher-signature grant. + +The native GPG-signed, fixed Ubuntu snapshot remains authoritative for APT. Retry classification ignores bracketed HTTPS peer address/port metadata (`443` is not an HTTP status); retries remain bounded to explicitly recognized 429/502/503/504 acquisition failures and immediate refusal on mixed or integrity failures. + +Full exactly pinned offline OCI/SQLite/engine, complete merge/check matrix and final Codex reviews are **still required** for any new head. No release-policy permissions or dependency-lock qualification are raised. + +## October 17 — portable independent verifiers and attested source closure + +RELEASE-1 runs `scripts/qualify-release-smoke.py` directly as a host CLI. The original `scripts/release_http_contract.py` top-level package import depended on CWD/`PYTHONPATH` and failed only *after* four real packaged games succeeded. The shared canonical HTTP policy is now imported by an explicit `importlib.util.spec_from_file_location` using `Path(__file__).resolve().parents[1]`, preserving one single policy file but removing accidental Python path dependence. A regression invokes both stand-alone host verifiers with normal `python` from an unrelated directory and `PYTHONPATH` unset. The real CI runner uses user-installed Python dependencies, so isolated `-I` mode is intentionally not imposed. Fast contracts must execute that check **before** expensive engine compilation. + +ONLINE-4A/4B source policies both include the new `tests/lichess/http_target_contract.py` in their exhaustive attestation path sets. The fake HTTP server captures the origin-form request target from the raw request line *before* CPython converts leading double or triple slashes to a single slash. Approved canonical upstream requests may retain only the audited allowlisted `ids=allfatherfixture` and `offeringDraw=true|false` parameters; rejected raw queries are not copied into downloadable reports. + +ONLINE-4A now replays all HTTP move attempts and authoritative commits in transport sequence. An attempted move must match game, receipt ply, canonical UCI and side/position legality. Coherent unsafe attempts remain observable `UNSAFE_WITNESS` evidence and cannot masquerade as normal safe continuation. A stale replay after prior committed legal move has its own observed disposition only when the correlated original lost/unknown acknowledgment is proven. ONLINE-4B additionally compares per-request IDs on full HTTP move audit versus trace. + +OCI attestation statement validation and sanitized diagnostics accept two forms only: valid singleton in-toto statements with `statements` *absent*, and nonempty correctly typed `statements` arrays. Explicit null/object/string/empty wrappers fail closed even when rehashed consistently. The RFC3986 URI input grammar rejects raw Unicode and forbidden delimiter characters, without rejecting valid percent-encoded URIs or legitimate pinned BuildKit/Syft data. No permission, licensing or public release gate is promoted. + +The pinned Syft scanner-only integration shares the same in-toto envelope contract as the full OCI verifier and safe diagnostic tool. Valid singleton statements have no `statements` key; present null/nonarray/empty wrappers are rejected. This preflight is a compatibility test only and cannot qualify or distribute any public artifact. + +### Source-locked observed unsafe recovery versus evidence corruption + +A genuine dropped-before-commit R01 fault can yield a second chess-legal HTTP submission on the unchanged position that the fake server actually commits. ONLINE-4A's independent verifier now preserves this as a *valid authoritative board commit and an unsafe repeat attempt*; it no longer rejects the entire harness merely because the committed request ID belongs to the second attempt. Other fault cases similarly retain observed stale/forbidden retry evidence under bounded, sanitized `UNSAFE_WITNESS` dispositions. A normal R00 cannot be declared `SAFE_CONTINUATION` if an attempted move was illegal, repeated, or had an unapproved raw target. + +ONLINE-4B verifies full original GET/POST request targets against the source-attested HTTP policy, requiring raw `request_target` to match the normalized route and admission bit before matching request-ID/move/SQLite evidence. The pinned bridge's genuine `ids=allfatherfixture` query remains permitted, but forged double-slash and secret/extra queries never qualify. A duplicate or mismatched raw target is rejected rather than reinterpreted through parsed pathname alone. + +RELEASE-1 URI admission now constrains path/query/fragment components individually under RFC3986 and validates optional recognized SLSA v1/v0.2 object/map/list fields, while preserving real BuildKit valid optional nulls and percent-encoded resource identities. Every new source change requires exact-head scanner, packaged-game, OCI/SLSA and aggregate qualification. No live transport or distribution claims arise. + +## Consolidated assurance-proof obligation boundary + +The new assurance contract is `qualification/assurance-contract-v1.json` with executable source dependency closure and Git committed-byte identity through `scripts/verify_source_closure.py`. Offline HTTP status meaning is defined separately from canonical request-target syntax. A successful harness is distinct from observed unsafe behavior and does not grant safe continuation; a green OCI image does not grant public distribution or even `offline_candidate_qualified` while other gates remain false. Versioned local validation profiles define the accepted subset of SLSA ResourceDescriptor and BuildMetadata semantics, with intentionally independent OCI/scanner negative tests. + +All retained independent reports pass through the bounded safe-output serializer. Tests must inject a secret-like canary into nested producer fields and verify that the final serialized artifact excludes it. Source imports and dynamic execution must be attested exhaustively before interpreting producer or verifier claims. + +### 2026-10-17 nested assurance follow-up + +Review the current source closure, HTTP, OCI/SLSA and sanitized-report contract jointly, never by a green one-field predicate. BuildKit material identities may use RFC3986 **opaque Package URLs** such as `pkg:docker/ubuntu@...` in SLSA provenance; these are immutable resource identifiers, not network destinations. The independent OCI verifier must accept correctly formed versions without broadening live network authority. + +An injected HTTP fault must be reflected in the *targeted request's observed HTTP status*, not just in an independent trace flag. The aggregate must independently link the OCI archive digest/layers and full SPDX/SLSA/subject graph to its positive `image_integrity_qualified` claim. Scanner preflight cannot select one valid SPDX sibling while silently dropping other malformed attestation statements. + +The October 18 SPDX review exposed a version-confusion risk: SPDX 2.2 package +copyright/license fields remain mandatory while SPDX 2.3 permits omission; +the file SHA-1 checksum rule applies to both versions. Neither an optional +verification code nor a missing one may bypass validation of supplied license +information. This is now enforced by version-specific negative controls. + +The bounded APT acquisition retry remains restricted to individually recognized transient statuses. A valid `apt-get install` unable-to-fetch summary may accompany those statuses, but unknown errors never become retryable by including that summary. All green checks must originate at one exact Git SHA; smoke/OCI data from another head cannot grant release promotion. + +## October 18 — verified interpreter inputs before qualification + +The RELEASE-1 workflow independently compares its scanner/OCI and aggregate +verifier files against Git's exact committed bytes **before execution**. +During aggregation, `scripts/qualify-release-aggregate.py` additionally +requires the stage's `source_attestation` hashes to match the actual +working-tree bytes and `git show SOURCE_SHA:path`. It then runs the +independent smoke, shared HTTP and claims verifiers from a private snapshot +containing only those verified bytes, protecting against later mutation of +the checkout. The aggregate cannot trust a changed smoke verifier merely +because `git rev-parse HEAD` is unchanged. + +These checks bind a qualification to its source bytes; they do **not** confer +operator authority, license clearance, authenticated publisher signatures, +network recovery claims or rights to distribute a binary image. + +## October 18 — scanner source-build and approval procedure + +The upstream v1.11.0 scanner uses Syft file hashing configured for SHA256 by +default; that conflicted with SPDX 2.x's required SHA1 on file records. The +source-attested `target.go.patch` adds `crypto.SHA1` and `crypto.SHA256` +through the existing Syft `WithFilesConfig(WithHashers(...))` API. A pinned +Go 1.25.8 compiler action builds only vendored dependencies offline. + +The CI source scanner lifecycle is: + +1. Compare all scanner scripts, patch, Dockerfile and lock bytes with the exact + Git HEAD. Fetch only upstream commit `d88056b4`, check the Go/Syft version + and apply exactly one patch to `internal/target.go`. +2. Compile twice and compare executable hashes. Build two `FROM scratch` + scanner images in the host Docker engine with a frozen source-date epoch. +3. Push **only to the current GitHub runner's** loopback-bound, digest-pinned + `registry:2.8.3` container; compare both manifest digest responses. +4. Configure a docker-container BuildKit daemon on the same Docker bridge, + allowing plain HTTP only for `scanner-registry:5000`, and address the + custom scanner by its manifest SHA256. +5. Run the real scanner against the pinned Ubuntu fixture and require genuine + SPDX/SLSA validation. Before marking scanner compatibility green, compare + the measured manifest and binary hashes with separately reviewed values in + `qualification/release-scanner-v1.json`. All results are offline only. +6. Recreate and verify the scanner independently on the full OCI integration + job. Destroy temporary registry, builder and network on exit. + +The initial scanner lock deliberately left binary and image identities +`null`, so the real scanner could be tested without self-approving its +manifest. Source-locked CI run 39130571069 built two matching Go binaries, +produced identical OCI manifests and validated a real 93-package/2265-file +SPDX document alongside the required SLSA v1 builder. Its measured binary +SHA256 and manifest digest are now frozen in +`qualification/release-scanner-v1.json`. Subsequent CI jobs must reproduce +both exact identities, independently prove a known regular-file hash, and +still satisfy all packaged OCI, safety and governance constraints. +No registry image or scanner binary is published, and no external credentials +are required. + +File records with SHA1 `000...000` (used by upstream Syft for entries without +regular-file digest metadata) may be structurally allowed by SPDX, but are +**not** proof of actual file contents. An independent known-regular-file hash +test is required before claiming scanner content-hash qualification. + +### Independent real-file content-hash witness + +The real scanner probe also extracts `/usr/bin/dpkg` from the exact digest- +pinned Ubuntu 24.04 base image without running the image. The independent host +verifier compares SHA1 and SHA256 over those actual bytes with the corresponding +SPDX `fileName` entry in the scanner's OCI-attested document. A hexadecimal +SHA1 string, an absent file or the nonregular-file zero placeholder does not +qualify. The reference executable is kept only in a temporary runner directory +and is never uploaded. The published witness contains safe Boolean verdicts, +not the raw file or SBOM. + +## October 18 — final independent-review repairs + +The manual final boarding review exposed a difference between the scanner-only +SLSA identity check and the **real application OCI** gate. Full image provenance +is now bound directly to `qualification/release-image-v1.json`: +exactly one SLSA v1 predicate, pinned workflow builder ID and exact +application-manifest subject, even when a caller supplies a correctly +rehashed but different SLSA archive. A scanner-only success cannot waive it. + +Build and qualification now have separate execution source domains. +The offline producer assembles chess evidence and an OCI archive in its +working copy. The independent RELEASE-1 verifiers run *after those steps* +from a new detached worktree at the exact head SHA; they do not import +the potentially modified producer checkout. ONLINE-4A and ONLINE-4B +independent jobs likewise create isolated worktrees and run +`verify_source_closure.py` against committed bytes *before* loading local +recovery code. The v2 verifier fetches its independently pinned bridge +checkout under its own verified root. This prevents a stale green HEAD from +authorizing a mutated executable module. + +ONLINE-4B accepts only distinct, phase-indexed `bridge-phase-N.log` evidence +inside its scenario directory. ONLINE-4A refuses symlinked or +path-escaping replay manifests and `route.json` before hashing. +These checks are evidence admission rules, not declarations of successful +live network recovery or host/public-play approval. diff --git a/qualification/assurance-contract-v1.json b/qualification/assurance-contract-v1.json new file mode 100644 index 00000000..9e6342ee --- /dev/null +++ b/qualification/assurance-contract-v1.json @@ -0,0 +1,125 @@ +{ + "schema_version": 1, + "scope": "immutable-offline-evidence-only", + "public_bot_release": false, + "authority": { + "public_distribution": false, + "live_transport": false, + "host_qualified": false, + "legal_approval": false, + "operator_approval": false, + "playing_strength": false + }, + "claims": { + "harness_qualified": { + "requires": [ + "exact_executed_source_closure", + "producer_qualifier_independent", + "complete_http_record_and_trace", + "authoritative_chess_replay", + "fault_witness", + "safe_retained_report" + ], + "does_not_imply": [ + "safe_continuation", + "offline_recovery_qualified", + "network_recovery_qualified", + "public_bot_release" + ] + }, + "containment_qualified_offline": { + "requires": [ + "harness_qualified", + "all_requests_admitted_and_classified", + "scenario_specific_http_status_witness", + "durable_intent_and_epoch_consistency", + "no_unjustified_unsafe_witness" + ], + "does_not_imply": [ + "network_recovery_qualified", + "public_bot_release" + ] + }, + "recovery_qualified_offline": { + "requires": [ + "containment_qualified_offline", + "frozen_fault_scenarios", + "new_epoch_confirmed_commit", + "exact_journal_reconciliation" + ], + "does_not_imply": [ + "network_recovery_qualified", + "public_bot_release" + ] + }, + "offline_smoke_qualified": { + "requires": [ + "exact_executed_source_closure", + "actual_bot_actor_on_bot_turn", + "complete_http_status_request_identity", + "legal_game_and_pgn", + "read_only_durable_sqlite", + "scenario_fault_evidence", + "safe_retained_report" + ], + "does_not_imply": [ + "offline_candidate_qualified", + "public_bot_release" + ] + }, + "oci_sbom_verified": { + "requires": [ + "same_archive_inode_integrity", + "complete_in_toto_statement_set", + "spdx_full_inventory_and_uri_schema", + "image_subject_binding" + ], + "does_not_imply": [ + "publisher_signature_verified", + "public_bot_release" + ] + }, + "oci_provenance_verified": { + "requires": [ + "same_archive_inode_integrity", + "complete_in_toto_statement_set", + "slsa_versioned_field_shapes", + "slsa_resource_identity", + "slsa_timestamp_semantics", + "image_subject_binding" + ], + "does_not_imply": [ + "publisher_signature_verified", + "public_bot_release" + ] + }, + "image_integrity_qualified": { + "requires": [ + "offline_smoke_qualified", + "oci_sbom_verified", + "oci_provenance_verified", + "exact_host_image_identity", + "complete_oci_layer_graph", + "safe_retained_report" + ], + "does_not_imply": [ + "offline_candidate_qualified", + "public_bot_release" + ] + }, + "offline_candidate_qualified": { + "requires": [ + "image_integrity_qualified", + "os_and_wheel_lock_complete", + "controller_license_approved", + "source_availability_review", + "destination_host_qualified", + "live_transport_qualified", + "operator_live_approval" + ], + "does_not_imply": [ + "public_bot_release" + ] + } + } +} diff --git a/qualification/http-evidence-contract-v1.json b/qualification/http-evidence-contract-v1.json new file mode 100644 index 00000000..18014ca4 --- /dev/null +++ b/qualification/http-evidence-contract-v1.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "source": "frozen-offline-fake-lichess", + "non_move_status": 200, + "ordinary_move_status": 200, + "fault_specific_move_status": { + "drop_before_commit": 0, + "rate_limit_429": 429, + "commit_then_504": 504 + }, + "fault_requires_exact_trace_request_id": true, + "non_grants": { + "live_transport": false, + "public_bot_release": false + } +} diff --git a/qualification/online-recovery-v1.json b/qualification/online-recovery-v1.json index 14951f1f..04f0bb91 100644 --- a/qualification/online-recovery-v1.json +++ b/qualification/online-recovery-v1.json @@ -49,15 +49,28 @@ "deploy/lichess/Dockerfile.offline", "deploy/lichess/bridge.lock.json", "deploy/lichess/bridge_policy.py", + "deploy/lichess/extra_game_handlers.py", + "deploy/lichess/fetch_bridge.py", "deploy/lichess/log_capture.py", "deploy/lichess/offline_session.py", "deploy/lichess/run_recovery.py", "docs/ONLINE_4_RECOVERY.md", + "qualification/assurance-contract-v1.json", + "qualification/http-evidence-contract-v1.json", "qualification/online-recovery-v1.json", + "qualification/schemas/retained-qualification-report-v1.json", + "qualification/schemas/slsa-build-metadata-v1.contract.json", + "qualification/schemas/slsa-resource-descriptor-v1.contract.json", + "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", "tests/lichess/fault_plan.py", + "tests/lichess/http_target_contract.py", + "tests/lichess/scripted_opponent.py", "tests/lichess/stream_sessions.py", "tests/lichess/test_fault_plan.py", "tests/lichess/test_recovery_evidence.py", diff --git a/qualification/online-recovery-v2.json b/qualification/online-recovery-v2.json index 1fa21f51..f6dcc395 100644 --- a/qualification/online-recovery-v2.json +++ b/qualification/online-recovery-v2.json @@ -60,23 +60,45 @@ "playing_strength_qualified": false, "attested_paths": [ ".github/workflows/online-recovery-v2.yml", + "deploy/lichess/Dockerfile.offline", "deploy/lichess/bridge.lock.json", + "deploy/lichess/bridge_policy.py", + "deploy/lichess/extra_game_handlers.py", + "deploy/lichess/fetch_bridge.py", "deploy/lichess/guarded_transport.py", + "deploy/lichess/log_capture.py", "deploy/lichess/move_journal.py", "deploy/lichess/offline_session.py", "deploy/lichess/reconcile.py", "deploy/lichess/recovery_overlay.py", + "deploy/lichess/run_recovery.py", "deploy/lichess/run_recovery_v2.py", "deploy/lichess/session_owner.py", "docs/ONLINE_4B_RECOVERY.md", + "qualification/assurance-contract-v1.json", + "qualification/http-evidence-contract-v1.json", + "qualification/online-recovery-v1.json", "qualification/online-recovery-v2.json", + "qualification/schemas/retained-qualification-report-v1.json", + "qualification/schemas/slsa-build-metadata-v1.contract.json", + "qualification/schemas/slsa-resource-descriptor-v1.contract.json", + "scripts/http_evidence_verdict.py", "scripts/qualify-online-recovery-v2.py", + "scripts/qualify-online-recovery.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "scripts/verify_source_closure.py", "tests/fixtures/lichess/recovery_scenarios.json", "tests/lichess/fake_lichess_api.py", + "tests/lichess/fault_plan.py", + "tests/lichess/http_target_contract.py", + "tests/lichess/scripted_opponent.py", + "tests/lichess/stream_sessions.py", "tests/lichess/test_guarded_transport.py", "tests/lichess/test_recovery_journal.py", "tests/lichess/test_recovery_overlay.py", - "tests/lichess/test_recovery_v2_evidence.py" + "tests/lichess/test_recovery_v2_evidence.py", + "tests/lichess/transport_trace.py" ], "require_terminal_state_confirmation": true, "require_new_epoch_confirmed_move": true, diff --git a/qualification/release-image-v1.json b/qualification/release-image-v1.json index 1bb9beee..9514c1ae 100644 --- a/qualification/release-image-v1.json +++ b/qualification/release-image-v1.json @@ -60,5 +60,10 @@ "ca_bootstrap_signed_http_only": true, "ca_bootstrap_snapshot_locked": false, "ca_bootstrap_sources": "deploy/release/bootstrap.sources", - "ca_bootstrap_source_reason": "Pinned minimal base has no usable CA trust before first HTTPS APT access; only signed native Ubuntu APT metadata may install ca-certificates. Exact bootstrap package version is part of generated runtime inventory; publication blocked until reviewed." + "ca_bootstrap_source_reason": "Pinned minimal base has no usable CA trust before first HTTPS APT access; only signed native Ubuntu APT metadata may install ca-certificates. Exact bootstrap package version is part of generated runtime inventory; publication blocked until reviewed.", + "sbom_generator_image": null, + "sbom_generator_evidence": "Source-locked v1.11.0 Syft scanner with dual SHA1+SHA256; digest-addressed ephemeral registry use only after reproducible binary and reviewed manifest approval.", + "slsa_provenance_predicate_type": "https://slsa.dev/provenance/v1", + "slsa_builder_id": "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml", + "sbom_generator_policy": "qualification/release-scanner-v1.json" } diff --git a/qualification/release-scanner-v1.json b/qualification/release-scanner-v1.json new file mode 100644 index 00000000..131d2c8f --- /dev/null +++ b/qualification/release-scanner-v1.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "scope": "offline-release-scanner-only", + "upstream_repository": "https://github.com/docker/buildkit-syft-scanner", + "upstream_tag": "v1.11.0", + "upstream_commit": "d88056b4e5b61d0ca037340df91be47d343b4386", + "toolchain_version": "go1.25.8", + "vendored_syft_version": "v1.42.3", + "patch_path": "deploy/release/scanner/target.go.patch", + "patch_sha256": "87fbe492c4b504374b41a6303ebc75833559bacb83addf9e5520b9768cc37d09", + "binary_path": "bin/syft-scanner", + "required_file_hash_algorithms": [ + "SHA1", + "SHA256" + ], + "scanner_dockerfile": "deploy/release/scanner/Dockerfile", + "registry_transport_image": "docker.io/library/registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "approved_binary_sha256": "a1043ee5fa8285740548b600fd24d106b4e68211327238b4edbbe70765bd67ca", + "approved_manifest_digest": "sha256:fe9efbc9d42e6ddbbe44f0f35e2daa45299123891076c1700414a74978a51d2a", + "reproducible_binary_required": true, + "reproducible_image_required": true, + "public_bot_release": false, + "artifact_distribution_authorized": false, + "live_credentials_authorized": false, + "approval_evidence_workflow": "https://github.com/HMarcusWH/AllfatherChess/actions/runs/39130571069", + "approval_basis": "Two source-locked binaries equal; two BuildKit image manifests equal; real scanner accepted SPDX 2.3 (93 packages, 2265 files, SHA1), SLSA v1 and expected builder. Additional independent file-content and packaged-OCI gates remain mandatory." +} diff --git a/qualification/schemas/retained-qualification-report-v1.json b/qualification/schemas/retained-qualification-report-v1.json new file mode 100644 index 00000000..2644caf8 --- /dev/null +++ b/qualification/schemas/retained-qualification-report-v1.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "deny_by_default_unknown_strings": true, + "allow_numeric_and_boolean": true, + "fields": { + "path": "validated-canonical-path-no-query", + "request_target": "never-retained", + "uci": "canonical-uci", + "request_id": "synthetic-request-id", + "reason": "enumeration-only", + "error": "fixed-redaction", + "unknown_strings": "redacted" + }, + "public_authority_granted": false +} diff --git a/qualification/schemas/slsa-build-metadata-v1.contract.json b/qualification/schemas/slsa-build-metadata-v1.contract.json new file mode 100644 index 00000000..a1ad48a9 --- /dev/null +++ b/qualification/schemas/slsa-build-metadata-v1.contract.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "source_spec": "SLSA provenance v1 and v0.2 BuildMetadata", + "spec_url": "https://slsa.dev/spec/v1.0/provenance#buildmetadata", + "timestamps": [ + "startedOn", + "finishedOn", + "buildStartedOn", + "buildFinishedOn" + ], + "format": "RFC3339 timezone-aware", + "relative_order": "finish >= start", + "absent_null_empty": "permitted optional", + "public_authority_granted": false +} diff --git a/qualification/schemas/slsa-resource-descriptor-v1.contract.json b/qualification/schemas/slsa-resource-descriptor-v1.contract.json new file mode 100644 index 00000000..706c37cc --- /dev/null +++ b/qualification/schemas/slsa-resource-descriptor-v1.contract.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "source_spec": "in-toto/attestation v1 ResourceDescriptor", + "spec_url": "https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md", + "identity_any_of": [ + "uri", + "digest", + "content" + ], + "resource_uri": "absolute RFC3986 URI", + "digest": "nonempty validated algorithm/hex map", + "content": "strict Base64 byte encoding", + "public_authority_granted": false +} diff --git a/qualification/spdx-license-identifiers-v3.29.0.json b/qualification/spdx-license-identifiers-v3.29.0.json new file mode 100644 index 00000000..bdb913bc --- /dev/null +++ b/qualification/spdx-license-identifiers-v3.29.0.json @@ -0,0 +1,837 @@ +{ + "schema_version": 1, + "spdx_license_list_version": "3.29.0", + "source_repository": "spdx/license-list-data", + "source_tag": "v3.29.0", + "release_date": "2026-09-16T00:00:00Z", + "license_ids": [ + "0BSD", + "3D-Slicer-1.0", + "AAL", + "ADSL", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "AGPL-1.0", + "AGPL-1.0-only", + "AGPL-1.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "ALGLIB-Documentation", + "AMD-newlib", + "AMDPLPA", + "AML", + "AML-glslang", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "APAFML", + "APL-1.0", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "ASWF-Digital-Assets-1.0", + "ASWF-Digital-Assets-1.1", + "Abstyles", + "AdaCore-doc", + "Adobe-2006", + "Adobe-Display-PostScript", + "Adobe-Glyph", + "Adobe-Utopia", + "Advanced-Cryptics-Dictionary", + "Afmparse", + "Aladdin", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "App-s2p", + "Arphic-1999", + "Artistic-1.0", + "Artistic-1.0-Perl", + "Artistic-1.0-cl8", + "Artistic-2.0", + "Artistic-dist", + "Aspell-RU", + "BOLA-1.1", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-Darwin", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-2-Clause-Patent", + "BSD-2-Clause-Views", + "BSD-2-Clause-first-lines", + "BSD-2-Clause-pkgconf-disclaimer", + "BSD-2-Clause-pos-unchanged", + "BSD-3-Clause", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-HP", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-No-Military-License", + "BSD-3-Clause-No-Nuclear-License", + "BSD-3-Clause-No-Nuclear-License-2014", + "BSD-3-Clause-No-Nuclear-Warranty", + "BSD-3-Clause-Open-MPI", + "BSD-3-Clause-OpenWebUI", + "BSD-3-Clause-Sun", + "BSD-3-Clause-Tso", + "BSD-3-Clause-acpica", + "BSD-3-Clause-flex", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-4.3RENO", + "BSD-4.3TAHOE", + "BSD-Advertising-Acknowledgement", + "BSD-Attribution-HPND-disclaimer", + "BSD-Inferno-Nettverk", + "BSD-Mark-Modifications", + "BSD-Protection", + "BSD-Source-Code", + "BSD-Source-Code-no-disclaimer", + "BSD-Source-alt-GPL", + "BSD-Source-beginning-file", + "BSD-Systemics", + "BSD-Systemics-W3Works", + "BSD-ask-to-endorse", + "BSL-1.0", + "BUSL-1.1", + "Baekmuk", + "Bahyph", + "Barr", + "Beerware", + "BitTorrent-1.0", + "BitTorrent-1.1", + "Bitstream-Charter", + "Bitstream-Vera", + "BlueOak-1.0.0", + "Boehm-GC", + "Boehm-GC-without-fee", + "Borceux", + "Brian-Gladman-2-Clause", + "Brian-Gladman-3-Clause", + "Brian-Gladman-3-Clause-no-conversion", + "Buddy", + "Bugroff", + "C-UDA-1.0", + "CAL-1.0", + "CAL-1.0-Combined-Work-Exception", + "CAPEC-tou", + "CATOSL-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-2.5-AU", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-AU", + "CC-BY-3.0-DE", + "CC-BY-3.0-IGO", + "CC-BY-3.0-NL", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-3.0-DE", + "CC-BY-NC-3.0-IGO", + "CC-BY-NC-4.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-ND-3.0-DE", + "CC-BY-NC-ND-3.0-IGO", + "CC-BY-NC-ND-4.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.0-DE", + "CC-BY-NC-SA-2.0-FR", + "CC-BY-NC-SA-2.0-UK", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-NC-SA-3.0-DE", + "CC-BY-NC-SA-3.0-IGO", + "CC-BY-NC-SA-4.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-ND-3.0-DE", + "CC-BY-ND-4.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.0-UK", + "CC-BY-SA-2.1-JP", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC-BY-SA-3.0-AT", + "CC-BY-SA-3.0-DE", + "CC-BY-SA-3.0-IGO", + "CC-BY-SA-4.0", + "CC-PDDC", + "CC-PDM-1.0", + "CC-SA-1.0", + "CC0-1.0", + "CDDL-1.0", + "CDDL-1.1", + "CDL-1.0", + "CDLA-Permissive-1.0", + "CDLA-Permissive-2.0", + "CDLA-Sharing-1.0", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-2.1", + "CECILL-B", + "CECILL-C", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CERN-OHL-S-2.0", + "CERN-OHL-W-2.0", + "CFITSIO", + "CMU-Mach", + "CMU-Mach-nodoc", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "COIL-1.0", + "CPAL-1.0", + "CPL-1.0", + "CPOL-1.02", + "CUA-OPL-1.0", + "Caldera", + "Caldera-no-preamble", + "Catharon", + "ClArtistic", + "Clips", + "Community-Spec-1.0", + "Condor-1.1", + "Cornell-Lossless-JPEG", + "Cronyx", + "Crossword", + "CryptoSwift", + "CrystalStacker", + "Cube", + "D-FSL-1.0", + "DEC-3-Clause", + "DL-DE-BY-2.0", + "DL-DE-ZERO-2.0", + "DOC", + "DRL-1.0", + "DRL-1.1", + "DSDP", + "DocBook-DTD", + "DocBook-Schema", + "DocBook-Stylesheet", + "DocBook-XML", + "Dotseqn", + "ECL-1.0", + "ECL-2.0", + "EFL-1.0", + "EFL-2.0", + "EPICS", + "EPL-1.0", + "EPL-2.0", + "ESA-PL-permissive-2.4", + "ESA-PL-strong-copyleft-2.4", + "ESA-PL-weak-copyleft-2.4", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "EUPL-1.2", + "Elastic-2.0", + "Entessa", + "ErlPL-1.1", + "Eurosym", + "FBM", + "FDK-AAC", + "FDK-MPEG-H", + "FSFAP", + "FSFAP-no-warranty-disclaimer", + "FSFUL", + "FSFULLR", + "FSFULLRSD", + "FSFULLRWD", + "FSL-1.1-ALv2", + "FSL-1.1-MIT", + "FTL", + "Fair", + "Ferguson-Twofish", + "Frameworx-1.0", + "FreeBSD-DOC", + "FreeImage", + "Furuseth", + "GCR-docs", + "GD", + "GFDL-1.1", + "GFDL-1.1-invariants-only", + "GFDL-1.1-invariants-or-later", + "GFDL-1.1-no-invariants-only", + "GFDL-1.1-no-invariants-or-later", + "GFDL-1.1-only", + "GFDL-1.1-or-later", + "GFDL-1.2", + "GFDL-1.2-invariants-only", + "GFDL-1.2-invariants-or-later", + "GFDL-1.2-no-invariants-only", + "GFDL-1.2-no-invariants-or-later", + "GFDL-1.2-only", + "GFDL-1.2-or-later", + "GFDL-1.3", + "GFDL-1.3-invariants-only", + "GFDL-1.3-invariants-or-later", + "GFDL-1.3-no-invariants-only", + "GFDL-1.3-no-invariants-or-later", + "GFDL-1.3-only", + "GFDL-1.3-or-later", + "GL2PS", + "GLWTPL", + "GPL-1.0", + "GPL-1.0+", + "GPL-1.0-only", + "GPL-1.0-or-later", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-2.0-with-GCC-exception", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GPL-3.0-with-GCC-exception", + "GPL-3.0-with-autoconf-exception", + "Game-Programming-Gems", + "Giftware", + "Glide", + "Glulxe", + "Graphics-Gems", + "Gutmann", + "HDF5", + "HIDAPI", + "HP-1986", + "HP-1989", + "HPND", + "HPND-DEC", + "HPND-Fenneberg-Livingston", + "HPND-INRIA-IMAG", + "HPND-Intel", + "HPND-Kevlin-Henney", + "HPND-MIT-disclaimer", + "HPND-Markus-Kuhn", + "HPND-Netrek", + "HPND-Pbmplus", + "HPND-SMC", + "HPND-UC", + "HPND-UC-export-US", + "HPND-doc", + "HPND-doc-sell", + "HPND-export-US", + "HPND-export-US-acknowledgement", + "HPND-export-US-modify", + "HPND-export2-US", + "HPND-merchantability-variant", + "HPND-sell-MIT-disclaimer-xserver", + "HPND-sell-regexpr", + "HPND-sell-variant", + "HPND-sell-variant-MIT-disclaimer", + "HPND-sell-variant-MIT-disclaimer-rev", + "HPND-sell-variant-critical-systems", + "HTMLTIDY", + "HaskellReport", + "Hippocratic-2.1", + "Hippocratic-3.0-core", + "IBM-pibs", + "ICU", + "IEC-Code-Components-EULA", + "IJG", + "IJG-short", + "IPA", + "IPL-1.0", + "ISC", + "ISC-Veillard", + "ISO-permission", + "ImageMagick", + "Imlib2", + "Info-ZIP", + "Informatica", + "Inner-Net-2.0", + "InnoSetup", + "Intel", + "Intel-ACPI", + "Interbase-1.0", + "JPL-image", + "JPNIC", + "JSON", + "Jam", + "JasPer-2.0", + "Kastrup", + "Kazlib", + "Knuth-CTAN", + "LAL-1.2", + "LAL-1.3", + "LGPL-2.0", + "LGPL-2.0+", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0", + "LGPL-3.0+", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "LGPLLR", + "LOOP", + "LPD-document", + "LPL-1.0", + "LPL-1.02", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3a", + "LPPL-1.3c", + "LZMA-SDK-9.11-to-9.20", + "LZMA-SDK-9.22", + "Latex2e", + "Latex2e-translated-notice", + "Leptonica", + "LiLiQ-P-1.1", + "LiLiQ-R-1.1", + "LiLiQ-Rplus-1.1", + "Libpng", + "Linux-OpenIB", + "Linux-man-pages-1-para", + "Linux-man-pages-copyleft", + "Linux-man-pages-copyleft-2-para", + "Linux-man-pages-copyleft-var", + "Lucida-Bitmap-Fonts", + "MIPS", + "MIT", + "MIT-0", + "MIT-CMU", + "MIT-Click", + "MIT-Festival", + "MIT-Khronos-old", + "MIT-Modern-Variant", + "MIT-STK", + "MIT-Wu", + "MIT-advertising", + "MIT-enna", + "MIT-feh", + "MIT-open-group", + "MIT-testregex", + "MITNFA", + "MMIXware", + "MMPL-1.0.1", + "MPEG-SSG", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "MS-LPL", + "MS-PL", + "MS-RL", + "MTLL", + "MVT-1.1", + "Mackerras-3-Clause", + "Mackerras-3-Clause-acknowledgment", + "MakeIndex", + "Martin-Birgmeier", + "McPhee-slideshow", + "Minpack", + "MirOS", + "Motosoto", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "NAIST-2003", + "NASA-1.3", + "NBPL-1.0", + "NCBI-PD", + "NCGL-UK-2.0", + "NCL", + "NCSA", + "NGPL", + "NICTA-1.0", + "NIST-PD", + "NIST-PD-TNT", + "NIST-PD-fallback", + "NIST-Software", + "NLOD-1.0", + "NLOD-2.0", + "NLPL", + "NOSL", + "NPL-1.0", + "NPL-1.1", + "NPOSL-3.0", + "NRL", + "NTIA-PD", + "NTP", + "NTP-0", + "Naumen", + "Net-SNMP", + "NetCDF", + "Newsletr", + "Nokia", + "Noweb", + "Nunit", + "O-UDA-1.0", + "OAR", + "OCCT-PL", + "OCLC-2.0", + "ODC-By-1.0", + "ODbL-1.0", + "OFFIS", + "OFL-1.0", + "OFL-1.0-RFN", + "OFL-1.0-no-RFN", + "OFL-1.1", + "OFL-1.1-RFN", + "OFL-1.1-no-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OGTSL", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OLFL-1.3", + "OML", + "OPL-1.0", + "OPL-UK-3.0", + "OPUBL-1.0", + "OSC-1.0", + "OSET-PL-2.1", + "OSL-1.0", + "OSL-1.1", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "OSSP", + "OpenMDW-1.0", + "OpenPBS-2.3", + "OpenSSL", + "OpenSSL-standalone", + "OpenVision", + "PADL", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "PPL", + "PSF-2.0", + "ParaType-Free-Font-1.3", + "Parity-6.0.0", + "Parity-7.0.0", + "Pixar", + "Plexus", + "PolyForm-Noncommercial-1.0.0", + "PolyForm-Small-Business-1.0.0", + "PostgreSQL", + "Python-2.0", + "Python-2.0.1", + "QPL-1.0", + "QPL-1.0-INRIA-2004", + "Qhull", + "RHeCos-1.1", + "RPL-1.1", + "RPL-1.5", + "RPSL-1.0", + "RSA-MD", + "RSCPL", + "Rdisc", + "Ruby", + "Ruby-pty", + "SAX-PD", + "SAX-PD-2.0", + "SCEA", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "SGI-OpenGL", + "SGMLUG-PM", + "SGP4", + "SHL-0.5", + "SHL-0.51", + "SISSL", + "SISSL-1.2", + "SL", + "SMAIL-GPL", + "SMLNJ", + "SMPPL", + "SNIA", + "SOFA", + "SPL-1.0", + "SSH-OpenSSH", + "SSH-short", + "SSLeay-standalone", + "SSPL-1.0", + "SUL-1.0", + "SWL", + "Saxpath", + "SchemeReport", + "Sendmail", + "Sendmail-8.23", + "Sendmail-Open-Source-1.1", + "SimPL-2.0", + "Sleepycat", + "Soundex", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "StandardML-NJ", + "SugarCRM-1.1.3", + "Sun-PPP", + "Sun-PPP-2000", + "SunPro", + "Symlinks", + "TAPR-OHL-1.0", + "TCL", + "TCP-wrappers", + "TGPPL-1.0", + "TMate", + "TORQUE-1.1", + "TOSL", + "TPDL", + "TPL-1.0", + "TTWL", + "TTYP0", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "TekHVC", + "TermReadKey", + "ThirdEye", + "TrustedQSL", + "UCAR", + "UCL-1.0", + "UMich-Merit", + "UPL-1.0", + "URT-RLE", + "Ubuntu-font-1.0", + "UnRAR", + "Unicode-3.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "Unicode-TOU", + "UnixCrypt", + "Unlicense", + "Unlicense-libtelnet", + "Unlicense-libwhirlpool", + "VOSTROM", + "VSL-1.0", + "Vim", + "Vixie-Cron", + "W3C", + "W3C-19980720", + "W3C-20150513", + "WTFNMFPL", + "WTFPL", + "Watcom-1.0", + "Widget-Workshop", + "WordNet", + "Wsuipa", + "X11", + "X11-distribute-modifications-variant", + "X11-no-permit-persons", + "X11-swapped", + "XFree86-1.1", + "XSkat", + "Xdebug-1.03", + "Xerox", + "Xfig", + "Xnet", + "YPL-1.0", + "YPL-1.1", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + "Zed", + "Zeeff", + "Zend-2.0", + "Zimbra-1.3", + "Zimbra-1.4", + "Zlib", + "any-OSI", + "any-OSI-perl-modules", + "atc-game", + "bcrypt-Solar-Designer", + "blessing", + "bzip2-1.0.5", + "bzip2-1.0.6", + "check-cvs", + "checkmk", + "copyleft-next-0.3.0", + "copyleft-next-0.3.1", + "curl", + "cve-tou", + "diffmark", + "dtoa", + "dvipdfm", + "eCos-2.0", + "eGenix", + "etalab-2.0", + "fwlw", + "gSOAP-1.3b", + "generic-xts", + "gnuplot", + "gtkbook", + "hdparm", + "hyphen-bulgarian", + "iMatix", + "jove", + "libpng-1.6.35", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "libutil-David-Nugent", + "lsof", + "magaz", + "mailprio", + "man2html", + "metamail", + "mpi-permissive", + "mpich2", + "mplus", + "ngrep", + "pkgconf", + "pnmstitch", + "psfrag", + "psutils", + "python-ldap", + "radvd", + "snprintf", + "softSurfer", + "ssh-keyscan", + "swrule", + "threeparttable", + "ulem", + "w3m", + "wwl", + "wxWindows", + "xinetd", + "xkeyboard-config-Zinoviev", + "xlock", + "xpp", + "xzoom", + "zlib-acknowledgement" + ], + "exception_ids": [ + "389-exception", + "Asterisk-exception", + "Asterisk-linking-protocols-exception", + "Autoconf-exception-2.0", + "Autoconf-exception-3.0", + "Autoconf-exception-generic", + "Autoconf-exception-generic-3.0", + "Autoconf-exception-macro", + "Bison-exception-1.24", + "Bison-exception-2.2", + "Bootloader-exception", + "CGAL-linking-exception", + "CLISP-exception-2.0", + "Classpath-exception-2.0", + "Classpath-exception-2.0-short", + "DigiRule-FOSS-exception", + "Digia-Qt-LGPL-exception-1.1", + "FLTK-exception", + "Fawkes-Runtime-exception", + "Font-exception-2.0", + "GCC-exception-2.0", + "GCC-exception-2.0-note", + "GCC-exception-3.1", + "GNAT-exception", + "GNOME-examples-exception", + "GNU-compiler-exception", + "GPL-3.0-389-ds-base-exception", + "GPL-3.0-interface-exception", + "GPL-3.0-linking-exception", + "GPL-3.0-linking-source-exception", + "GPL-CC-1.0", + "GStreamer-exception-2005", + "GStreamer-exception-2008", + "Gmsh-exception", + "Google-Patent-WebM", + "Independent-modules-exception", + "KiCad-libraries-exception", + "LGPL-3.0-linking-exception", + "LLGPL", + "LLVM-exception", + "LZMA-exception", + "Libtool-exception", + "Linux-syscall-note", + "Nokia-Qt-exception-1.1", + "OCCT-exception-1.0", + "OCaml-LGPL-linking-exception", + "OpenJDK-assembly-exception-1.0", + "PCRE2-exception", + "PS-or-PDF-font-exception-20170817", + "QPL-1.0-INRIA-2004-exception", + "Qt-GPL-exception-1.0", + "Qt-LGPL-exception-1.1", + "Qwt-exception-1.0", + "RRDtool-FLOSS-exception-2.0", + "SANE-exception", + "SHL-2.0", + "SHL-2.1", + "SWI-exception", + "Simple-Library-Usage-exception", + "Spelling-Provider-LGPL-exception", + "Swift-exception", + "Texinfo-exception", + "UBDL-exception", + "Universal-FOSS-exception-1.0", + "WxWindows-exception-3.1", + "cryptsetup-OpenSSL-exception", + "eCos-exception-2.0", + "erlang-otp-linking-exception", + "fmt-exception", + "freertos-exception-2.0", + "gnu-javamail-exception", + "harbour-exception", + "i2p-gpl-java-exception", + "kvirc-openssl-exception", + "libpri-OpenH323-exception", + "mif-exception", + "mxml-exception", + "openvpn-openssl-exception", + "polyparse-exception", + "romic-exception", + "rsync-linking-exception", + "sqlitestudio-OpenSSL-exception", + "stunnel-exception", + "u-boot-exception-2.0", + "vsftpd-openssl-exception", + "x11vnc-openssl-exception" + ] +} diff --git a/scripts/build-release-candidate.py b/scripts/build-release-candidate.py index f3150793..8610f2c6 100644 --- a/scripts/build-release-candidate.py +++ b/scripts/build-release-candidate.py @@ -24,6 +24,7 @@ ENGINE_MANIFEST = "build/online-release/release-manifest.json" TOOL_FILES = ( + "deploy/lichess/bridge.lock.json", "deploy/release/entrypoint.py", "deploy/release/offline_smoke.py", "deploy/release/runtime-contract.json", @@ -33,6 +34,7 @@ "deploy/lichess/bridge_policy.py", "deploy/lichess/log_capture.py", "tests/lichess/fake_lichess_api.py", + "tests/lichess/http_target_contract.py", "tests/lichess/scripted_opponent.py", "tests/lichess/fault_plan.py", "tests/lichess/stream_sessions.py", @@ -43,9 +45,31 @@ SOURCE_FILES = (POLICY, BRIDGE_LOCK, "qualification/release-qualification-v1.json", "qualification/license-compliance-v1.json", "deploy/release/ubuntu.sources", "deploy/release/bootstrap.sources", + "deploy/release/install-pinned-apt.sh", "deploy/release/Dockerfile", "deploy/release/Dockerfile.dockerignore", - "scripts/build-release-candidate.py", *TOOL_FILES) + "scripts/build-release-candidate.py", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", + "scripts/qualify-release-aggregate.py", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "qualification/schemas/retained-qualification-report-v1.json", + "qualification/schemas/slsa-resource-descriptor-v1.contract.json", + "qualification/schemas/slsa-build-metadata-v1.contract.json", + "qualification/assurance-contract-v1.json", + "scripts/inspect-release-oci.py", + "scripts/check-release-snapshot.py", + "scripts/check-release-scanner.py", *TOOL_FILES) def fail(condition: bool, reason: str): @@ -80,28 +104,9 @@ def checksum_tree(root: Path) -> list[dict]: def copy_tracked_bridge(source: Path, destination: Path) -> None: - """Export only pinned Git-tracked upstream bytes, never local untracked files. - - fetch_bridge.verify rejects dirty tracked files but intentionally does - not reject untracked files. Those must never enter the container image. - """ - names = subprocess.check_output( - ["git", "-C", str(source), "ls-files", "-z", "--cached"]) - entries = [x.decode("utf-8") for x in names.split(b"\x00") if x] - fail(bool(entries), "locked bridge has no tracked files") - destination.mkdir(parents=True, exist_ok=False) - for name in entries: - p = Path(name) - fail(not p.is_absolute() and ".." not in p.parts and - "\\\\" not in name and p.as_posix() == name, - "noncanonical tracked bridge path: " + name) - source_file = source / name - fail(source_file.is_file() and not source_file.is_symlink() - and source_file.resolve().is_relative_to(source.resolve()), - "linked/invalid tracked bridge file: " + name) - target = destination / name - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source_file, target) + """One canonical exact-HEAD tracked bridge export for all offline consumers.""" + fetch_bridge.copy_tracked(source, destination, verify_lock=False) + def assemble(root: Path, destination: Path, release: Path, bridge: Path, diff --git a/scripts/check-release-scanner-content.py b/scripts/check-release-scanner-content.py new file mode 100644 index 00000000..aaa319ba --- /dev/null +++ b/scripts/check-release-scanner-content.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Independently verify real file bytes against the pinned Syft SPDX document. + +Never accept a schema-shaped SHA1 string or Syft's nonregular-file zero SHA1 +sentinel as proof that a regular file was hashed. +""" +from __future__ import annotations +import argparse +import hashlib +import json +import tarfile +from pathlib import Path + +MAX_BYTES=12*1024*1024 +SPDX="https://spdx.dev/Document" +STATEMENT="https://in-toto.io/Statement/v1" +EXPECTED_FILE=frozenset(("./usr/bin/dpkg","/usr/bin/dpkg","usr/bin/dpkg")) + + +def insist(condition, reason): + if not condition: + raise ValueError(reason) + + +def file_checksum_evidence(document: dict, contents: bytes) -> dict: + insist(type(document) is dict and type(contents) is bytes and + 0 dict: + with tarfile.open(archive,"r:*") as tar: + members={m.name:m for m in tar.getmembers() if m.isfile()} + def raw_json(key: str, descriptor:dict|None=None): + m=members.get(key) + insist(m is not None and 0 dict: + """This scanner-only probe verifies SPDX syntax, not image authority.""" + policy=json.loads(POLICY.read_text(encoding="utf-8")) + require(policy.get("public_bot_release") is False and + policy.get("slsa_provenance_predicate_type")== + "https://slsa.dev/provenance/v1" and + type(policy.get("slsa_builder_id")) is str and + contract.valid_identity_uri(policy["slsa_builder_id"]), + "scanner pinned provenance policy invalid") + with tarfile.open(archive,"r:*") as tar: + members={m.name:m for m in tar.getmembers() + if m.isfile() and not m.issym() and not m.islnk()} + require(len(members)<=256, "scanner probe exceeds blob budget") + def read(path, oid=None, expected_size=None): + m=members.get(path) + require(m is not None and m.size<=MAX_JSON, + "scanner probe missing or oversized JSON") + if expected_size is not None: + require(type(expected_size) is int and + m.size==expected_size,"scanner descriptor byte mismatch") + stream=tar.extractfile(m) + require(stream is not None,"scanner archive cannot read JSON") + raw=stream.read(MAX_JSON+1) + require(len(raw)<=MAX_JSON,"scanner probe JSON too large") + if oid is not None: + require("sha256:"+hashlib.sha256(raw).hexdigest()==oid, + "scanner descriptor digest mismatch") + return json.loads(raw) + index=read("index.json") + require(type(index) is dict and + type(index.get("manifests")) is list, + "scanner output missing OCI index") + spdx=[] + slsa=[] + def walk(descriptor,depth=0): + require(depth<=8 and type(descriptor) is dict, + "scanner descriptor nesting invalid") + oid=descriptor.get("digest") + require(isinstance(oid,str) and + oid.startswith("sha256:") and len(oid)==71 and + all(c in "0123456789abcdef" for c in oid[7:]), + "scanner OCI digest invalid") + typ=descriptor.get("mediaType") + data=read("blobs/sha256/"+oid[7:],oid,descriptor.get("size")) + if typ=="application/vnd.oci.image.index.v1+json": + require(type(data.get("manifests")) is list, + "scanner image index malformed") + for child in data["manifests"]: + walk(child,depth+1) + return + require(typ in ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"), + "scanner unknown OCI manifest") + annotations=descriptor.get("annotations") or {} + if annotations.get("vnd.docker.reference.type")!="attestation-manifest": + return + for layer in data.get("layers",[]): + require(type(layer) is dict and + layer.get("mediaType")=="application/vnd.in-toto+json", + "scanner attestation layer format invalid") + lid=layer.get("digest") + require(isinstance(lid,str) and lid.startswith("sha256:"), + "scanner attestation statement digest malformed") + statement=read("blobs/sha256/"+lid[7:],lid,layer.get("size")) + require(type(statement) is dict, + "scanner statement envelope invalid") + # An absent field is BuildKit's valid singleton statement; + # explicitly present null/scalar/empty wrappers are invalid. + if "statements" in statement: + rows=statement["statements"] + else: + rows=[statement] + require(type(rows) is list and bool(rows), + "scanner statement envelope invalid") + for row in rows: + require(type(row) is dict and + row.get("_type")==contract.STATEMENT and + type(row.get("predicateType")) is str and + type(row.get("subject")) is list and + bool(row["subject"]), + "scanner in-toto statement element invalid") + require(row.get("predicateType")==contract.SPDX or + row["predicateType"] in contract.SLSA, + "scanner unsupported in-toto predicate") + require(all(type(subject) is dict and + type(subject.get("digest")) is dict and + len(subject["digest"]) > 0 and + type(subject["digest"].get("sha256")) is str and + re.fullmatch(r"[0-9a-f]{64}", + subject["digest"]["sha256"]) is not None + for subject in row["subject"]), + "scanner in-toto subject identity malformed") + pred=row.get("predicate") + require(type(pred) is dict and bool(pred), + "scanner in-toto predicate missing or malformed") + if row["predicateType"]==contract.SPDX: + contract.validate_spdx_document(pred) + spdx.append((len(pred.get("packages",[])), + len(pred.get("files",[])))) + else: + contract.validate_slsa(row["predicateType"],pred) + require(row["predicateType"]== + policy["slsa_provenance_predicate_type"], + "scanner unexpected SLSA provenance version") + require(pred["runDetails"]["builder"]["id"] == + policy["slsa_builder_id"], + "scanner provenance builder identity differs from policy") + slsa.append(True) + for root in index["manifests"]: + walk(root) + require(len(spdx)==1 and sum(spdx[0])>=1, + "scanner must export exactly one nonempty valid SPDX statement") + require(len(slsa)==1, + "scanner must export exactly one expected SLSA v1 statement") + return {"schema_version":1,"scanner_spdx_contract_verified":True, + "scanner_slsa_v1_contract_verified":True, + "builder_identity_matched":True, + "slsa_statement_count":len(slsa), + "spdx_document_count":len(spdx), + "spdx_package_count":spdx[0][0], + "spdx_file_count":spdx[0][1], + "integrity_verified":False,"release_qualified":False, + "public_bot_release":False} + + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument("--archive",type=Path,required=True) + ap.add_argument("--output",type=Path,required=True) + args=ap.parse_args() + report=inspect_scanner_output(args.archive) + args.output.parent.mkdir(parents=True,exist_ok=True) + args.output.write_text(json.dumps(report,indent=2,sort_keys=True)+"\n") + print(json.dumps(report,sort_keys=True)) + + +if __name__=="__main__": + main() diff --git a/scripts/check-release-snapshot.py b/scripts/check-release-snapshot.py new file mode 100644 index 00000000..4f8fafce --- /dev/null +++ b/scripts/check-release-snapshot.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Bounded snapshot reachability preflight; signed APT remains authoritative. + +The preflight rejects missing/truncated clear-signed indexes, but DOES NOT +cryptographically authenticate Ubuntu's InRelease metadata. Docker APT must. +""" +from __future__ import annotations +import argparse +import json +import re +import time +from http.client import IncompleteRead +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +SUITES = ("noble", "noble-updates", "noble-backports", "noble-security") +RETRY_HTTP = (429, 502, 503, 504) +MAX_INRELEASE_BYTES = 4 * 1024 * 1024 +MIN_INRELEASE_BYTES = 512 +TOTAL_BUDGET_SECONDS = 145 + + +def require(ok, reason): + if not ok: + raise ValueError(reason) + + +def validate_sources(content: str, snapshot: str) -> None: + """No extra source, unsigned stanza or changed Ubuntu suite.""" + expected = ( + (("noble", "noble-updates", "noble-backports"), + "https://archive.ubuntu.com/ubuntu"), + (("noble-security",), "https://security.ubuntu.com/ubuntu"), + ) + stanzas = [s for s in re.split(r"\n[ \t]*\n", content.strip()) + if s.strip() and not s.lstrip().startswith("#")] + require(len(stanzas) == 2, "unexpected pinned Ubuntu sources count") + for stanza, (suites, origin) in zip(stanzas, expected): + fields = {} + for raw in stanza.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + require(":" in line, "malformed Ubuntu source stanza") + key, value = line.split(":", 1) + require(key not in fields, "duplicate Ubuntu source key") + fields[key] = value.strip() + require(set(fields) == {"Types", "URIs", "Suites", "Components", + "Architectures", "Signed-By", "Snapshot"} and + fields["Types"] == "deb" and fields["URIs"] == origin and + tuple(fields["Suites"].split()) == suites and + fields["Architectures"] == "amd64" and + fields["Snapshot"] == snapshot and + fields["Signed-By"] == + "/usr/share/keyrings/ubuntu-archive-keyring.gpg" and + set(fields["Components"].split()) == + {"main", "universe", "restricted", "multiverse"}, + "unapproved pinned Ubuntu source identity") + + +def validate_inrelease(body: bytes, suite: str) -> None: + """A bounded, complete, recognizable PGP clear-signed Release envelope.""" + require(MIN_INRELEASE_BYTES <= len(body) <= MAX_INRELEASE_BYTES and + b"\x00" not in body, "empty or truncated Ubuntu InRelease") + try: + content = body.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("unreadable Ubuntu InRelease") from exc + require(content.startswith("-----BEGIN PGP SIGNED MESSAGE-----\n") and + "\nHash: SHA" in content[:150] and + "\n\n" in content[:160], "missing signed Ubuntu InRelease header") + begin = content.find("\n-----BEGIN PGP SIGNATURE-----\n") + ending = content.find("\n-----END PGP SIGNATURE-----", begin) + require(begin > 300 and ending > begin + 48 and + content[ending + len("\n-----END PGP SIGNATURE-----"):].strip() == "", + "incomplete Ubuntu InRelease signature envelope") + signed_text = content[:begin] + require("Origin: Ubuntu" in signed_text and + re.search(r"(?m)^Suite: " + re.escape(suite) + r"\s*$", + signed_text) is not None and + "Architectures:" in signed_text and + "Components:" in signed_text, + "Ubuntu InRelease suite or release metadata invalid") + + +def read_complete(response, expected_url: str, suite: str, *, + deadline: float | None = None, + clock=time.monotonic) -> None: + observed = response.geturl() if hasattr(response, "geturl") else expected_url + require(observed == expected_url, + "Ubuntu snapshot redirected outside frozen suite URL") + content = bytearray() + while True: + require(deadline is None or clock() < deadline, + "snapshot global preflight budget expired during body read") + part = response.read(min(65536, MAX_INRELEASE_BYTES + 1 - len(content))) + if not part: + break + content.extend(part) + require(len(content) <= MAX_INRELEASE_BYTES, + "Ubuntu InRelease exceeds preflight size budget") + declared = getattr(response, "headers", {}).get("Content-Length") + if declared is not None: + require(declared.isdecimal() and int(declared) == len(content), + "Ubuntu InRelease HTTP body truncated") + validate_inrelease(bytes(content), suite) + + +def probe(snapshot: str, *, opener=urlopen, sleeper=time.sleep, + clock=time.monotonic) -> dict: + require(isinstance(snapshot, str) and + re.fullmatch(r"\d{8}T\d{6}Z", snapshot) is not None, + "invalid pinned Ubuntu snapshot identity") + deadline = clock() + TOTAL_BUDGET_SECONDS + results = {} + for suite in SUITES: + url = (f"https://snapshot.ubuntu.com/ubuntu/{snapshot}/dists/" + f"{suite}/InRelease") + for attempt in range(1, 4): + left = deadline - clock() + require(left > 1.0, "pinned snapshot global preflight budget exhausted") + try: + with opener(url, timeout=min(10.0, left - 0.5)) as response: + read_complete(response, url, suite, + deadline=deadline, clock=clock) + results[suite] = {"reachable": True, "attempts": attempt} + break + except HTTPError as exc: + require(exc.code in RETRY_HTTP and attempt != 3, + f"pinned snapshot {suite} HTTP {exc.code} unavailable") + except (URLError, TimeoutError, ConnectionResetError, + BrokenPipeError, IncompleteRead, OSError) as exc: + require(attempt != 3, + f"pinned snapshot {suite} network/read unavailable") + left = deadline - clock() + require(left > 1.0, "pinned snapshot global preflight budget exhausted") + sleeper(min(attempt * 3, max(0.0, left - 1.0))) + return {"schema_version": 1, "pinned_snapshot": snapshot, + "availability_only": True, "signature_verified": False, + "suites": results, "public_bot_release": False} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--policy", type=Path, required=True) + ap.add_argument("--sources", type=Path, required=True) + ap.add_argument("--output", type=Path, required=True) + args = ap.parse_args() + report = {"schema_version": 1, "availability_only": True, + "signature_verified": False, "public_bot_release": False} + try: + policy = json.loads(args.policy.read_text(encoding="utf-8")) + snapshot = policy.get("apt_snapshot") + require(isinstance(snapshot, str), "invalid pinned snapshot in policy") + validate_sources(args.sources.read_text(encoding="utf-8"), snapshot) + report["pinned_snapshot"] = snapshot + report.update(probe(snapshot)) + code = 0 + except (ValueError, OSError, TypeError, KeyError) as exc: + # All expected filesystem, socket and HTTP acquisition failures + # create bounded, structured evidence; no raw response is retained. + report["error_class"] = type(exc).__name__ + report["failure_reason"] = str(exc)[:180] + report["unavailable_or_invalid"] = True + code = 2 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, sort_keys=True, indent=2) + "\n") + print(json.dumps(report, sort_keys=True)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci-release-scanner.py b/scripts/ci-release-scanner.py new file mode 100644 index 00000000..282bcbc4 --- /dev/null +++ b/scripts/ci-release-scanner.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Prepare a source-locked offline scanner through a disposable Docker registry. + +This transports exact scanner bytes between the host Docker daemon and a +docker-container BuildKit daemon. No image is pushed to a public registry. +A candidate manifest is NEVER its own release approval. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import subprocess +import time +from pathlib import Path +from urllib.request import Request, urlopen + +_source_path=Path(__file__).with_name("prepare-release-scanner.py") +_spec=importlib.util.spec_from_file_location("release1_scanner_source",_source_path) +if _spec is None or _spec.loader is None: + raise ValueError("scanner source verifier unavailable") +_source=importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_source) +ROOT, LOCK, prepare, require=(_source.ROOT,_source.LOCK, + _source.prepare,_source.require) + +REGISTRY_HOST="127.0.0.1:5000" +REGISTRY_BUILDKIT="scanner-registry:5000" +NAME="allfather/release-scanner" +MEDIA_TYPES=("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") +OUT=ROOT/"build/test-results/release1-scanner" + + +def call(args: list[str], *, timeout: int=900) -> str: + result=subprocess.run(args,check=False,stdout=subprocess.PIPE, + stderr=subprocess.PIPE,timeout=timeout) + require(result.returncode==0, + "scanner Docker/registry operation refused: "+args[0]) + return result.stdout.decode("utf-8","replace").strip() + + +def docker_names() -> tuple[str,str]: + run=os.environ.get("GITHUB_RUN_ID","local") + attempt=os.environ.get("GITHUB_RUN_ATTEMPT","1") + require(run.isdigit() or run=="local","scanner GitHub run identity malformed") + require(attempt.isdigit(),"scanner GitHub attempt identity malformed") + return "release1-scanner-"+run+"-"+attempt, "release1-scanner-registry-"+run + + +def builder_name() -> str: + mode=os.environ.get("RELEASE1_SCANNER_BUILDER","release1-scanner-probe") + require(mode in ("release1-scanner-probe","release1-builder"), + "unapproved scanner builder name") + return mode + + +def cleanup() -> None: + network,registry=docker_names() + for args in (["docker","buildx","rm","--force",builder_name()], + ["docker","rm","-f",registry], + ["docker","network","rm",network]): + try: + subprocess.run(args,stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,timeout=35,check=False) + except (OSError,subprocess.TimeoutExpired): + pass + + +def response_digest(tag: str) -> str: + url="http://"+REGISTRY_HOST+"/v2/"+NAME+"/manifests/"+tag + req=Request(url,headers={"Accept":", ".join(MEDIA_TYPES)}) + with urlopen(req,timeout=12) as response: + media=response.headers.get("Content-Type","").split(";",1)[0] + raw=response.read(8*1024*1024+1) + header=response.headers.get("Docker-Content-Digest") + require(media in MEDIA_TYPES and len(raw)<=8*1024*1024, + "local scanner manifest content type or size invalid") + digest="sha256:"+hashlib.sha256(raw).hexdigest() + require(header==digest and + json.loads(raw).get("schemaVersion")==2, + "local scanner manifest hash, header or schema mismatch") + return digest + + +def setup() -> dict: + require(os.environ.get("SOURCE_SHA") and + os.environ.get("GITHUB_ENV"), + "scanner setup is restricted to exact-head GitHub CI") + require(call(["git","rev-parse","HEAD"])== + os.environ["SOURCE_SHA"], + "scanner setup checkout differs from exact requested SHA") + policy=json.loads(LOCK.read_text(encoding="utf-8")) + require(policy.get("registry_transport_image")== + "docker.io/library/registry@sha256:" + "a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "scanner registry transport image is not pinned") + OUT.mkdir(parents=True,exist_ok=True) + source=prepare(output=OUT) + network,registry=docker_names() + try: + call(["docker","network","create","--driver","bridge",network],timeout=90) + call(["docker","run","-d","--name",registry,"--network",network, + "--network-alias","scanner-registry", + "-p","127.0.0.1:5000:5000", + policy["registry_transport_image"]],timeout=90) + for attempt in range(25): + try: + req=Request("http://127.0.0.1:5000/v2/") + with urlopen(req,timeout=3) as r: + if r.status==200: + break + except (OSError,TimeoutError): + time.sleep(1) + else: + raise ValueError("isolated scanner registry readiness failed") + config=OUT/"buildkitd.scanner.toml" + config.write_text( + '[registry."scanner-registry:5000"]\n http = true\n', + encoding="utf-8") + call(["docker","buildx","create","--driver","docker-container", + "--driver-opt","network="+network,"--name",builder_name(), + "--buildkitd-config",str(config),"--use"],timeout=90) + call(["docker","buildx","inspect","--builder",builder_name(), + "--bootstrap"],timeout=180) + # Export directly from the reproducible BuildKit image exporter. + # Unlike Docker's legacy --load path this exposes timestamp rewriting + # and a stable OCI-format manifest, with no host Docker image store. + hashes=[] + for index in (1,2): + image=REGISTRY_BUILDKIT+"/"+NAME+":candidate-"+str(index) + call(["docker","buildx","build","--builder",builder_name(), + "--platform","linux/amd64","--no-cache","--pull=false", + "--provenance=false","--sbom=false", + "--build-arg","SOURCE_DATE_EPOCH="+str(source["source_date_epoch"]), + "-f",str(ROOT/"deploy/release/scanner/Dockerfile"), + "--output","type=image,name="+image+ + ",push=true,registry.insecure=true," + "rewrite-timestamp=true,oci-mediatypes=true", + str(OUT)],timeout=360) + hashes.append(response_digest("candidate-"+str(index))) + digest=hashes[0] + candidate={ + "schema_version":1, + "upstream_commit":source["upstream_commit"], + "binary_sha256":source["binary_sha256"], + "manifest_digest":digest, + "candidate_1_manifest_digest":hashes[0], + "candidate_2_manifest_digest":hashes[1], + "image_reproducible":hashes[0]==hashes[1], + "binary_reproducible":True, + "scanner_reference_kind":"ephemeral-local-registry-manifest", + "public_bot_release":False, + "artifact_distribution_authorized":False, + "approved_identity_matched":( + policy.get("approved_binary_sha256")==source["binary_sha256"] + and policy.get("approved_manifest_digest")==digest), + } + (OUT/"scanner-candidate.json").write_text( + json.dumps(candidate,indent=2,sort_keys=True)+"\n",encoding="utf-8") + print(json.dumps(candidate,sort_keys=True)) + require(candidate["image_reproducible"], + "scanner independent BuildKit image exports differ") + ref=REGISTRY_BUILDKIT+"/"+NAME+"@"+digest + # The verifier always addresses the exact exported manifest, never + # a tag that a later producer could repoint. + env=Path(os.environ["GITHUB_ENV"]) + with env.open("a",encoding="utf-8") as fd: + fd.write("RELEASE_SCANNER_REF="+ref+"\n") + fd.write("RELEASE_SCANNER_DIGEST="+digest+"\n") + return candidate + except BaseException: + cleanup() + raise + + +def verify_approval() -> None: + policy=json.loads(LOCK.read_text(encoding="utf-8")) + candidate=json.loads((OUT/"scanner-candidate.json").read_text("utf-8")) + require(candidate.get("image_reproducible") is True and + candidate.get("binary_reproducible") is True, + "scanner candidate reproducibility not proven") + require(candidate.get("upstream_commit")==policy.get("upstream_commit"), + "scanner candidate source identity mismatch") + # Only a separate committed reviewed lock can grant scanner identity. + require(type(policy.get("approved_binary_sha256")) is str and + type(policy.get("approved_manifest_digest")) is str and + policy["approved_binary_sha256"]==candidate.get("binary_sha256") and + policy["approved_manifest_digest"]==candidate.get("manifest_digest"), + "scanner candidate digest not yet frozen/approved") + print(json.dumps({ + "scanner_identity_approved":True, + "scanner_manifest_digest":candidate["manifest_digest"], + "source_locked":True, + "public_bot_release":False, + "artifact_distribution_authorized":False},sort_keys=True)) + + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument("action",choices=("setup","verify-approval","cleanup")) + args=ap.parse_args() + if args.action=="cleanup": + cleanup() + return + try: + if args.action=="setup": + setup() + else: + verify_approval() + except (OSError,ValueError,subprocess.SubprocessError,TimeoutError, + KeyError,TypeError) as error: + ap.exit(2,"scanner CI candidate refused: "+type(error).__name__+ + " ("+str(error)[:160]+")\n") + + +if __name__=="__main__": + main() diff --git a/scripts/http_evidence_verdict.py b/scripts/http_evidence_verdict.py new file mode 100644 index 00000000..79ab8b2e --- /dev/null +++ b/scripts/http_evidence_verdict.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Independent status adjudication of an *observed*, canonical HTTP request. + +An injected fault status is legitimate only on the exact request ID selected +by the frozen source trace. This validator never assumes syntactically valid +GET/POST equals successful or safe operation. +""" +from __future__ import annotations +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +POLICY=ROOT/"qualification/http-evidence-contract-v1.json" +MOVE_PREFIX="/api/bot/game/off3b001/move/" + + +def approved_status(request: object, trace: list[dict], + action: str="none") -> bool: + if not isinstance(request,dict) or type(request.get("status")) is not int: + return False + policy=json.loads(POLICY.read_text(encoding="utf-8")) + if policy.get("schema_version") != 1 or policy.get("public_bot_release") is True: + return False + method,path,status=request.get("method"),request.get("path"),request["status"] + if method not in ("GET","POST") or type(path) is not str: + return False + if method == "GET" or not path.startswith(MOVE_PREFIX): + return status == policy["non_move_status"] + target_status=policy["fault_specific_move_status"].get(action) + request_id=request.get("request_id") + matching=[row for row in trace + if type(row) is dict and row.get("event")=="fault_activated" + and row.get("action")==action + and row.get("endpoint")=="move_post" + and row.get("request_id")==request_id] + if target_status is not None and matching: + return len(matching)==1 and status==target_status + return status==policy["ordinary_move_status"] + +def unexpected_statuses(requests: list[dict], trace: list[dict], + action: str) -> list[dict]: + """Return safe bounded labels; no raw requests, URL or query strings.""" + result=[] + for row in requests: + if not approved_status(row,trace,action): + result.append({ + "method":row.get("method") if row.get("method") in ("GET","POST") + else "unknown", + "status":row.get("status") if type(row.get("status")) is int else None, + "reason":"unapproved_http_status", + }) + return result[:32] diff --git a/scripts/inspect-release-oci.py b/scripts/inspect-release-oci.py new file mode 100644 index 00000000..d80e0c88 --- /dev/null +++ b/scripts/inspect-release-oci.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Diagnostic-only OCI structure inspection; never grants image qualification. + +Read only bounded JSON descriptors and statement metadata from the archive. +No binary layer, complete SBOM/provenance predicate, or credentials are exported. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import tarfile +from pathlib import Path + +MAX_JSON = 8 * 1024 * 1024 +SAFE_ANNOTATION_KEYS = {"vnd.docker.reference.type", "vnd.docker.reference.digest"} +SAFE_DESCRIPTOR_TYPES = frozenset({ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +}) +SAFE_CONFIG_TYPES = frozenset({ + "application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", + "application/vnd.oci.empty.v1+json", +}) +SAFE_PREDICATE_TYPES = frozenset({ + "https://spdx.dev/Document", + "https://slsa.dev/provenance/v0.2", + "https://slsa.dev/provenance/v1", +}) +INTOTO_V1 = "https://in-toto.io/Statement/v1" + + +def approved_type(value, allowed): + return value if type(value) is str and value in allowed else "unrecognized" +SAFE_SPDX_KEYS = { + "SPDXID", "spdxVersion", "name", "downloadLocation", "fileName", + "checksums", "filesAnalyzed", "licenseConcluded", "licenseDeclared", + "licenseInfoFromFiles", "licenseInfoInFiles", "copyrightText", + "versionInfo", "externalRefs", "primaryPackagePurpose", + "sourceInfo", "supplier", "originator", "verificationCode", + "packageVerificationCode", "fileTypes", "description", +} + + +def digest_only(value): + if (isinstance(value, str) and len(value) == 71 and + value.startswith("sha256:") and + all(ch in "0123456789abcdef" for ch in value[7:])): + return value + return None + + +def approved_annotations(value): + if type(value) is not dict: + return {} + result = {} + for key in SAFE_ANNOTATION_KEYS: + observed = value.get(key) + if key.endswith(".digest"): + verified = digest_only(observed) + if verified: + result[key] = verified + elif observed == "attestation-manifest": + result[key] = observed + return result + + +def approved_platform(value): + if type(value) is not dict: + return None + return { + "os": value.get("os") if value.get("os") in ("linux", "unknown") else "other", + "architecture": value.get("architecture") + if value.get("architecture") in ("amd64", "unknown") else "other", + "additional_field_count": max(len(value)-2, 0), + } + + + +def inspect(archive: Path, metadata: Path) -> dict: + """Bounded diagnostic traversal of indexes, manifests, and statement summaries.""" + result = {"schema_version": 1, "diagnostic_only": True, + "integrity_verified": False, "public_bot_release": False} + build = json.loads(metadata.read_text(encoding="utf-8")) + if type(build) is not dict: + raise ValueError("invalid OCI diagnostic BuildKit metadata") + result["buildkit_digest"] = digest_only(build.get("containerimage.digest")) + with tarfile.open(archive, "r:*") as tar: + members = {} + for member in tar.getmembers(): + path = Path(member.name) + if path.is_absolute() or ".." in path.parts or member.name in members: + raise ValueError("invalid or duplicate OCI diagnostic archive path") + if member.issym() or member.islnk(): + raise ValueError("linked OCI diagnostic archive member") + members[member.name] = member + + def document(path: str): + member = members.get(path) + if member is None or not member.isfile() or member.size > MAX_JSON: + raise ValueError("missing or oversized descriptor: " + path) + stream = tar.extractfile(member) + if stream is None: + raise ValueError("unreadable descriptor: " + path) + with stream: + content = stream.read(MAX_JSON + 1) + if len(content) > MAX_JSON: + raise ValueError("oversized descriptor: " + path) + return json.loads(content) + + def bounded_descriptor(desc, ancestry): + if not isinstance(desc, dict): + raise ValueError("malformed OCI descriptor") + oid = desc.get("digest") + size = desc.get("size") + typ = desc.get("mediaType") + if (not isinstance(oid, str) or len(oid) != 71 or + not oid.startswith("sha256:") or + any(c not in "0123456789abcdef" for c in oid[7:]) or + type(size) is not int or size < 0): + raise ValueError("malformed OCI diagnostic descriptor identity") + if oid in ancestry: + raise ValueError("cyclic OCI descriptor graph") + path = "blobs/sha256/" + oid[7:] + actual = members.get(path) + if actual is None or not actual.isfile(): + raise ValueError("missing OCI referenced blob") + return {"digest": oid, + "media_type": approved_type(typ, SAFE_DESCRIPTOR_TYPES), + "declared_size": size, "actual_size": actual.size, + "descriptor_path": list(ancestry) + [oid], + "platform": approved_platform(desc.get("platform")), + "annotations": approved_annotations(desc.get("annotations"))} + + index = document("index.json") + if type(index) is not dict or type(index.get("manifests")) is not list: + raise ValueError("invalid layout index") + roots = index["manifests"] + result["index_manifests"] = [] + result["application_manifest_digest"] = None + result["attestation_manifest_digests"] = [] + result["exported_root_digest"] = digest_only(build.get("containerimage.digest")) + inspected = [0] + + def traverse(desc, ancestry=()): + inspected[0] += 1 + if inspected[0] > 256 or len(ancestry) > 8: + raise ValueError("OCI diagnostic graph too deep or large") + rec = bounded_descriptor(desc, ancestry) + oid = rec["digest"] + if rec["declared_size"] != rec["actual_size"]: + raise ValueError("OCI diagnostic descriptor size mismatch") + typ = rec["media_type"] + if typ == "application/vnd.oci.image.index.v1+json": + node = document("blobs/sha256/" + oid[7:]) + if not isinstance(node, dict) or not isinstance(node.get("manifests"), list): + raise ValueError("malformed nested OCI index") + rec["children"] = [traverse(child, ancestry + (oid,)) + for child in node["manifests"]] + rec["artifact_type"] = "nested-index" + return rec + if typ not in ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"): + raise ValueError("unexpected descriptor media type") + manifest = document("blobs/sha256/" + oid[7:]) + if type(manifest) is not dict: + raise ValueError("invalid OCI diagnostic manifest object") + config = manifest.get("config") + layers = manifest.get("layers") + if type(config) is not dict or type(layers) is not list: + raise ValueError("invalid OCI diagnostic config/layers container") + if any(type(layer) is not dict for layer in layers): + raise ValueError("invalid OCI diagnostic layer descriptor") + rec["artifact_type"] = ( + "application" if not ( + (rec["annotations"] or {}).get("vnd.docker.reference.type") + == "attestation-manifest") else "attestation") + subject = manifest.get("subject") + if isinstance(subject, dict): + oid_subject = digest_only(subject.get("digest")) + referenced = (members.get("blobs/sha256/" + oid_subject[7:]) + if oid_subject else None) + rec["manifest_subject"] = { + "digest": oid_subject, + "media_type": subject.get("mediaType") + if subject.get("mediaType") in ( + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") + else "unrecognized", + "size": subject.get("size") if type(subject.get("size")) is int + else None, + } + rec["subject_declared_size"] = rec["manifest_subject"]["size"] + rec["subject_actual_size"] = ( + referenced.size if referenced is not None and referenced.isfile() + else None) + else: + rec["manifest_subject"] = None + rec["config_type"] = approved_type( + config.get("mediaType"), SAFE_CONFIG_TYPES) + attested = (rec["annotations"] or {}).get( + "vnd.docker.reference.type") == "attestation-manifest" + if attested: + result["attestation_manifest_digests"].append(oid) + else: + result["application_manifest_digest"] = oid + rec["layers"] = [] + for layer in layers: + lid = layer.get("digest", "") + member = members.get("blobs/sha256/" + str(lid)[7:]) + row = {"digest": digest_only(lid), + "media_type": layer.get("mediaType") + if layer.get("mediaType") in ( + "application/vnd.in-toto+json", + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip") + else "unrecognized", + "annotations": approved_annotations(layer.get("annotations")), + "declared_size": layer.get("size") if type(layer.get("size")) is int + else None, + "actual_size": member.size if member else None} + if attested and layer.get("mediaType") == "application/vnd.in-toto+json": + statement = document("blobs/sha256/" + str(lid)[7:]) + if type(statement) is not dict: + raise ValueError("invalid OCI diagnostic attestation object") + # Only an *absent* wrapper authorizes singleton form. + # Present null must never be reinterpreted as singleton. + if "statements" in statement: + statements = statement["statements"] + else: + statements = [statement] + if type(statements) is not list or not statements: + raise ValueError("invalid OCI diagnostic statement list") + row["statements"] = [] + for st in statements: + if type(st) is not dict: + raise ValueError("invalid OCI diagnostic statement object") + predicate = st.get("predicate") + safe_keys = (sorted(predicate.keys()) + if isinstance(predicate, dict) else []) + summary = { + "_type": approved_type(st.get("_type"), {INTOTO_V1}), + "predicate_type": approved_type( + st.get("predicateType"), SAFE_PREDICATE_TYPES), + "subject_count": len(st.get("subject", [])) + if isinstance(st.get("subject"), list) else None, + # Subject names and untrusted predicate values + # must never reach retained CI artifacts. + "subject_digests": sorted({ + digest for sub in st.get("subject", []) + if isinstance(sub, dict) + and isinstance(sub.get("digest"), dict) + for digest in [digest_only( + "sha256:" + str(sub["digest"].get("sha256", "")))] + if digest + })[:8] if type(st.get("subject")) is list else [], + "predicate_keys": [k for k in safe_keys + if k in SAFE_SPDX_KEYS][:32], + } + if st.get("predicateType") == "https://spdx.dev/Document": + packages = (predicate.get("packages") + if isinstance(predicate, dict) else None) + files = (predicate.get("files") + if isinstance(predicate, dict) else None) + summary["spdx_package_count"] = ( + len(packages) if type(packages) is list else None) + summary["spdx_file_count"] = ( + len(files) if type(files) is list else None) + summary["spdx_package_field_keys"] = ( + sorted({key for item in packages[:32] + if isinstance(item, dict) for key in item + if key in SAFE_SPDX_KEYS})[:32] + if type(packages) is list else []) + summary["spdx_file_field_keys"] = ( + sorted({key for item in files[:32] + if isinstance(item, dict) for key in item + if key in SAFE_SPDX_KEYS})[:32] + if type(files) is list else []) + row["statements"].append(summary) + rec["layers"].append(row) + return rec + + result["index_manifests"] = [traverse(root) for root in roots] + result["descriptor_count"] = inspected[0] + return result + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--archive", type=Path, required=True) + ap.add_argument("--metadata", type=Path, required=True) + ap.add_argument("--output", type=Path, required=True) + args = ap.parse_args() + try: + report = inspect(args.archive, args.metadata) + except (OSError, ValueError, TypeError, AttributeError, IndexError, + KeyError, tarfile.TarError) as exc: + report = {"schema_version": 1, "diagnostic_only": True, + "integrity_verified": False, "public_bot_release": False, + # No untrusted archive path, metadata or statement string + # may be published when verification refuses an archive. + "inspection_error": type(exc).__name__, + "inspection_failed": True} + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + print(json.dumps({"diagnostic_only": True, + "descriptor_count": len(report.get("index_manifests", [])), + "inspection_error": report.get("inspection_error")})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare-release-scanner.py b/scripts/prepare-release-scanner.py new file mode 100644 index 00000000..0a8ee2d5 --- /dev/null +++ b/scripts/prepare-release-scanner.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Build a locally patched, source-locked BuildKit scanner. No release grant. + +Only commits from the frozen v1.11.0 Git tree, one checked patch, and vendored +Go dependencies may determine the executable. Produces an unprivileged test +binary; Docker/OCI transport and release authority are independent gates. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +LOCK=ROOT/"qualification/release-scanner-v1.json" + + +def require(condition: bool, message: str): + if not condition: + raise ValueError(message) + + +def sha256(path: Path) -> str: + h=hashlib.sha256() + with path.open("rb") as f: + for block in iter(lambda:f.read(1024*1024),b""): + h.update(block) + return h.hexdigest() + + +def command(argv: list[str], *, cwd: Path|None=None, + env: dict|None=None, timeout: int=300, + capture: bool=True) -> str: + p=subprocess.run(argv,cwd=cwd,env=env,timeout=timeout, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + check=False) + require(p.returncode==0, + "scanner source/build operation failed: "+argv[0]) + return p.stdout.decode("utf-8","replace").strip() if capture else "" + + +def check_lock(policy: dict) -> None: + require(policy.get("schema_version")==1 and + policy.get("scope")=="offline-release-scanner-only" and + policy.get("upstream_repository")== + "https://github.com/docker/buildkit-syft-scanner" and + policy.get("upstream_tag")=="v1.11.0" and + policy.get("upstream_commit")== + "d88056b4e5b61d0ca037340df91be47d343b4386" and + policy.get("toolchain_version")=="go1.25.8" and + policy.get("vendored_syft_version")=="v1.42.3" and + policy.get("patch_path")== + "deploy/release/scanner/target.go.patch" and + policy.get("required_file_hash_algorithms")==["SHA1","SHA256"] and + policy.get("reproducible_binary_required") is True and + policy.get("reproducible_image_required") is True and + policy.get("public_bot_release") is False and + policy.get("artifact_distribution_authorized") is False and + policy.get("live_credentials_authorized") is False, + "scanner immutable source/build policy mismatch") + digest=policy.get("patch_sha256") + require(type(digest) is str and len(digest)==64 and + all(c in "0123456789abcdef" for c in digest) and + sha256(ROOT/policy["patch_path"])==digest, + "scanner patch bytes differ from reviewed lock") + + +def prepare(*, output: Path) -> dict: + policy=json.loads(LOCK.read_text(encoding="utf-8")) + check_lock(policy) + require(output.resolve().is_relative_to(ROOT.resolve()), + "scanner build output must remain within checkout") + require(not output.is_symlink(),"scanner build output cannot be a symlink") + output.mkdir(parents=True,exist_ok=True) + env=os.environ.copy() + env.update({"GOPROXY":"off","GOSUMDB":"off","GOTOOLCHAIN":"local", + "GOFLAGS":"-mod=vendor","CGO_ENABLED":"0", + "GOOS":"linux","GOARCH":"amd64"}) + tool=command(["go","version"],env=env) + require(tool.startswith("go version go1.25.8 "), + "scanner requires exact Go 1.25.8 toolchain") + patch=ROOT/policy["patch_path"] + with tempfile.TemporaryDirectory(prefix="release1-scanner-src-") as td: + source=Path(td)/"upstream" + command(["git","clone","--depth=1","--single-branch", + "--branch",policy["upstream_tag"], + policy["upstream_repository"],str(source)],timeout=600) + require(command(["git","rev-parse","HEAD"],cwd=source)== + policy["upstream_commit"], + "scanner upstream source SHA mismatch") + require(command(["git","status","--porcelain"],cwd=source)=="", + "scanner upstream checkout not clean") + gomod=(source/"go.mod").read_text(encoding="utf-8") + require("\ngo 1.25.8\n" in gomod and + "github.com/anchore/syft v1.42.3" in gomod and + (source/"vendor/modules.txt").is_file(), + "scanner vendored dependency baseline mismatch") + command(["git","apply","--check",str(patch)],cwd=source) + command(["git","apply",str(patch)],cwd=source) + require(command(["git","diff","--name-only"],cwd=source)== + "internal/target.go", + "scanner patch modified unapproved source") + command(["git","diff","--check"],cwd=source) + patched=source/"internal/target.go" + require('WithHashers(crypto.SHA1, crypto.SHA256)' in + patched.read_text(encoding="utf-8"), + "scanner required hashers missing") + epoch=command(["git","show","-s","--format=%ct","HEAD"],cwd=source) + env["SOURCE_DATE_EPOCH"]=epoch + ldflags=("-buildid= "+ + "-X github.com/docker/buildkit-syft-scanner/version.Version=v1.11.0 "+ + "-X github.com/docker/buildkit-syft-scanner/version.SyftVersion=v1.42.3") + binary_a=output/"scanner-build-a" + binary_b=output/"scanner-build-b" + for dest in (binary_a,binary_b): + command(["go","build","-trimpath","-buildvcs=false", + "-ldflags",ldflags,"-o",str(dest), + "./cmd/syft-scanner"],cwd=source,env=env,timeout=1200) + binary_hash=sha256(binary_a) + require(binary_hash==sha256(binary_b), + "scanner reproducible executable bytes differ") + if policy.get("approved_binary_sha256") is not None: + require(binary_hash==policy["approved_binary_sha256"], + "scanner executable differs from reviewed binary identity") + final=output/"syft-scanner" + shutil.copyfile(binary_a,final) + final.chmod(0o555) + report={ + "schema_version":1, + "upstream_commit":policy["upstream_commit"], + "go_version":policy["toolchain_version"], + "vendored_syft_version":policy["vendored_syft_version"], + "source_patch_sha256":policy["patch_sha256"], + "binary_sha256":binary_hash, + "binary_reproducible":True, + "source_locked":True, + "source_date_epoch":int(epoch), + "public_bot_release":False, + "artifact_distribution_authorized":False, + } + (output/"scanner-source.json").write_text( + json.dumps(report,indent=2,sort_keys=True)+"\n",encoding="utf-8") + print(json.dumps(report,sort_keys=True)) + return report + + +def main() -> None: + p=argparse.ArgumentParser() + p.add_argument("--output",type=Path, + default=ROOT/"build/test-results/release1-scanner") + args=p.parse_args() + try: + prepare(output=args.output) + except (OSError,ValueError,subprocess.SubprocessError,TimeoutError) as ex: + p.exit(2,"release scanner source candidate refused: "+ + type(ex).__name__+"\n") + + +if __name__=="__main__": + main() diff --git a/scripts/qualify-online-recovery-v2.py b/scripts/qualify-online-recovery-v2.py index 18a29f62..eb594f3c 100644 --- a/scripts/qualify-online-recovery-v2.py +++ b/scripts/qualify-online-recovery-v2.py @@ -10,6 +10,9 @@ import hashlib import importlib.util import json +import tempfile +from collections import Counter +from urllib.parse import unquote import subprocess import sqlite3 import sys @@ -20,7 +23,12 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from tests.lichess.fault_plan import load_scenarios +from tests.lichess.http_target_contract import canonical_target from deploy.lichess.reconcile import position_id +from scripts.verify_source_closure import verify_profile, verify_committed_bytes +from scripts.http_evidence_verdict import approved_status +from scripts.sanitize_qualification_evidence import sanitize_report +from scripts.verify_assurance_contract import verify_report def _v1_module(): spec = importlib.util.spec_from_file_location( @@ -67,6 +75,143 @@ def attest(document: dict, exact_sha: str, paths: list[str]): require(files[name] == hash_file(target), "source hash mismatch: "+name) +def independently_expected_overlay() -> dict: + """Rebuild the frozen overlay from verified upstream source, never evidence.""" + from deploy.lichess import fetch_bridge, recovery_overlay + source = fetch_bridge.DEFAULT + lock = read(fetch_bridge.LOCK) + fetch_bridge.verify(source, lock) + with tempfile.TemporaryDirectory(prefix="offline-overlay-verifier-") as d: + staged = Path(d) / "bridge" + fetch_bridge.copy_tracked(source, staged) + return recovery_overlay.apply(staged) + + +def verify_overlay_identity(manifest: dict, expected: dict) -> None: + require(manifest == expected, + "overlay files, original blob IDs or actual patched hashes differ from frozen source") + + +def verify_guarded_requests(requests: list[dict], trace: list[dict], + game: dict, intents: list[dict], *, + scenario_action: str = "none") -> dict: + """Bind every HTTP move attempt to a unique durable intent and legal board.""" + gid = game.get("game_id") + moves = game.get("moves") + color = game.get("color") + require(gid == "off3b001" and color in ("white", "black") + and isinstance(moves, list), "invalid authoritative game identity") + prefix = f"/api/bot/game/{gid}/move/" + allowed = {"/api/token/test", f"/api/challenge/{gid}/accept", + f"/api/challenge/{gid}/decline"} + require(isinstance(requests, list) and isinstance(trace, list) + and isinstance(intents, list), "missing source request/journal evidence") + forbidden = game.get("forbidden_attempts") + require(isinstance(forbidden, list) + and forbidden == ["POST " + r["path"] for r in requests + if r.get("method") == "POST" and r.get("status") == 403], + "server forbidden-attempt audit differs from HTTP records") + require(not forbidden, "guard submitted forbidden HTTP operations") + # Audit *original raw request semantics*. Checking only the producer's + # normalized pathname would accept a forged GET query or a leading-// + # request that CPython normalized before dispatch. + for request in requests: + raw = request.get("request_target") if isinstance(request, dict) else None + method = request.get("method") if isinstance(request, dict) else None + require(isinstance(raw, str) and + request.get("target_canonical") is True and + canonical_target(method, raw) and + request.get("path") == raw.split("?", 1)[0], + "guarded HTTP request target is not canonical") + require(approved_status(request, trace, scenario_action), + "guarded HTTP request has unqualified status or fault witness") + http_moves = [] + for request in requests: + if request.get("method") != "POST": + continue + path = request.get("path") + require(isinstance(path, str) + and (path in allowed or path.startswith(prefix)), + "unexpected guarded POST operation") + if path.startswith(prefix): + uci = unquote(path[len(prefix):]) + require(uci and "/" not in uci and "?" not in uci and + path == prefix + uci, + "noncanonical guarded move URL") + try: + canonical = chess.Move.from_uci(uci) + except (ValueError, TypeError) as exc: + raise ValueError("malformed guarded move URL") from exc + require(canonical != chess.Move.null() and canonical.uci() == uci, + "noncanonical guarded move URL") + rid = request.get("request_id") + require(isinstance(rid, str) and bool(rid), + "guarded move HTTP request ID missing") + http_moves.append((path, rid)) + raw_attempts = [r for r in trace if r.get("event") == "move_attempt"] + require(all(type(r.get("seq")) is int and r["seq"] > 0 + for r in raw_attempts), + "guarded move trace sequence identity malformed") + require(Counter(http_moves) == + Counter((r.get("path"), f"request-{r['seq']}") + for r in raw_attempts), + "guarded move HTTP records, request IDs and trace attempts differ") + + board = chess.Board() + seen = set() + ledger = {(i.get("game_id"),i.get("ply_before"),i.get("move")): i + for i in intents} + require(len(ledger) == len(intents), "duplicate durable intent identity") + validated = 0 + for event in trace: + typ = event.get("event") + if typ == "board_commit": + uci = event.get("uci") + require(event.get("game_id") == gid and + event.get("ply_before") == len(board.move_stack), + "commit does not match authoritative ply") + try: + move = chess.Move.from_uci(uci) + except (ValueError,TypeError) as exc: + raise ValueError("non-UCI authoritative chess move") from exc + require(move in board.legal_moves, "illegal authoritative commit") + board.push(move) + if typ != "move_attempt": + continue + ply = len(board.move_stack) + uci = event.get("uci") + require(event.get("game_id") == gid and + type(event.get("ply_at_receipt")) is int and + event["ply_at_receipt"] == ply, + "move attempt does not match authoritative game/ply") + require(board.turn == (color == "white"), + "HTTP move attempt made on opponent turn") + try: + move = chess.Move.from_uci(uci) + except (ValueError,TypeError) as exc: + raise ValueError("malformed HTTP UCI move submission") from exc + require(move != chess.Move.null() and move.uci() == uci and + event.get("path") == prefix + uci, + "guarded move URL differs from traced canonical UCI") + require(move in board.legal_moves, + "position-illegal HTTP move submission") + key = (gid, ply, uci) + require(key not in seen, "blind repeat HTTP move submission") + seen.add(key) + intent = ledger.get(key) + require(intent is not None and + json.loads(intent["history_json"]) == + [m.uci() for m in board.move_stack], + "move attempt without exact authoritative durable intent") + validated += 1 + require([m.uci() for m in board.move_stack] == moves, + "HTTP trace board does not reconstruct game history") + require(validated == len(raw_attempts), + "not every recorded HTTP move was independently checked") + return {"unique_legal_move_attempts": validated, + "forbidden_requests": 0} + + def verify_journal(journal: dict, trace: list[dict], game: dict, result: dict, guard_events: list[dict]): require(isinstance(journal, dict) and isinstance(journal.get("owner_epoch"), str), @@ -92,15 +237,22 @@ def verify_journal(journal: dict, trace: list[dict], game: dict, "conflicting intents for one authoritative ply") for row in intents: prior = tuple(json.loads(row["history_json"])) + ply = row.get("ply_before") + require(row.get("game_id") == game.get("game_id") and + type(ply) is int and 0 <= ply <= len(game["moves"]) and + prior == tuple(game["moves"][:ply]), + "journal intent not bound to authoritative game history") require(row["position_id"] == position_id(prior) and - row["ply_before"] == len(prior), "journal position identity drift") + ply == len(prior), "journal position identity drift") require(row["status"] in ("PREPARED", "SUBMITTING", "ACK_OBSERVED", "UNKNOWN_ACK", "CONFIRMED"), "unknown move state") if row["status"] == "CONFIRMED": - require(any(r["ply_before"] == row["ply_before"] - and r["uci"] == row["move"] for r in committed), - "journal claims uncommitted move was confirmed") + require(len([r for r in committed + if r.get("game_id") == row["game_id"] and + r["ply_before"] == row["ply_before"] + and r["uci"] == row["move"]]) == 1, + "journal claims uncommitted or duplicated move confirmation") g = journal.get("games") require(isinstance(g, list) and len(g) <= 1, "unexpected concurrent games") if g: @@ -266,8 +418,41 @@ def event_ids(event_name): return False +def verify_phase_logs(folder: Path, logging: object, + phases: list[dict]) -> None: + """Bind sanitized log bytes to the correct phase and exact child path. + + A producer-supplied hash over a producer-selected arbitrary path does not + independently attest the configured phase or prevent symlink traversal. + """ + require(type(logging) is list and len(logging)==len(phases), + "missing sanitized bridge phase logs") + root=folder.resolve() + for index, row in enumerate(logging, start=1): + expected=f"bridge-phase-{index}.log" + require(type(row) is dict and type(row.get("phase")) is int and + row["phase"]==index and row.get("path")==expected, + "bridge phase log index or filename mismatch") + capture=row.get("capture") + require(type(capture) is dict and + type(capture.get("sanitized_sha256")) is str and + type(capture.get("sanitized_bytes")) is int and + 0<=capture["sanitized_bytes"]<=8*1024*1024, + "bridge log size or hash declaration invalid") + path=folder/expected + require(path.is_file() and not path.is_symlink() and + path.resolve().is_relative_to(root) and + path.parent.resolve()==root and + path.stat().st_size==capture["sanitized_bytes"] and + hash_file(path)==capture["sanitized_sha256"], + "bridge phase log is missing, linked, escaped or modified") + require("offline-fixture-token" not in path.read_text(errors="replace"), + "synthetic credentials appeared in sanitized log") + + def verify_one(folder: Path, *, scenario: dict, color: str, - sha: str, bridge: str, attested_paths: list[str]) -> dict: + sha: str, bridge: str, attested_paths: list[str], + expected_overlay: dict) -> dict: result = read(folder / "result.json") game = read(folder / "authoritative-game.json") plan = read(folder / "fault-plan.json") @@ -291,9 +476,7 @@ def verify_one(folder: Path, *, scenario: dict, color: str, manifest.get("mode") == "OFFLINE-4B-ONLY" and manifest.get("public_bot_release") is False, "upstream recovery overlay identity invalid") - require(len(manifest.get("patched_upstream", {})) == 3 and - len(manifest.get("guard_modules", {})) == 4, - "source-locked overlay surface incomplete") + verify_overlay_identity(manifest, expected_overlay) require(commits == [r for r in trace if r.get("event") == "board_commit"], "server commit extract differs from raw trace") history = v1.verify_trace(trace, game) @@ -311,9 +494,10 @@ def verify_one(folder: Path, *, scenario: dict, color: str, r.get("method") in ("GET", "POST") for r in requests), "malformed HTTP request record") invalid = v1.invalid_move_submissions(requests) - forbidden = [r for r in requests if r["path"].endswith( - ("/abort", "/resign", "/chat", "/upgrade"))] - require(not invalid and not forbidden, "guarded transport sent invalid or prohibited operation") + require(not invalid, "guarded transport sent malformed or null UCI move") + http_witness = verify_guarded_requests(requests, trace, game, + result["journal"]["intents"], + scenario_action=scenario["action"]) runner_errors = result.get("runner_errors") require(isinstance(runner_errors, list) and not runner_errors, "harness startup/cleanup failed: " + str(runner_errors)) @@ -323,20 +507,11 @@ def verify_one(folder: Path, *, scenario: dict, color: str, "missing or additional recovery phase") require(all(isinstance(p.get("bridge_exit"), int) for p in phases), "bridge process was not reaped") - logging = result.get("logging") - require(isinstance(logging, list) and len(logging) == len(phases), - "missing sanitized bridge phase logs") - for row in logging: - path = folder / row["path"] - capture = row["capture"] - require(path.is_file() and hash_file(path) == capture["sanitized_sha256"] - and path.stat().st_size == capture["sanitized_bytes"], - "bridge log evidence incomplete") - require("offline-fixture-token" not in path.read_text(errors="replace"), - "synthetic credentials appeared in sanitized log") + verify_phase_logs(folder, result.get("logging"), phases) journal = result.get("journal") verify_persisted_sqlite(folder, journal) details = verify_journal(journal, trace, game, result, guarded) + details.update(http_witness) terminal_confirmed = verify_terminal_confirmation(game, journal, phases) epochs = {p["epoch"] for p in phases} require(all(e.get("owner_epoch") in epochs for e in guarded), @@ -399,8 +574,11 @@ def qualify(evidence: Path, source_sha: str): require(subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() == source_sha, "not executing verifier on exact CI source head") + verify_profile("online-recovery-v2") + verify_committed_bytes(source_sha, policy["attested_paths"]) scenarios = load_scenarios(ROOT / policy["scenario_source"]) bridge = read(ROOT / policy["bridge_lock"])["commit"] + expected_overlay = independently_expected_overlay() results, failures = [], [] for case, colors in policy["required"].items(): require(case in scenarios and colors == scenarios[case]["colors"], @@ -410,7 +588,8 @@ def qualify(evidence: Path, source_sha: str): results.append(verify_one( evidence / (case+"-"+color), scenario=scenarios[case], color=color, sha=source_sha, bridge=bridge, - attested_paths=policy["attested_paths"])) + attested_paths=policy["attested_paths"], + expected_overlay=expected_overlay)) except (OSError, ValueError, TypeError, KeyError, IndexError, json.JSONDecodeError) as exc: failures.append({"scenario": case, "color": color, "error": str(exc)}) @@ -452,6 +631,8 @@ def main(): "qualification_failures": [{"error": str(exc)}], "network_recovery_qualified": False, "public_bot_release": False, "release_blocked": True} + report=sanitize_report(report) + verify_report(report,"online4b") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") print(json.dumps({k:v for k,v in report.items() if k!="scenario_results"},sort_keys=True)) diff --git a/scripts/qualify-online-recovery.py b/scripts/qualify-online-recovery.py index a9dc4c6a..87129f45 100644 --- a/scripts/qualify-online-recovery.py +++ b/scripts/qualify-online-recovery.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +from collections import Counter import hashlib import json import subprocess @@ -22,6 +23,11 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from tests.lichess.fault_plan import load_scenarios +from tests.lichess.http_target_contract import canonical_target +from scripts.verify_source_closure import verify_profile, verify_committed_bytes +from scripts.http_evidence_verdict import unexpected_statuses +from scripts.sanitize_qualification_evidence import sanitize_report, safe_path +from scripts.verify_assurance_contract import verify_report POLICY = ROOT / "qualification/online-recovery-v1.json" @@ -68,35 +74,133 @@ def verify_sources(manifest: dict, exact_sha: str): def verify_trace(trace: list[dict], game: dict) -> dict: + """Replay *every* observed HTTP attempt at the authoritative receipt ply. + + A syntactically valid UCI is not automatically legal in the actual board. + Classify position-illegal attempts as unsafe evidence; classify a stale + retry separately only if an earlier *legal* attempt was committed and a + correlated lost/unknown-ACK fault preceded the repeated submission. + """ seq = [row.get("seq") for row in trace] - ensure(seq == list(range(1, len(trace) + 1)), "trace ordering or numbering invalid") + ensure(seq == list(range(1, len(trace) + 1)), + "trace ordering or numbering invalid") + ensure(game.get("color") in ("white", "black") and + isinstance(game.get("moves"), list), + "authoritative game bot color or move history missing") board = chess.Board() - attempts = {f"request-{r['seq']}": r for r in trace if r.get("event") == "move_attempt"} - commits = [r for r in trace if r.get("event") == "board_commit"] - ensure(isinstance(game.get("moves"), list), "authoritative move history missing") - for item in commits: - ensure(item.get("game_id") == "off3b001", "foreign committed game id") - ensure(item.get("ply_before") == len(board.move_stack), "commit before-ply drift") - ensure(item.get("ply_after") == len(board.move_stack) + 1, "commit after-ply drift") - ensure(item.get("fen_before") == board.fen(), "commit before-FEN drift") - move = chess.Move.from_uci(item["uci"]) - ensure(move in board.legal_moves, "committed illegal chess move") - origin = item.get("origin") - ensure(origin in ("allfather", "scripted_opponent"), "unknown commit origin") - if origin == "allfather": - req = item.get("request_id") - ensure(req in attempts, "accepted move has no observed HTTP attempt") - ensure(attempts[req].get("uci") == item["uci"], "move request/commit mismatch") - ensure(attempts[req].get("seq") < item["seq"], "commit preceded its request") - else: - ensure(item.get("request_id") is None, "opponent move bound to bridge request") - board.push(move) - ensure(item.get("fen_after") == board.fen(), "commit after-FEN drift") + prefix = "/api/bot/game/off3b001/move/" + bot_white = game["color"] == "white" + attempts = {} + legal_attempts = {} + first_attempt_by_ply = {} + committed_requests = set() + fault_request_ids = set() + lost_ack_request_ids = set() + commits = [] + unsafe_attempts = [] + stale_retries = [] + for row in trace: + event = row.get("event") + if event == "fault_activated" and row.get("action") in ( + "commit_then_close", "commit_then_504", "low_clock_close", + "terminate_bridge"): + if isinstance(row.get("request_id"), str): + fault_request_ids.add(row["request_id"]) + elif event == "connection_close_requested": + if row.get("request_id") in fault_request_ids: + lost_ack_request_ids.add(row["request_id"]) + elif event == "response_write_completed" and row.get("status") == 504: + if row.get("request_id") in fault_request_ids: + lost_ack_request_ids.add(row["request_id"]) + + if event == "move_attempt": + rid = f"request-{row['seq']}" + uci = row.get("uci") + ensure(row.get("game_id") == "off3b001" and + type(row.get("ply_at_receipt")) is int and + row["ply_at_receipt"] == len(board.move_stack), + "move HTTP attempt game/receipt ply identity invalid") + try: + move = chess.Move.from_uci(uci) + except (ValueError, TypeError) as exc: + raise ValueError("invalid traced HTTP move UCI") from exc + ensure(move != chess.Move.null() and move.uci() == uci and + row.get("path") == prefix + uci, + "traced HTTP move path differs from canonical UCI") + ensure(rid not in attempts, "duplicate trace move request ID") + attempts[rid] = row + ply = len(board.move_stack) + position_legal = board.turn == bot_white and move in board.legal_moves + if position_legal: + # The actual server can legally commit a second submission + # after a first dropped-before-commit attempt (R01). The + # repeated attempt is an UNSAFE behavioral witness, but its + # committed legal move must not be mistaken for corrupted + # evidence. Keep every position-legal request in the ledger. + legal_attempts[rid] = (ply, uci) + if ply in first_attempt_by_ply: + unsafe_attempts.append({ + "request_id": rid, "uci": uci, "ply_at_receipt": ply, + "original_request_id": first_attempt_by_ply[ply], + "reason": "repeated_move_submission_same_ply"}) + else: + first_attempt_by_ply[ply] = rid + else: + # A previously legal/committed move is not itself proof of + # safe retransmission. Require exact lost-ACK request evidence. + first = next( + (old_id for old_id, (old_ply, old_move) in legal_attempts.items() + if old_move == uci and old_ply < ply and + old_id in committed_requests and + old_id in lost_ack_request_ids), + None) + if first is not None: + stale_retries.append({ + "request_id": rid, "uci": uci, "ply_at_receipt": ply, + "original_request_id": first, + "reason": "once_legal_retry_after_lost_ack"}) + else: + unsafe_attempts.append({ + "request_id": rid, "uci": uci, "ply_at_receipt": ply, + "reason": "position_illegal_or_wrong_turn"}) + elif event == "board_commit": + item = row + ensure(item.get("game_id") == "off3b001", + "foreign committed game id") + ensure(item.get("ply_before") == len(board.move_stack) and + item.get("ply_after") == len(board.move_stack) + 1 and + item.get("fen_before") == board.fen(), + "commit before-position or ply drift") + try: + move = chess.Move.from_uci(item.get("uci")) + except (ValueError, TypeError) as exc: + raise ValueError("malformed authoritative commit UCI") from exc + ensure(move in board.legal_moves, "committed illegal chess move") + origin = item.get("origin") + ensure(origin in ("allfather", "scripted_opponent"), + "unknown commit origin") + if origin == "allfather": + rid = item.get("request_id") + earlier = attempts.get(rid) + ensure(rid in legal_attempts and earlier is not None and + legal_attempts[rid] == + (len(board.move_stack), move.uci()) and + earlier["seq"] < item["seq"], + "server bot commit lacks earlier legal HTTP move attempt") + committed_requests.add(rid) + else: + ensure(item.get("request_id") is None, + "opponent move bound to bridge request") + board.push(move) + ensure(item.get("fen_after") == board.fen(), + "commit after-position drift") + commits.append(item) ensure(game["moves"] == [m.uci() for m in board.move_stack], "authoritative history differs from committed ledger") accepted = [dict(ply=r["ply_after"], uci=r["uci"]) for r in commits if r["origin"] == "allfather"] - ensure(accepted == game.get("accepted"), "server bot-acceptance list differs from ledger") + ensure(accepted == game.get("accepted"), + "server bot-acceptance list differs from ledger") if game.get("complete") is True: outcome = board.outcome(claim_draw=False) ensure(outcome is not None and game.get("outcome") is not None, @@ -104,7 +208,50 @@ def verify_trace(trace: list[dict], game: dict) -> dict: ensure(outcome.termination.name == game["outcome"].get("termination"), "termination differs from rules oracle") return {"plies": len(commits), "bot_commits": len(accepted), - "http_attempts": len(attempts), "game_complete": game.get("complete") is True} + "http_attempts": len(attempts), + "illegal_attempts": unsafe_attempts, + "stale_retry_attempts": stale_retries, + "game_complete": game.get("complete") is True} + + +def verify_state_fault(action: str, witnessed: dict, sends: list[dict], + trace: list[dict]) -> None: + """Verify the delivered state or elapsed delay, not only producer labels.""" + matching = [r for r in trace if r.get("event") == "stream_fault_source" + and r.get("sid") == witnessed.get("sid") + and r.get("action") == action + and witnessed["seq"] < r.get("seq", -1)] + ensure(len(matching) == 1 and len(sends) == 1, + "faulted state missing unique before/after witness") + source, delivery = matching[0], sends[0] + ensure(source["seq"] < delivery["seq"] and + source.get("original_sha256") and + type(source.get("monotonic_ns")) is int and + type(delivery.get("monotonic_ns")) is int, + "state fault timing or source identity absent") + if action == "delay_state": + ensure(delivery["monotonic_ns"] - source["monotonic_ns"] >= 390_000_000, + "state delay did not actually occur") + ensure(delivery.get("payload_sha256") == source["original_sha256"] + and delivery.get("moves") == source.get("original_moves"), + "delayed delivery does not preserve original state") + if action == "stale_state": + old_moves = source.get("predecessor_moves") + new_moves = source.get("original_moves") + ensure(isinstance(new_moves, str) and isinstance(old_moves, str), + "stale-state original/predecessor history absent") + old = old_moves.split() if old_moves else [] + new = new_moves.split() if new_moves else [] + commits = [r["uci"] for r in trace if + r.get("event") == "board_commit" and + r.get("seq", 0) < source["seq"]] + ensure(len(new) == len(old) + 1 and new[:-1] == old + and new == commits[:len(new)] + and source["predecessor_sha256"] != source["original_sha256"], + "stale-state predecessor is not the actual prior game position") + ensure(delivery.get("moves") == old_moves and + delivery.get("payload_sha256") == source["predecessor_sha256"], + "stale-state delivered unchanged/current payload") def verify_scenario_witness(scenario: dict, activation: dict, @@ -173,6 +320,8 @@ def verify_scenario_witness(scenario: dict, activation: dict, sends = [r for r in trace if r.get("event") == "stream_emit" and r.get("sid") == sid and r.get("fault_action") == action] ensure(bool(sends), "game-state fault was never written") + if action in ("delay_state", "stale_state"): + verify_state_fault(action, witnessed[0], sends, trace) if action in ("duplicate_state", "duplicate_terminal"): ensure(any(r.get("event") == "stream_emit" and r.get("fault_action") == "duplicate" and @@ -180,15 +329,31 @@ def verify_scenario_witness(scenario: dict, activation: dict, for r in trace), "duplicate state witness absent") +def replay_evidence_file(case_dir: Path, relative: str) -> Path: + """Resolve a replay file without following producer-controlled links.""" + ensure(type(relative) is str and + relative.startswith("replays/") and + "\\" not in relative and + not Path(relative).is_absolute() and + not any(p in ("", ".", "..") for p in relative.split("/")), + "unsafe replay manifest path") + root=case_dir.resolve() + cursor=case_dir + for part in relative.split("/"): + cursor=cursor/part + ensure(not cursor.is_symlink(), "replay symlink evidence rejected") + ensure(cursor.is_file() and cursor.resolve().is_relative_to(root), + "replay evidence missing or escapes case directory") + return cursor + + def validate_replays(case_dir: Path) -> dict: rows = read_json(case_dir / "replay-index.json") ensure(isinstance(rows, list) and len(rows) <= 128, "invalid replay index") for row in rows: path = row.get("manifest") - ensure(isinstance(path, str) and path.startswith("replays/") and - ".." not in Path(path).parts, "unsafe replay manifest path") - manifest = case_dir / path - ensure(manifest.is_file() and digest(manifest) == row.get("sha256"), + manifest = replay_evidence_file(case_dir,path) + ensure(digest(manifest) == row.get("sha256"), "replay manifest bytes mismatch") document = read_json(manifest) ensure(document.get("run_id") == row.get("run_id") and @@ -197,7 +362,9 @@ def validate_replays(case_dir: Path) -> dict: ensure((document.get("position") or {}).get("position_id") == row.get("position_id"), "replay position identity mismatch") if "route_sha256" in row: - ensure(digest(manifest.parent / "route.json") == row["route_sha256"], + route = replay_evidence_file( + case_dir, str(Path(path).parent / "route.json")) + ensure(digest(route) == row["route_sha256"], "routing artifact tampered") return {"manifests_retained": len(rows), "game_to_replay_link_qualified": False} @@ -221,10 +388,65 @@ def invalid_move_submissions(http_requests: list[dict]) -> list[dict]: except ValueError: move = None if move is None or move == chess.Move.null(): - invalid.append({"path": path, "uci": uci, "status": request.get("status")}) + invalid.append({"path": safe_path(path), + "uci": uci if uci == "0000" else "__redacted__", + "status": request.get("status")}) return invalid +def unexpected_raw_targets(http_requests: list[dict]) -> list[dict]: + """Bounded unsafe-behavior observations, never raw rejected query data.""" + unexpected = [] + for row in http_requests: + if not isinstance(row, dict): + continue + raw = row.get("request_target") + method = row.get("method") + bad = (not isinstance(raw, str) or + row.get("target_canonical") is not True or + not canonical_target(method, raw) or + row.get("path") != raw.split("?", 1)[0]) + if bad and len(unexpected) < 32: + unexpected.append({ + "method": method if method in ("GET", "POST") else "unknown", + "status": row.get("status") if type(row.get("status")) is int else None, + "path": safe_path(row.get("path")), + "reason": "noncanonical_request_target", + }) + return unexpected + + +def verify_exact_http_move_audit( + http_requests: list[dict], trace: list[dict]) -> int: + """An observed move POST must have exactly one server trace attempt. + + Multisets include request IDs, not only path membership: a legal extra + POST is not made safe by the trace of a different legal POST. + """ + prefix = "/api/bot/game/off3b001/move/" + require_rows = lambda rows: isinstance(rows, list) and all( + isinstance(row, dict) for row in rows) + ensure(require_rows(http_requests) and require_rows(trace), + "invalid HTTP or trace move audit") + observed = [r for r in http_requests if + r.get("method") == "POST" and + isinstance(r.get("path"), str) and + r["path"].startswith(prefix)] + attempted = [r for r in trace if r.get("event") == "move_attempt"] + ensure(all(type(t.get("seq")) is int and t["seq"] > 0 and + isinstance(t.get("uci"), str) and + t.get("path") == prefix + t["uci"] for t in attempted), + "traced move endpoint identity invalid") + ensure(all(isinstance(r.get("request_id"), str) and + bool(r["request_id"]) for r in observed), + "HTTP move request identity missing") + server_rows = Counter((r["path"], r["request_id"]) for r in observed) + trace_rows = Counter((r["path"], f"request-{r['seq']}") for r in attempted) + ensure(server_rows == trace_rows, + "HTTP move records and trace attempts differ") + return sum(trace_rows.values()) + + def validate_experiment(case_dir: Path, scenario: dict, color: str, source_sha: str, bridge_sha: str) -> dict: result = read_json(case_dir / "result.json") @@ -254,10 +476,7 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, isinstance(r.get("path"), str) and type(r.get("status")) is int for r in http_requests), "malformed server HTTP request audit") - attempted_paths = [r["path"] for r in http_requests if r["method"] == "POST"] - ensure(all(r.get("path") in attempted_paths for r in trace - if r.get("event") == "move_attempt"), - "HTTP attempt trace has no underlying request audit") + verify_exact_http_move_audit(http_requests, trace) verify_sources(read_json(case_dir / "source-manifest.json"), source_sha) ensure(ndjson(case_dir / "http-attempts.ndjson") == [r for r in trace if r["event"] == "move_attempt"], "HTTP attempt extract drift") @@ -289,18 +508,39 @@ def validate_experiment(case_dir: Path, scenario: dict, color: str, "bridge process cleanup was not recorded") early_rate_requests = [] if scenario["action"] == "rate_limit_429": + ensure(result.get("observation") == "bounded_postfault_observation" + and type(result.get("postfault_observed_seconds")) in (int, float) + and result["postfault_observed_seconds"] >= 61, + "incomplete R10 post-fault observation") + activation_row = next((r for r in trace if + r.get("event") == "fault_activated" and + r.get("action") == "rate_limit_429"), None) + complete_rows = [r for r in trace if + r.get("event") == "postfault_window_complete" + and r.get("action") == "rate_limit_429"] + ensure(activation_row is not None and len(complete_rows) == 1 + and type(activation_row.get("monotonic_ns")) is int + and type(complete_rows[0].get("monotonic_ns")) is int + and complete_rows[0]["monotonic_ns"] - + activation_row["monotonic_ns"] >= 61_000_000_000, + "R10 server trace lacks 61 seconds after rate-limit fault") responses = [r for r in http_requests if r.get("status") == 429] ensure(len(responses) == 1, "missing or repeated authoritative 429") when = responses[0]["monotonic_ms"] rate_index = http_requests.index(responses[0]) early_rate_requests = [r for r in http_requests[rate_index + 1:] if 0 <= r["monotonic_ms"] - when < 60000] - unusable_moves = invalid_move_submissions(http_requests) + ensure(isinstance(result.get("runner_errors"), list) and not result["runner_errors"], + "fault harness recorded execution or teardown errors") + unusable_moves = (invalid_move_submissions(http_requests) + + history["illegal_attempts"] + + history["stale_retry_attempts"] + + unexpected_raw_targets(http_requests) + + unexpected_statuses(http_requests,trace,scenario["action"])) if scenario["action"] == "none": ensure(history["bot_commits"] > 0 and logging is not None, "normal engine baseline did not play") - ensure(not unusable_moves, "normal game submitted invalid/null engine move") - status = "SAFE_CONTINUATION" + status = "UNSAFE_WITNESS" if unusable_moves else "SAFE_CONTINUATION" elif game["forbidden_attempts"] or early_rate_requests or unusable_moves: status = "UNSAFE_WITNESS" elif game.get("complete") is True: @@ -327,6 +567,8 @@ def qualify(evidence: Path, source_sha: str) -> dict: actual = subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip() ensure(actual == source_sha, "validator checkout is not exact source commit") + verify_profile("online-recovery-v1") + verify_committed_bytes(source_sha, policy["attested_source_paths"]) bridge = read_json(ROOT / "deploy/lichess/bridge.lock.json")["commit"] scenarios = load_scenarios(ROOT / policy["scenario_source"]) required = policy["required_integration_scenarios"] @@ -385,6 +627,8 @@ def main() -> int: "public_bot_release": False, "release_blocked": True, } + report=sanitize_report(report) + verify_report(report,"online4a") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, sort_keys=True, indent=2, allow_nan=False) + "\n") print(json.dumps({k: v for k, v in report.items() if k != "scenario_results"}, diff --git a/scripts/qualify-release-aggregate.py b/scripts/qualify-release-aggregate.py index 408a959f..78f40fcc 100644 --- a/scripts/qualify-release-aggregate.py +++ b/scripts/qualify-release-aggregate.py @@ -7,15 +7,40 @@ """ from __future__ import annotations import argparse +from contextlib import contextmanager import hashlib +import importlib.util import json import re import subprocess +import tempfile from pathlib import Path ROOT=Path(__file__).resolve().parents[1] GIT_SHA1_HEX=re.compile(r"^[0-9a-f]{40}$") SHA256_HEX=re.compile(r"^[0-9a-f]{64}$") +# Declare all sources whose exact bytes can influence the RELEASE-1 verdict. +# The committed stage attests these files independently of the producer's flags. +VERDICT_SOURCE_PATHS=( + "scripts/qualify-release-aggregate.py", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/sanitize_qualification_evidence.py", + "scripts/verify_assurance_contract.py", + "tests/lichess/http_target_contract.py", + "qualification/assurance-contract-v1.json", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "scripts/check-release-scanner.py", + "qualification/release-image-v1.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", +) def is_sha256_hex(value, *, prefixed=False): @@ -45,7 +70,72 @@ def require(ok,reason): raise ValueError(reason) -def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, +def load_verified_module(root: Path, relative: str, namespace: str): + """Load only from an isolated private snapshot of committed source bytes.""" + module_path=root/relative + spec=importlib.util.spec_from_file_location(namespace,module_path) + require(spec is not None and spec.loader is not None, + "verified RELEASE-1 module loader unavailable") + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@contextmanager +def verified_release_snapshot(root: Path, source_sha: str, manifest: dict): + """Bind every verdict-critical local input to manifest AND Git bytes. + + A mutable checkout with an unchanged HEAD cannot become an accepted + verifier. The modules are loaded from the private verified snapshot, + rather than reopening the original untrusted working-tree paths. + """ + attested=manifest.get("source_attestation") + require(type(attested) is dict, + "RELEASE-1 source attestation absent") + require(type(source_sha) is str and GIT_SHA1_HEX.fullmatch(source_sha), + "RELEASE-1 source commit malformed") + trusted={} + for relative in VERDICT_SOURCE_PATHS: + local=root/relative + require(local.is_file() and not local.is_symlink() and + local.resolve().is_relative_to(root.resolve()), + "RELEASE-1 verdict source missing, linked or outside checkout") + raw=local.read_bytes() + require(isinstance(attested.get(relative),str) and + is_sha256_hex(attested[relative]) and + hashlib.sha256(raw).hexdigest()==attested[relative], + "RELEASE-1 executed source differs from staged attestation") + committed=subprocess.run( + ["git","-C",str(root),"show",source_sha+":"+relative], + capture_output=True,check=False,timeout=12) + require(committed.returncode==0 and committed.stdout==raw, + "RELEASE-1 executed source differs from Git committed bytes") + trusted[relative]=raw + with tempfile.TemporaryDirectory(prefix="allfather-release-verified-") as tmp: + sealed=Path(tmp) + for relative,raw in trusted.items(): + target=sealed/relative + target.parent.mkdir(parents=True,exist_ok=True) + with target.open("xb") as handle: + handle.write(raw) + target.chmod(0o400) + yield sealed + + +def check_smoke_evidence(path:Path, key:str, verifier=None) -> dict: + """Use the exact pre-verified smoke module, never an unchecked path.""" + require(verifier is not None, + "independent smoke verifier was not source-admitted") + return verifier.verify_file(path,key) + + +def allowed_release_http(row: object, case: str, verified_contract=None) -> bool: + require(verified_contract is not None, + "shared RELEASE-1 HTTP verifier was not source-admitted") + return verified_contract.allowed_release_http(row,case) + + +def qualify(*, stage:Path, oci:Path, oci_archive:Path, host:Path, smoke_dir:Path, os_inventory:Path, wheel_inventory:Path, source_sha:str, root:Path=ROOT): source= subprocess.check_output(["git","-C",str(root),"rev-parse","HEAD"], text=True).strip() @@ -66,14 +156,28 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, digest(root/"qualification/release-image-v1.json"), "candidate policy mismatch") require(oci_report.get("integrity_verified") is True and + all(oci_report.get(field) is True for field in ( + "oci_layers_verified","oci_diff_ids_verified", + "oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")) and oci_report.get("public_bot_release") is False and oci_report.get("publisher_signature_verified") is False, - "OCI attestation invalid or makes an unsupported publisher claim") + "OCI attestation or verified application binding invalid") for key in ("oci_image_manifest_digest","oci_config_digest"): require(is_sha256_hex(oci_report.get(key),prefixed=True), "invalid OCI "+key) require(is_sha256_hex(oci_report.get("oci_archive_sha256")), "invalid OCI archive SHA-256 digest") + require(is_sha256_hex(oci_report.get("oci_exported_root_digest"), + prefixed=True) and + oci_report.get("buildkit_digest") == + oci_report.get("oci_exported_root_digest"), + "OCI BuildKit digest differs from selected exported root") + require(oci_archive.is_file() and not oci_archive.is_symlink() and + digest(oci_archive) == oci_report["oci_archive_sha256"], + "OCI archive identity does not match independently verified report") require(host_report["destination_host_qualified"] is False and host_report["test_host_observed"] is True, "host admission attempted to promote CI runner") @@ -84,59 +188,102 @@ def qualify(*, stage:Path, oci:Path, host:Path, smoke_dir:Path, require(qualification["artifact_distribution_authorized"] is False and qualification["release_promotion_authorized"] is False, "promotion gate unexpectedly opened") - tests={} - for key in ("r00-white","r00-black","r02-white","r10-white"): - target=smoke_dir/key/"result.json" - require(target.is_file(), "release-image smoke scenario missing: "+key) - result=load(target) - require(result.get("offline") is True and - result.get("ready") is True and - result.get("runner_errors")==[] and - result.get("public_bot_release") is False, - "release-image smoke failed: "+key) - if key!="r10-white": - require(result["game"]["complete"] is True, - "offline completed-game requirement not met: "+key) - else: - require(result["game"]["complete"] is False, - "rate-limit test unexpectedly marked game as complete") - tests[key]={"passed":True,"accepted_moves":len(result["game"]["accepted"] or []), - "phase_count":len(result["observations"])} - wheel_hash=digest(stage/"wheels/runtime-wheels.lock") - wheel_inv=digest(wheel_inventory) - native_hash=digest(os_inventory) - checked={ - "os_lock":bool(image_policy["os_package_lock_complete"] and - image_policy.get("exact_os_package_inventory_sha256")==native_hash), - "wheel_lock":bool(image_policy["wheel_lock_complete"] and - image_policy.get("exact_runtime_wheel_lock_sha256")==wheel_hash and - image_policy.get("exact_runtime_wheel_inventory_sha256")==wheel_inv), - } - source_contents={ - "schema_version":1,"source_commit":source, - "image_manifest_digest":oci_report["oci_image_manifest_digest"], - "oci_image_config_digest":oci_report["oci_config_digest"], - "oci_archive_sha256":oci_report["oci_archive_sha256"], - "base_image":image_policy["base_image"], - "apt_snapshot":image_policy["apt_snapshot"], - "native_inventory_sha256":native_hash, - "wheel_requirements_sha256":wheel_hash, - "wheel_inventory_sha256":wheel_inv, - "wheel_bytes":runtime_wheels["wheels"], - "smoke":tests,"image_integrity_qualified":True, - "frozen_dependency_lock_qualified":all(checked.values()), - "dependency_lock_checks":checked, - "offline_candidate_qualified":all(checked.values()), - "license_approved":False,"destination_host_qualified":False, - "network_recovery_qualified":False, - "artifact_distribution_authorized":False, - "public_bot_release":False,"release_promotion_authorized":False, - "release_blocked":True, - "blockers":[name for name,ok in checked.items() if not ok] - + ["controller_license_review","source_availability_review", - "destination_host_qualification","live_transport_qualification", - "operator_live_play_approval"], - } + with verified_release_snapshot(root,source_sha,manifest) as sealed: + verified_http=load_verified_module( + sealed,"scripts/release_http_contract.py","release1_verified_http") + verified_smoke=load_verified_module( + sealed,"scripts/qualify-release-smoke.py","release1_verified_smoke") + tests={} + for key in ("r00-white","r00-black","r02-white","r10-white"): + target=smoke_dir/key/"result.json" + require(target.is_file(), "release-image smoke scenario missing: "+key) + result=load(target) + independent=check_smoke_evidence(target,key,verified_smoke) + scenario,color=key.split("-",1) + require(independent.get("independent_qualified") is True and + independent.get("persisted_sqlite_verified") is True and + independent.get("release_blocked") is True and + independent.get("run_id") == key and + independent.get("scenario") == scenario.upper() and + independent.get("color") == color and + independent.get("public_bot_release") is False, + "independent chess/HTTP/SQLite evidence failed: "+key) + require(result.get("offline") is True and + result.get("ready") is True and + result.get("runner_errors")==[] and + result.get("public_bot_release") is False, + "release-image smoke failed: "+key) + game=result.get("game") or {} + require(game.get("forbidden_attempts")==[], + "release image simulator recorded forbidden API attempts: "+key) + requests=result.get("http") + require(isinstance(requests,list), + "release-image complete HTTP audit missing: "+key) + require(all(allowed_release_http(r,key,verified_http) for r in requests), + "release image HTTP contains prohibited request: "+key) + if key=="r10-white": + require(result.get("rate_cooldown_verified") is True, + "release-image 429 cooldown witness absent") + if key=="r02-white": + require(type(result.get("fresh_second_epoch_commits")) is int and + result["fresh_second_epoch_commits"]>0, + "release image restart has no fresh confirmed move") + if key!="r10-white": + require(result["game"]["complete"] is True, + "offline completed-game requirement not met: "+key) + else: + require(result["game"]["complete"] is False, + "rate-limit test unexpectedly marked game as complete") + tests[key]={"passed":True,"independent_evidence":independent, + "accepted_moves":len(result["game"]["accepted"] or []), + "phase_count":len(result["observations"])} + wheel_hash=digest(stage/"wheels/runtime-wheels.lock") + wheel_inv=digest(wheel_inventory) + native_hash=digest(os_inventory) + checked={ + "os_lock":bool(image_policy["os_package_lock_complete"] and + image_policy.get("exact_os_package_inventory_sha256")==native_hash), + "wheel_lock":bool(image_policy["wheel_lock_complete"] and + image_policy.get("exact_runtime_wheel_lock_sha256")==wheel_hash and + image_policy.get("exact_runtime_wheel_inventory_sha256")==wheel_inv), + } + source_contents={ + "schema_version":1,"source_commit":source, + "image_manifest_digest":oci_report["oci_image_manifest_digest"], + "oci_image_config_digest":oci_report["oci_config_digest"], + "oci_archive_sha256":oci_report["oci_archive_sha256"], + "base_image":image_policy["base_image"], + "apt_snapshot":image_policy["apt_snapshot"], + "native_inventory_sha256":native_hash, + "wheel_requirements_sha256":wheel_hash, + "wheel_inventory_sha256":wheel_inv, + "wheel_bytes":runtime_wheels["wheels"], + "smoke":tests, + "oci_evidence":{field:oci_report.get(field) for field in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")}, + "image_integrity_qualified":True, + "frozen_dependency_lock_qualified":all(checked.values()), + "dependency_lock_checks":checked, + # Source and image integrity do not grant host/legal/live authority. + # This deliberately remains false even if dependency pins are filled. + "offline_candidate_qualified":False, + "license_approved":False,"destination_host_qualified":False, + "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "public_bot_release":False,"release_promotion_authorized":False, + "release_blocked":True, + "blockers":[name for name,ok in checked.items() if not ok] + + ["controller_license_review","source_availability_review", + "destination_host_qualification","live_transport_qualification", + "operator_live_play_approval"], + } + verified_claims=load_verified_module( + sealed,"scripts/verify_assurance_contract.py","release1_verified_claims") + verified_claims.verify_report(source_contents,"release_aggregate") return source_contents @@ -144,6 +291,7 @@ def main(): p=argparse.ArgumentParser() p.add_argument("--stage",type=Path,required=True) p.add_argument("--oci",type=Path,required=True) + p.add_argument("--oci-archive",type=Path,required=True) p.add_argument("--host",type=Path,required=True) p.add_argument("--smoke-dir",type=Path,required=True) p.add_argument("--os-inventory",type=Path,required=True) @@ -152,7 +300,7 @@ def main(): p.add_argument("--output",type=Path,required=True) a=p.parse_args() try: - report=qualify(stage=a.stage,oci=a.oci,host=a.host, + report=qualify(stage=a.stage,oci=a.oci,oci_archive=a.oci_archive,host=a.host, smoke_dir=a.smoke_dir,os_inventory=a.os_inventory, wheel_inventory=a.wheel_inventory,source_sha=a.source_sha) a.output.parent.mkdir(parents=True,exist_ok=True) @@ -160,7 +308,9 @@ def main(): print(json.dumps({k:v for k,v in report.items() if k not in ("wheel_bytes","smoke")}, sort_keys=True)) except (OSError,ValueError,TypeError,KeyError,subprocess.SubprocessError) as exc: - p.exit(2,"RELEASE-1 aggregate verification refused: "+str(exc)+"\n") + # Failures are logged, but untrusted evidence must not appear in logs. + p.exit(2,"RELEASE-1 aggregate verification refused: "+ + type(exc).__name__+"\n") if __name__=="__main__": diff --git a/scripts/qualify-release-smoke.py b/scripts/qualify-release-smoke.py new file mode 100644 index 00000000..f5e4bd38 --- /dev/null +++ b/scripts/qualify-release-smoke.py @@ -0,0 +1,591 @@ +#!/usr/bin/env python3 +"""RELEASE-1 independent host-side validation of packaged offline game evidence. + +Never trust the packaged producer's ready/confirmed/cooldown flags as proofs. +Reconstruct each attempted HTTP move against the authoritative trace and +durable journal. No network, credentials, bot authorization, or public release. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import shutil +import sqlite3 +import stat +import tempfile +from pathlib import Path +from urllib.parse import unquote + +import chess +# This is invoked as a standalone host-side script; do not rely on the repo +# root or its optional package path being on sys.path. +import importlib.util as _safe_importlib +_safe_spec = _safe_importlib.spec_from_file_location( + "release1_sanitized_report", + Path(__file__).with_name("sanitize_qualification_evidence.py")) +if _safe_spec is None or _safe_spec.loader is None: + raise ValueError("missing safe release report serializer") +_safe_module = _safe_importlib.module_from_spec(_safe_spec) +_safe_spec.loader.exec_module(_safe_module) +sanitize_report = _safe_module.sanitize_report +_claim_spec = _safe_importlib.spec_from_file_location( + "release1_assurance_claims", + Path(__file__).with_name("verify_assurance_contract.py")) +if _claim_spec is None or _claim_spec.loader is None: + raise ValueError("missing independent release assurance check") +_claim_module = _safe_importlib.module_from_spec(_claim_spec) +_claim_spec.loader.exec_module(_claim_module) +verify_claims = _claim_module.verify_report + +CASES = {"r00-white": ("R00", "white"), + "r00-black": ("R00", "black"), + "r02-white": ("R02", "white"), + "r10-white": ("R10", "white")} +GAME_ID = "off3b001" +MOVE_PREFIX = f"/api/bot/game/{GAME_ID}/move/" +ALLOWED_POSTS = {"/api/token/test", f"/api/challenge/{GAME_ID}/accept", + f"/api/challenge/{GAME_ID}/decline"} +MAX_EVIDENCE_BYTES = 16 * 1024 * 1024 + + +# The same host-only HTTP admission contract is used by the downstream +# RELEASE-1 aggregate; neither verifier trusts a producer success bit. +import importlib.util as _http_importlib +_http_spec = _http_importlib.spec_from_file_location( + "release1_http_contract", Path(__file__).with_name("release_http_contract.py")) +require_spec = _http_spec is not None and _http_spec.loader is not None +if not require_spec: + raise ValueError("independent HTTP contract module missing") +http_contract = _http_importlib.module_from_spec(_http_spec) +_http_spec.loader.exec_module(http_contract) +ALLOWED_POSTS = http_contract.ALLOWED_POSTS +MOVE_PREFIX = http_contract.MOVE_PREFIX + + +def require(value, reason): + if not value: + raise ValueError(reason) + + +def finite_number(value) -> bool: + """Reject SQLite TEXT deadlines, booleans, NaN and infinite numbers.""" + if type(value) not in (int, float): + return False + try: + return math.isfinite(value) + except (OverflowError, ValueError): + return False + + +def trace_bytes(value) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + allow_nan=False).encode("utf-8") + + +def verify_smoke(doc: dict, key: str) -> dict: + require(key in CASES, "unapproved offline smoke identity") + scenario, color = CASES[key] + require(type(doc) is dict and doc.get("schema_version") == 2 and + doc.get("run_id") == key and doc.get("scenario") == scenario and + doc.get("color") == color and doc.get("offline") is True and + doc.get("public_bot_release") is False and + doc.get("ready") is True and + doc.get("runner_errors") == [], + "packaged offline smoke has invalid source/disposition identity") + game = doc.get("game") + requests = doc.get("http") + trace = doc.get("transport_trace") + journal = doc.get("journal") + obs = doc.get("observations") + require(all(type(x) is dict for x in (game, journal)) and + type(requests) is list and type(trace) is list and bool(trace) and + type(obs) is list and bool(obs), + "packaged smoke is missing authoritative evidence") + require(game.get("game_id") == GAME_ID and game.get("color") == color and + isinstance(game.get("moves"), list) and + isinstance(game.get("accepted"), list) and + game.get("forbidden_attempts") == [], + "invalid authoritative game or forbidden endpoint evidence") + require(doc.get("transport_trace_sha256") == + hashlib.sha256(trace_bytes(trace)).hexdigest(), + "ordered transport trace digest mismatch") + require(all(type(t) is dict and type(t.get("seq")) is int and + type(t.get("monotonic_ns")) is int + for t in trace) and + [t["seq"] for t in trace] == list(range(1, len(trace) + 1)) and + all(trace[i]["monotonic_ns"] <= trace[i+1]["monotonic_ns"] + for i in range(len(trace)-1)), + "transport ordering or monotonic observation invalid") + require(type(journal.get("intents")) is list and + type(journal.get("games")) is list and + isinstance(journal.get("owner_epoch"), str), + "durable journal incomplete") + + attempt_index = {} + commits = [] + authoritative = [] + board = chess.Board() + seen_ply = set() + history_at_attempt = {} + for row in trace: + event = row.get("event") + if event == "move_attempt": + ply = len(board.move_stack) + rid = f"request-{row['seq']}" + require(row.get("game_id") == GAME_ID and + type(row.get("ply_at_receipt")) is int and + row["ply_at_receipt"] == ply and + board.turn == (color == "white") and ply not in seen_ply, + "HTTP move attempted for wrong game/turn/ply or repeated intent") + uci = row.get("uci") + try: + move = chess.Move.from_uci(uci) + except (TypeError, ValueError) as exc: + raise ValueError("malformed attempted UCI move") from exc + require(move != chess.Move.null() and move in board.legal_moves and + move.uci() == uci and row.get("path") == MOVE_PREFIX + uci, + "position-illegal, null or noncanonical HTTP move submission") + seen_ply.add(ply) + attempt_index[rid] = row + history_at_attempt[rid] = [m.uci() for m in board.move_stack] + elif event == "board_commit": + require(row.get("game_id") == GAME_ID and + type(row.get("ply_before")) is int and + row["ply_before"] == len(board.move_stack) and + row.get("fen_before") == board.fen(), + "authoritative commit before-position mismatch") + try: + move = chess.Move.from_uci(row.get("uci")) + except (TypeError, ValueError) as exc: + raise ValueError("malformed authoritative chess commit") from exc + require(move in board.legal_moves, + "illegal authoritative board commit") + origin = row.get("origin") + expected_origin = ("allfather" if + board.turn == (color == "white") else + "scripted_opponent") + require(origin == expected_origin, + "committed move actor differs from authoritative side to move") + if origin == "allfather": + rid = row.get("request_id") + prior = attempt_index.get(rid) + require(prior is not None and prior.get("uci") == row["uci"] and + prior.get("ply_at_receipt") == row["ply_before"] and + prior["seq"] < row["seq"], + "bot server commit lacks exact earlier HTTP move attempt") + else: + require(row.get("request_id") is None, + "scripted opponent has bot request identity") + board.push(move) + require(row.get("ply_after") == len(board.move_stack) and + row.get("fen_after") == board.fen(), + "authoritative commit after-position mismatch") + commits.append(row) + authoritative.append(move.uci()) + + require(authoritative == game["moves"] and + [dict(ply=x["ply_after"], uci=x["uci"]) for x in commits + if x["origin"] == "allfather"] == game["accepted"] and + doc.get("commits") == commits, + "authoritative history/accepted moves/commit extract disagree") + terminal = board.outcome(claim_draw=False) + if scenario == "R10": + require(game.get("complete") is False and terminal is None, + "429 suspension fabricated a completed game") + else: + require(game.get("complete") is True and terminal is not None and + isinstance(game.get("outcome"), dict) and + game["outcome"].get("termination") == terminal.termination.name, + "completed game lacks independent rules-based termination") + + http_attempts = {} + for request in requests: + require(type(request) is dict and + request.get("method") in ("GET", "POST") and + type(request.get("status")) is int and + type(request.get("monotonic_ms")) is int and + request["monotonic_ms"] >= 0 and + isinstance(request.get("path"), str) and + request["status"] != 403, + "unexpected or malformed HTTP request evidence") + require(http_contract.allowed_release_http(request, key), + "unapproved GET or POST endpoint/status in HTTP audit") + if request["method"] != "POST": + continue + path = request["path"] + if path.startswith(MOVE_PREFIX): + rid = request.get("request_id") + require(isinstance(rid, str) and rid in attempt_index and + rid not in http_attempts and + attempt_index[rid].get("path") == path and + request["status"] in ((200, 429) if scenario == "R10" else (200,)), + "unexpected, duplicate or rejected HTTP move request") + http_attempts[rid] = request + else: + require(path in ALLOWED_POSTS and request["status"] == 200 and + request.get("request_id") is None, + "unsupported non-move POST or rejection") + require(set(http_attempts) == set(attempt_index), + "HTTP request audit omits or duplicates trace move attempts") + require(all(type(a.get("status")) is int for a in http_attempts.values()), + "unrecorded HTTP move status") + for row in trace: + if (row.get("event") not in + ("response_write_completed", "connection_close_requested") or + not isinstance(row.get("path"), str) or + not row["path"].startswith(MOVE_PREFIX)): + continue + rid = row.get("request_id") + require(rid in attempt_index and + row["path"] == attempt_index[rid]["path"] and + row["seq"] > attempt_index[rid]["seq"], + "move response/close event lacks exact request correlation") + if row["event"] == "response_write_completed": + require(type(row.get("status")) is int and + row["status"] == http_attempts[rid]["status"], + "completed move response status differs from HTTP audit") + + intents = journal["intents"] + require(all(isinstance(i, dict) for i in intents), "malformed durable intent") + by_ply = {} + for intent in intents: + require(intent.get("game_id") == GAME_ID and + type(intent.get("ply_before")) is int and + isinstance(intent.get("move"), str) and + isinstance(intent.get("epoch"), str), + "durable intent has invalid identity") + key_intent = intent["ply_before"] + require(key_intent not in by_ply, "duplicate durable intent at same game ply") + by_ply[key_intent] = intent + require(0 <= key_intent <= len(authoritative) and + json.loads(intent["history_json"]) == authoritative[:key_intent], + "durable intent history differs from authoritative game prefix") + guarded = doc.get("guard_events") + require(type(guarded) is list, "missing guarded submission audit") + guarded_submissions = [g for g in guarded + if isinstance(g, dict) and + g.get("event") == "http_move_submission_attempt"] + require(len(guarded_submissions) == len(attempt_index), + "guarded HTTP submission log does not cover exact requests") + for rid, attempt in attempt_index.items(): + ply = attempt["ply_at_receipt"] + intent = by_ply.get(ply) + require(intent is not None and intent.get("move") == attempt["uci"] and + json.loads(intent["history_json"]) == history_at_attempt[rid] and + len([g for g in guarded_submissions + if g.get("game_id") == GAME_ID and + g.get("ply") == ply and g.get("uci") == attempt["uci"] and + g.get("owner_epoch") == intent["epoch"]]) == 1, + "move attempt lacks unique matching journal/guard authorization") + for commit in commits: + if commit["origin"] != "allfather": + continue + intent = by_ply.get(commit["ply_before"]) + require(intent is not None and + intent.get("move") == commit["uci"] and + intent.get("status") == "CONFIRMED", + "server-accepted move was not durably confirmed") + records = [g for g in journal["games"] + if isinstance(g, dict) and g.get("game_id") == GAME_ID] + require(len(records) == 1, "authoritative game missing from journal") + stored = json.loads(records[0]["history_json"]) + require(stored == authoritative if scenario != "R10" + else stored == authoritative[:len(stored)], + "durable game history diverges from server") + phases = obs + require(all(isinstance(p, dict) and isinstance(p.get("epoch"), str) + and type(p.get("phase")) is int for p in phases) and + journal["owner_epoch"] == phases[-1]["epoch"], + "phase/journal ownership epoch mismatch") + + fault = doc.get("fault") or {} + activations = fault.get("activations") + require(isinstance(activations, list), "scenario fault activations missing") + fault_events = [r for r in trace if r.get("event") == "fault_activated"] + if scenario == "R00": + require(len(phases) == 1 and len(activations) == 0 + and not fault_events and + phases[0].get("observation") == "complete_confirmed", + "normal image game had unexpected fault or incomplete confirmation") + else: + action = "commit_then_close" if scenario == "R02" else "rate_limit_429" + require(len(activations) == len(fault_events) == 1 and + activations[0].get("action") == action and + fault_events[0].get("action") == action and + activations[0].get("request_id") == fault_events[0].get("request_id"), + "fault activation lacks exact independent server trace") + rid = fault_events[0].get("request_id") + require(rid in attempt_index, "fault targeted unobserved HTTP move attempt") + if scenario == "R02": + closed = [r for r in trace if + r.get("event") == "connection_close_requested" + and r.get("request_id") == rid and + r.get("path") == attempt_index[rid]["path"] and + r["seq"] > fault_events[0]["seq"]] + all_closes = [r for r in trace if + r.get("event") == "connection_close_requested" and + r.get("path") == attempt_index[rid]["path"]] + completed_responses = [r for r in trace if + r.get("event") == "response_write_completed" and + r.get("request_id") == rid] + commits_for_request = [r for r in commits if + r.get("request_id") == rid] + first_epoch = phases[0]["epoch"] if phases else None + unknown_ack = [g for g in guarded if isinstance(g, dict) and + g.get("event") == "http_unknown_ack" and + g.get("game_id") == GAME_ID and + g.get("owner_epoch") == first_epoch] + acknowledged = [g for g in guarded if isinstance(g, dict) and + g.get("event") == "http_move_ack_observed" and + g.get("game_id") == GAME_ID and + g.get("owner_epoch") == first_epoch and + g.get("move") == attempt_index[rid]["uci"]] + first_submissions = [g for g in guarded_submissions if + g.get("game_id") == GAME_ID and + g.get("owner_epoch") == first_epoch] + require(len(closed) == len(all_closes) == + len(commits_for_request) == len(unknown_ack) == + len(first_submissions) == 1 and + unknown_ack[0].get("endpoint") == "move" and + not completed_responses and not acknowledged and + commits_for_request[0]["seq"] < closed[0]["seq"] and + attempt_index[rid]["seq"] < fault_events[0]["seq"] < + commits_for_request[0]["seq"] and + len(phases) == 2 and + phases[0].get("observation") == "restart_after_lost_ack" and + phases[1].get("observation") == "complete_confirmed" and + phases[0]["epoch"] != phases[1]["epoch"] and + any(i.get("epoch") == phases[1]["epoch"] and + i.get("status") == "CONFIRMED" and + i.get("ply_before") in {c["ply_before"] for c in commits + if c["origin"] == "allfather"} for i in intents), + "lost-ack restart lacks new-epoch authoritative confirmation") + else: + limited = [(ident, r) for ident, r in http_attempts.items() + if r["status"] == 429] + require(len(limited) == 1 and limited[0][0] == rid and + not any(c.get("request_id") == rid for c in commits), + "429 fault does not match one uncommitted HTTP attempt") + first = limited[0][1]["monotonic_ms"] + require(not any(0 <= r["monotonic_ms"] - first < 60000 + for r in requests if r is not limited[0][1]) and + type(journal.get("cooldown")) is list and bool(journal["cooldown"]) and + len(phases) == 1 and + phases[0].get("observation") == "rate_limited_suspension", + "rate-limit suspension missed account-wide 60-second cooldown") + # A server-side 429 is not evidence that the client received it. + # Require the matching HTTP response *and* the guarded transport's + # own observed-rate-limit audit before granting a cooldown witness. + completed_429 = [r for r in trace if + r.get("event") == "response_write_completed" and + r.get("request_id") == rid] + first_epoch = phases[0]["epoch"] + rate_blocks = [g for g in guarded if + isinstance(g, dict) and + g.get("event") == "global_rate_limit_block"] + require(len(completed_429) == 1 and + completed_429[0].get("status") == 429 and + completed_429[0].get("path") == attempt_index[rid]["path"] and + fault_events[0]["seq"] < completed_429[0]["seq"], + "R10 targeted 429 response was not completed") + require(len(rate_blocks) == 1 and + rate_blocks[0].get("owner_epoch") == first_epoch and + rate_blocks[0].get("endpoint") == "move" and + type(rate_blocks[0].get("status")) is int and + rate_blocks[0]["status"] == 429, + "R10 client guard did not observe the move 429") + target_intent = by_ply[attempt_index[rid]["ply_at_receipt"]] + require(target_intent.get("status") == "UNKNOWN_ACK" and + len(journal["cooldown"]) == 1 and + isinstance(journal["cooldown"][0], dict) and + journal["cooldown"][0].get("cause") == "http429:move", + "R10 durable 429 intent/cooldown evidence invalid") + cooldown_row = journal["cooldown"][0] + require(type(cooldown_row.get("singleton")) is int and + cooldown_row["singleton"] == 1 and + finite_number(cooldown_row.get("until_wall")), + "R10 persisted cooldown deadline must be finite and numeric") + durable_events = journal.get("events") + require(type(durable_events) is list and + all(isinstance(e, dict) for e in durable_events), + "R10 persisted cooldown audit events absent") + cooldown_events = [e for e in durable_events + if e.get("event") == "cooldown"] + require(len(cooldown_events) == 1, + "R10 durable cooldown event missing or duplicated") + observed_event = cooldown_events[0] + require(observed_event.get("epoch") == first_epoch and + observed_event.get("game_id") is None and + finite_number(observed_event.get("ts")) and + observed_event["ts"] > 0 and + isinstance(observed_event.get("details"), str), + "R10 durable cooldown event identity/time invalid") + details = json.loads(observed_event["details"]) + require(isinstance(details, dict) and + details.get("cause") == "http429:move" and + finite_number(details.get("until_wall")) and + details["until_wall"] == cooldown_row["until_wall"], + "R10 durable cooldown event/details disagree with persisted deadline") + # MoveJournal samples time.time before the transaction and writes + # the cooldown event just after storing the deadline. A one-second + # tolerance accounts for that write, without allowing an arbitrary + # stale or unbounded persistent suspension. + remaining_at_event = cooldown_row["until_wall"] - observed_event["ts"] + require(59.0 <= remaining_at_event <= 86400.0, + "R10 persisted cooldown outside the 60-86400 second contract") + require(finite_number(rate_blocks[0].get("timestamp")) and + observed_event["ts"] - 1 <= rate_blocks[0]["timestamp"] and + rate_blocks[0]["timestamp"] <= observed_event["ts"] + 60 and + rate_blocks[0]["timestamp"] <= cooldown_row["until_wall"], + "R10 guard observation is not aligned with durable cooldown") + hold = [r for r in trace if + r.get("event") == "rate_limit_hold_completed" and + r.get("action") == "rate_limit_429"] + require(len(hold) == 1 and + hold[0].get("request_id") == rid and + completed_429[0]["seq"] < hold[0]["seq"] and + hold[0]["monotonic_ns"] - completed_429[0]["monotonic_ns"] + >= 61_000_000_000, + "R10 post-429 observation did not last 61 seconds") + + return {"schema_version": 1, "scenario": scenario, "color": color, + "run_id": key, "independent_qualified": True, + "legal_unique_attempts": len(attempt_index), + "authoritative_commits": len(commits), + "public_bot_release": False, "release_blocked": True} + + +def verify_persisted_sqlite(folder: Path, journal: dict) -> dict: + """Verify SQLite/WAL bytes from the producer without altering that volume. + + The installed-image process owns the persistent directory (UID 10001). + The GitHub runner can read it after chmod a+rX, but must not write there. + Opening a WAL database read-only can nevertheless need to create -shm. + Read the stopped producer's database + WAL into a private writable staging + directory, then open *that copy* with SQLite mode=ro (NOT immutable=1). + Discard its rebuilt shared-memory sidecar after qualification. + """ + state = folder / "state" + db = state / "move-journal.sqlite3" + require(folder.is_dir() and not folder.is_symlink() and + not folder.parent.is_symlink() and + state.is_dir() and not state.is_symlink() and + db.is_file() and not db.is_symlink(), + "persisted SQLite journal absent or linked") + require(type(journal) is dict, "persisted SQLite snapshot absent") + + def source(path: Path, *, required: bool) -> bool: + require(not path.is_symlink(), "persisted SQLite sidecar cannot be a symlink") + if not path.exists(): + require(not required, "persisted SQLite database missing") + return False + mode = path.stat() + require(stat.S_ISREG(mode.st_mode) and + (mode.st_size > 0 if required else mode.st_size >= 0) and + mode.st_size <= 256 * 1024 * 1024, + "persisted SQLite database/sidecar invalid or oversized") + return True + + source(db, required=True) + wal = state / "move-journal.sqlite3-wal" + shm = state / "move-journal.sqlite3-shm" + has_wal = source(wal, required=False) + # The source SHM is disposable indexing state, not authoritative bytes. + # Never follow a linked SHM or reuse it in the private directory. + if shm.exists() or shm.is_symlink(): + source(shm, required=False) + + with tempfile.TemporaryDirectory(prefix="release1-sqlite-verify-") as scratch: + staged = Path(scratch) / "move-journal.sqlite3" + shutil.copyfile(db, staged) + if has_wal: + shutil.copyfile(wal, Path(scratch) / "move-journal.sqlite3-wal") + # Permit SQLite to generate its own -shm only inside scratch. Source + # volume remains read-only, including when the original had live WAL. + connection = sqlite3.connect( + staged.as_uri() + "?mode=ro", + uri=True, isolation_level=None, timeout=5) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + try: + require([tuple(r) for r in connection.execute( + "PRAGMA integrity_check")] == [("ok",)], + "persisted SQLite integrity check failed") + owner = connection.execute( + "SELECT epoch FROM owner WHERE singleton=1").fetchone() + require(owner is not None and + owner["epoch"] == journal.get("owner_epoch"), + "SQLite owner epoch differs from JSON snapshot") + queries = { + "games": "SELECT * FROM games ORDER BY game_id", + "intents": "SELECT * FROM intents ORDER BY game_id,ply_before", + "cooldown": "SELECT * FROM cooldown ORDER BY singleton", + "events": "SELECT * FROM events ORDER BY event_id", + } + observed = { + name: [dict(row) for row in connection.execute(query)] + for name, query in queries.items() + } + for name, rows in observed.items(): + require(type(journal.get(name)) is list and + rows == journal[name], + "SQLite persisted " + name + + " differs from JSON snapshot") + finally: + connection.execute("ROLLBACK") + finally: + connection.close() + return {"persisted_sqlite_verified": True, + "durable_events": len(observed["events"]), + "durable_intents": len(observed["intents"])} + + +def verify_file(path: Path, key: str) -> dict: + require(path.is_file() and not path.is_symlink() and + path.stat().st_size <= MAX_EVIDENCE_BYTES, + "packaged smoke evidence absent, linked or oversized") + doc = json.loads(path.read_text(encoding="utf-8")) + require(type(doc) is dict and type(doc.get("journal")) is dict, + "packaged smoke durable journal snapshot missing") + persisted = verify_persisted_sqlite(path.parent, doc["journal"]) + verdict = verify_smoke(doc, key) + verdict.update(persisted) + return verdict + + +def qualify_directory(root: Path) -> dict: + results, failures = {}, [] + for key in CASES: + try: + results[key] = verify_file(root / key / "result.json", key) + except (OSError, ValueError, KeyError, TypeError, IndexError, + sqlite3.DatabaseError) as exc: + failures.append({"scenario": key, + "error": type(exc).__name__ + ": " + str(exc)}) + return {"schema_version": 1, "offline_smoke_qualified": not failures and + len(results) == len(CASES), + "verified_cases": results, "failures": failures, + "public_bot_release": False, "release_blocked": True} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--smoke-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + report = sanitize_report(qualify_directory(args.smoke_dir)) + verify_claims(report, "release_smoke") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + print(json.dumps(report, sort_keys=True)) + return 0 if report["offline_smoke_qualified"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release-promotion-policy.py b/scripts/release-promotion-policy.py index c478b486..6f786689 100644 --- a/scripts/release-promotion-policy.py +++ b/scripts/release-promotion-policy.py @@ -11,9 +11,9 @@ def audit(root=ROOT): license=json.loads((root/"qualification/license-compliance-v1.json").read_text()) qual=json.loads((root/"qualification/release-qualification-v1.json").read_text()) for doc,fields in ( - (image,("allow_real_credentials","public_bot_release","network_recovery_qualified","deployment_host_qualified")), + (image,("allow_real_credentials","public_bot_release","network_recovery_qualified","deployment_host_qualified","playing_strength_qualified")), (license,("artifact_distribution_authorized","controller_distribution_license_approved","legal_review_completed")), - (qual,("public_bot_release","artifact_distribution_authorized","release_promotion_authorized","live_transport_qualified","license_compliance_approved")), + (qual,("public_bot_release","artifact_distribution_authorized","release_promotion_authorized","live_transport_qualified","license_compliance_approved","authenticated_publication_authorized","destination_host_qualified")), ): for field in fields: if doc.get(field) is not False: diff --git a/scripts/release_http_contract.py b/scripts/release_http_contract.py new file mode 100644 index 00000000..13e4b080 --- /dev/null +++ b/scripts/release_http_contract.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Authoritative RELEASE-1 HTTP request-evidence shape, shared by two gates. + +The fake server enforces these endpoints. This module only verifies recorded +HTTP method/path/status/request ID. Independent host verification separately +reconstructs chess legality, receipt timing, epochs and durable intent. +""" +from __future__ import annotations +import re +import importlib.util +from pathlib import Path + +# Resolve the single approved policy by file identity. RELEASE-1's standalone +# host verifier runs as a script, often with sys.path[0] = /repo/scripts; +# importing a top-level "tests" package then depends on CWD/PYTHONPATH. +_POLICY = Path(__file__).resolve().parents[1] / "tests/lichess/http_target_contract.py" +_spec = importlib.util.spec_from_file_location("release1_http_target_policy", _POLICY) +if _spec is None or _spec.loader is None: + raise ValueError("RELEASE-1 HTTP target policy not available") +policy = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(policy) + +GAME_ID = policy.GAME_ID +MOVE_PREFIX = policy.MOVE_PREFIX +APPROVED_GETS = policy.APPROVED_GETS +APPROVED_POSTS = policy.APPROVED_POSTS +UCI = policy.UCI +canonical_target = policy.canonical_target + +ALLOWED_GETS = APPROVED_GETS +ALLOWED_POSTS = APPROVED_POSTS +UCI_TOKEN = UCI + +def allowed_release_http(row: object, key: str) -> bool: + """No implicit wildcard GET and no successful forbidden HTTP response.""" + if (type(row) is not dict or key not in + ("r00-white", "r00-black", "r02-white", "r10-white")): + return False + method = row.get("method") + path = row.get("path") + status = row.get("status") + rid = row.get("request_id") + target = row.get("request_target") + if (method not in ("GET", "POST") or + type(path) is not str or type(status) is not int or + row.get("target_canonical") is not True or + not canonical_target(method, target) or + path != target.split("?", 1)[0]): + return False + if method == "GET": + return status == 200 and rid is None + if path in ALLOWED_POSTS: + return status == 200 and rid is None + if not path.startswith(MOVE_PREFIX): + return False + uci = path[len(MOVE_PREFIX):] + return (UCI_TOKEN.fullmatch(uci) is not None and + len(uci) in (4, 5) and + isinstance(rid, str) and bool(rid.strip()) and + status in ((200, 429) if key == "r10-white" else (200,))) diff --git a/scripts/sanitize_qualification_evidence.py b/scripts/sanitize_qualification_evidence.py new file mode 100644 index 00000000..288948c5 --- /dev/null +++ b/scripts/sanitize_qualification_evidence.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Single bounded output boundary for offline qualification evidence. + +All producer-controlled strings are untrusted, including values received +under apparently safe keys. Never emit arbitrary producer request targets, +exception descriptions, free-form filenames or values into CI artifacts. +""" +from __future__ import annotations +import json +import math +import re + +ACTIONS=frozenset({ + "none","drop_before_commit","commit_then_close","commit_then_504", + "duplicate_move","delay_state","duplicate_state","stale_state", + "partial_chunk","replace_event_stream","rate_limit_429","wrong_game", + "low_clock_close","terminate_bridge","duplicate_terminal", +}) +SCENARIOS=frozenset(f"R{i:02d}" for i in range(15)) +BEHAVIORS=frozenset({ + "SAFE_CONTINUATION","UNSAFE_WITNESS","OBSERVED_COMPLETION", + "OBSERVED_STOP_OR_UNRESOLVED","NORMAL_COMPLETION","RECOVERED", + "SAFE_SUSPENSION","INCONCLUSIVE","OFFLINE_SMOKE_PASS", + "OFFLINE_SMOKE_FAIL","CONFIRMED","UNKNOWN_ACK", +}) +SAFE_ENUM_VALUES={ + "status":BEHAVIORS, + "behavior":BEHAVIORS, + "scenario":SCENARIOS, + "color":frozenset(("white","black")), + "origin":frozenset(("allfather","scripted_opponent")), + "method":frozenset(("GET","POST")), + "http_method":frozenset(("GET","POST")), + "action":ACTIONS, + "endpoint":frozenset(("move","move_post","game_state","event_stream", + "terminal_state","account","token","challenge")), + "reason":frozenset(("noncanonical_request_target", + "unapproved_http_status", + "position_illegal_or_wrong_turn", + "repeated_move_submission_same_ply", + "once_legal_retry_after_lost_ack", + "duplicate_unconfirmed_move_submission")), + "observation":frozenset(("complete_confirmed","restart_after_lost_ack", + "rate_limited_suspension","fault_triggered_bridge_kill", + "bounded_postfault_observation","not_started", + "experiment_timeout","game_stream_observed", + "injected_bridge_kill","server_game_complete", + "terminal_reconciliation_timeout", + "restart_requested_after_fault", + "unresolved_precommit_contained", + "rate_limit_containment_window", + "invalid_move_containment_window", + "bridge_exited","bounded_restarted_game", + "bounded_experiment_timeout","phase_error", + "launching")), + "termination":frozenset(("CHECKMATE","STALEMATE", + "INSUFFICIENT_MATERIAL","SEVENTYFIVE_MOVES", + "FIVEFOLD_REPETITION","VARIANT_WIN", + "VARIANT_LOSS","VARIANT_DRAW")), + "cause":frozenset(("http429:move","quiesce")), + "scope":frozenset(("offline-fault-harness-only", + "offline-guarded-bridge-only")), + "mode":frozenset(("OFFLINE-4B-ONLY","OFFLINE-ONLY")), + "release_kind":frozenset(("ONLINE-3A-OFFLINE-ONLY",)), + "event":frozenset(("move_attempt","board_commit","fault_activated", + "http_move_submission_attempt","http_unknown_ack", + "http_move_ack_observed","global_rate_limit_block", + "rate_limit_hold_completed","response_write_completed", + "connection_close_requested","stream_emit", + "stream_partial_chunk","cooldown","subscribe", + "position_suspended","authoritative_snapshot_observed", + "unavailable_or_illegal_uci_move","move_prepare_refused")), +} +SAFE_ENUM_FIELDS=frozenset(SAFE_ENUM_VALUES) +HASH_FIELDS=frozenset({ + "source_commit","bridge_commit","source_sha","sha256","policy_sha256", + "scenario_source_sha256","sanitized_sha256","manifest_sha256", + "position_id","owner_epoch","epoch","request_target_sha256", +}) +SAFE_KEYS=re.compile(r"[a-z][A-Za-z0-9_-]{0,79}\Z") +SAFE_ENUM=re.compile(r"[A-Za-z][A-Za-z0-9_.:-]{0,95}\Z") +SAFE_MOVE=re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") +SAFE_ID=re.compile(r"[A-Fa-f0-9]{16,128}\Z") +# Publish only complete approved endpoints, never unrecognized path suffixes. +SAFE_PUBLIC_PATHS=frozenset({ + "/api/stream/event", "/api/bot/game/stream/off3b001", + "/api/account", "/api/account/playing", "/api/users/status", + "/game/export/off3b001", "/api/token/test", + "/api/challenge/off3b001/accept", "/api/challenge/off3b001/decline", +}) +SAFE_MOVE_PATH=re.compile( + r"/api/bot/game/off3b001/move/[a-h][1-8][a-h][1-8][qrbn]?\Z") +SAFE_REQUEST=re.compile(r"(?:request|stream)-\d{1,12}\Z") +MAX_DEPTH=22 +MAX_SEQUENCE=2000 + + +def safe_path(value: object) -> str: + """Retain only approved whole endpoints or one fixed sentinel. + + Stripping a query is not enough to sanitize an invalid move suffix. + Chess legality remains a separate independent-verification concern. + """ + if type(value) is not str: + return "__invalid_path__" + path=value.split("?",1)[0].split("#",1)[0] + return (path if path in SAFE_PUBLIC_PATHS or + SAFE_MOVE_PATH.fullmatch(path) is not None + else "__invalid_path__") + + +def safe_string(field: str, value: str) -> str | None: + if field=="request_target": + return None + if field=="path": + return safe_path(value) + if field in ("uci","move"): + return value if SAFE_MOVE.fullmatch(value) else "__redacted__" + if field in ("request_id","original_request_id"): + return value if SAFE_REQUEST.fullmatch(value) else "__redacted__" + if field in HASH_FIELDS or field.endswith("_sha256"): + return value if SAFE_ID.fullmatch(value) else "__redacted__" + if field in ("error","errors","runner_errors","message","details", + "stdout","stderr","url","uri","query","raw","raw_path"): + return "__redacted__" + if field in SAFE_ENUM_FIELDS: + return value if value in SAFE_ENUM_VALUES[field] else "__redacted__" + if field == "run_id": + return value if value in {f"r{i:02d}-{color}" for i in range(15) + for color in ("white","black")} else "__redacted__" + # Strict default deny: otherwise any new producer-controlled field can + # leak a secret without a separate output-safety review. + return "__redacted__" + + +def sanitize_report(value: object, *, field: str = "", depth: int = 0): + if depth>MAX_DEPTH: + return "__redacted__" + if value is None or type(value) is bool or type(value) is int: + return value + if type(value) is float: + return value if math.isfinite(value) else None + if type(value) is str: + return safe_string(field,value) + if type(value) in (tuple,list): + return [sanitize_report(row,field=field,depth=depth+1) + for row in value[:MAX_SEQUENCE]] + if type(value) is dict: + report={} + for key,item in list(value.items())[:MAX_SEQUENCE]: + if type(key) is str and SAFE_KEYS.fullmatch(key): + report[key]=sanitize_report(item,field=key,depth=depth+1) + return report + return "__redacted__" + + +def serialize_report(value: object) -> str: + return json.dumps(sanitize_report(value),sort_keys=True, + indent=2,allow_nan=False)+"\n" diff --git a/scripts/spdx_license_contract.py b/scripts/spdx_license_contract.py new file mode 100644 index 00000000..00418bdd --- /dev/null +++ b/scripts/spdx_license_contract.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Bounded offline SPDX 2.x license-expression syntax and identifier validator. + +Identifiers are matched against a frozen SPDX License List (including deprecated +identifiers) and exception catalogue from spdx/license-list-data tag v3.29.0. +This proves structural syntax, NOT licenses, redistribution rights or approval. +""" +from __future__ import annotations + +import json +import re +from functools import lru_cache +from pathlib import Path + +CATALOG = Path(__file__).resolve().parents[1] / ( + "qualification/spdx-license-identifiers-v3.29.0.json") +TOKEN = re.compile(r"\(|\)|[A-Za-z0-9][A-Za-z0-9.+:-]*", re.ASCII) +USER_LICENSE = re.compile( + r"(?:DocumentRef-[A-Za-z0-9.-]+:)?LicenseRef-[A-Za-z0-9.-]+\Z", + re.ASCII) +MAX_LENGTH = 4096 +MAX_TOKENS = 256 +MAX_NESTING = 24 + + +@lru_cache(maxsize=1) +def approved_identifiers() -> tuple[frozenset[str], frozenset[str]]: + """Read the pinned exact identifier catalogue; malformed data fails closed.""" + policy = json.loads(CATALOG.read_text(encoding="utf-8")) + if (type(policy) is not dict or policy.get("schema_version") != 1 or + policy.get("spdx_license_list_version") != "3.29.0" or + policy.get("source_tag") != "v3.29.0"): + raise ValueError("SPDX license identifier catalogue malformed") + licenses = policy.get("license_ids") + exceptions = policy.get("exception_ids") + if (type(licenses) is not list or type(exceptions) is not list or + len(licenses) < 700 or len(exceptions) < 80 or + not all(type(x) is str and x for x in licenses + exceptions) or + len(set(licenses)) != len(licenses) or + len(set(exceptions)) != len(exceptions)): + raise ValueError("SPDX license identifier catalogue incomplete") + return frozenset(licenses), frozenset(exceptions) + + +def valid_license_expression(value: object) -> bool: + """Accept SPDX 2.x Annex-D syntax, known IDs, LicenseRefs and sentinels. + + The parser is deliberately bounded and does not interpret license terms. + NONE/NOASSERTION are accepted as whole field values only. + """ + if (type(value) is not str or not value or len(value) > MAX_LENGTH or + value.strip(" ") != value or + any(ord(c) > 127 or (ord(c) < 32) for c in value)): + return False + if value in ("NONE", "NOASSERTION"): + return True + try: + licenses, exceptions = approved_identifiers() + except (OSError, ValueError, TypeError): + return False + + tokens: list[str] = [] + end = 0 + for match in TOKEN.finditer(value): + if any(c != " " for c in value[end:match.start()]): + return False + tokens.append(match.group()) + end = match.end() + if len(tokens) > MAX_TOKENS: + return False + if end != len(value) or not tokens: + return False + + def is_license(token: str) -> bool: + if USER_LICENSE.fullmatch(token): + return True + return token in licenses or ( + token.endswith("+") and token[:-1] in licenses) + + pos = 0 + + def parse_atom(depth: int) -> bool: + nonlocal pos + if pos >= len(tokens) or depth > MAX_NESTING: + return False + current = tokens[pos] + if current == "(": + pos += 1 + if not parse_or(depth + 1): + return False + if pos >= len(tokens) or tokens[pos] != ")": + return False + pos += 1 + # WITH applies only to a simple license identifier, not a group. + return True + if not is_license(current): + return False + pos += 1 + if pos < len(tokens) and tokens[pos] == "WITH": + pos += 1 + if pos >= len(tokens) or tokens[pos] not in exceptions: + return False + pos += 1 + return True + + def parse_and(depth: int) -> bool: + nonlocal pos + if not parse_atom(depth): + return False + while pos < len(tokens) and tokens[pos] == "AND": + pos += 1 + if not parse_atom(depth): + return False + return True + + def parse_or(depth: int) -> bool: + nonlocal pos + if not parse_and(depth): + return False + while pos < len(tokens) and tokens[pos] == "OR": + pos += 1 + if not parse_and(depth): + return False + return True + + return parse_or(0) and pos == len(tokens) diff --git a/scripts/verify-release-candidate.py b/scripts/verify-release-candidate.py index f94893c6..27c83a3a 100644 --- a/scripts/verify-release-candidate.py +++ b/scripts/verify-release-candidate.py @@ -88,10 +88,17 @@ def verify(manifest: dict, roots: dict[str, Path], for flag in ("artifact_distribution_authorized","public_bot_release", "network_recovery_qualified","deployment_host_qualified"): insist(manifest.get(flag) is False, "release claimed unapproved authority: " + flag) + frozen_bridge = roots["tools"] / "deploy/lichess/bridge.lock.json" + insist(frozen_bridge.is_file() and not frozen_bridge.is_symlink(), + "frozen upstream bridge lock absent from packaged tools") + bridge_lock = read(frozen_bridge) insist(manifest.get("source_commit") and manifest.get("source_tree") and - manifest.get("bridge_commit") == - read(roots["tools"] / "qualification/release-image-v1.json").get("bridge_commit", - manifest["bridge_commit"]), "incomplete source/bridge metadata") + manifest.get("bridge_commit") == bridge_lock["commit"] and + bridge_lock.get("live_api_permitted") is False and + bridge_lock.get("scope") == "ONLINE-3B-OFFLINE-ONLY" and + manifest.get("source_attestation", {}).get( + "deploy/lichess/bridge.lock.json") == sha(frozen_bridge), + "upstream bridge commit or source lock does not match pinned input") expected_policy = roots["tools"] / "qualification/release-image-v1.json" insist(expected_policy.is_file() and sha(expected_policy) == manifest["release_image_policy_sha256"], "frozen policy digest mismatch") diff --git a/scripts/verify-release-oci.py b/scripts/verify-release-oci.py index 69b02ba2..4f0390e7 100644 --- a/scripts/verify-release-oci.py +++ b/scripts/verify-release-oci.py @@ -6,13 +6,33 @@ """ from __future__ import annotations import argparse +import base64 +import binascii +from datetime import datetime +import os +import stat import hashlib +import importlib.util +import gzip +import zlib import json import re +import ipaddress import tarfile +from datetime import datetime from pathlib import Path, PurePosixPath +from urllib.parse import urlsplit HEX = re.compile(r"^[0-9a-f]{64}$") +# The SPDX parser uses a reviewed, versioned, offline license/exception list. +_spdx_path=Path(__file__).with_name("spdx_license_contract.py") +_spdx_spec=importlib.util.spec_from_file_location("release1_spdx_license_contract", + _spdx_path) +if _spdx_spec is None or _spdx_spec.loader is None: + raise ValueError("SPDX offline expression validator absent") +_spdx_mod=importlib.util.module_from_spec(_spdx_spec) +_spdx_spec.loader.exec_module(_spdx_mod) +valid_spdx_license_expression=_spdx_mod.valid_license_expression def require(ok, why): @@ -32,6 +52,28 @@ def file_digest(path: Path): return sha.hexdigest() +def digest_verified_archive(fileobj, archive: Path, before) -> str: + """Hash the *opened/verified inode*, not a possibly replaced pathname. + + Detect both in-place mutation and atomic pathname replacement. A later + aggregate recomputes the digest from the supplied archive path, so any + replacement after this function also fails admission. + """ + fileobj.seek(0) + h = hashlib.sha256() + for part in iter(lambda: fileobj.read(1 << 20), b""): + h.update(part) + after = os.fstat(fileobj.fileno()) + path_stat = os.stat(archive, follow_symlinks=False) + identity = lambda s: (s.st_dev, s.st_ino, s.st_size, + s.st_mtime_ns, s.st_ctime_ns) + require(stat.S_ISREG(after.st_mode) and + stat.S_ISREG(path_stat.st_mode) and + identity(before) == identity(after) == identity(path_stat), + "OCI archive file was mutated or replaced during verification") + return h.hexdigest() + + def verify_image_config_link(*, image_config_digest: str, metadata_config_digest: str | None, docker_inspection: dict | None) -> bool: @@ -49,158 +91,911 @@ def verify_image_config_link(*, image_config_digest: str, require(metadata_config_digest == image_config_digest, "BuildKit config metadata contradicts OCI archive image") if docker_inspection is not None: - require(docker_inspection.get("Id") == image_config_digest and - docker_inspection.get("Architecture") == "amd64", + require(isinstance(docker_inspection, dict) and + docker_inspection.get("Id") == image_config_digest and + docker_inspection.get("Architecture") == "amd64" and + docker_inspection.get("Os", "linux") == "linux", "tested Docker image differs from exported OCI config") require(metadata_config_digest is not None or docker_inspection is not None, "OCI config identity has neither BuildKit metadata nor image inspection") return True +def verify_layer_diff_ids(archive: tarfile.TarFile, members: dict, + layers: list[dict], rootfs: dict) -> list[str]: + """Independently hash the expanded application layers in rootfs order.""" + require(rootfs.get("type") == "layers" and + isinstance(rootfs.get("diff_ids"), list) and + len(rootfs["diff_ids"]) == len(layers), + "OCI rootfs does not enumerate every application layer") + computed = [] + for layer, expected in zip(layers, rootfs["diff_ids"]): + oid = layer.get("digest") + kind = layer.get("mediaType") + require(isinstance(oid, str) and oid.startswith("sha256:") and + HEX.fullmatch(oid[7:]) is not None and + isinstance(expected, str) and expected.startswith("sha256:") and + HEX.fullmatch(expected[7:]) is not None, + "invalid application layer/rootfs digest") + require(kind in ( + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip", + ), "unsupported OCI layer compression type") + member = members.get("blobs/sha256/" + oid[7:]) + require(member is not None and member.isfile(), + "application layer blob missing from archive") + stream = archive.extractfile(member) + require(stream is not None, "unreadable OCI application layer") + compressed = kind.endswith(("+gzip", ".gzip")) + with stream: + decoded = gzip.GzipFile(fileobj=stream, mode="rb") if compressed else stream + h = hashlib.sha256() + expanded = 0 + try: + while True: + piece = decoded.read(1 << 20) + if not piece: + break + expanded += len(piece) + require(expanded <= (4 << 30), + "OCI application layer exceeds bounded expanded size") + h.update(piece) + finally: + if decoded is not stream: + decoded.close() + observed = "sha256:" + h.hexdigest() + require(observed == expected, + "application OCI layer differs from config.rootfs.diff_ids") + computed.append(observed) + return computed + + +SPDX = "https://spdx.dev/Document" +SLSA = {"https://slsa.dev/provenance/v0.2", "https://slsa.dev/provenance/v1"} +STATEMENT = "https://in-toto.io/Statement/v1" + + +def valid_uri_syntax(value: object, allowed_schemes: set[str], *, + allow_git_ssh_user: bool = False) -> bool: + """One RFC3986 URI grammar for SPDX identities, downloads and SLSA. + + URI syntax is common; field-specific schemes, fragments and special tokens + remain caller policy. Require percent-encoding for non-ASCII bytes. + """ + if not isinstance(value, str) or not value or value != value.strip(): + return False + # RFC3986 unreserved + reserved delimiters and encoded %HH octets. + if re.fullmatch(r"[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", value, + re.ASCII) is None: + return False + if (re.search(r"%(?![0-9A-Fa-f]{2})", value) or + re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", + value, re.IGNORECASE)): + return False + try: + uri = urlsplit(value) + # RFC3986: "[" and "]" are only valid around an IP-literal + # *authority*, never in a path, query, fragment or URN NSS. + # The component grammar also prevents reserved gen-delimiters + # being treated as ordinary pchar at any position. + pchar = r"A-Za-z0-9._~!$&'()*+,;=:@%-" + if (re.fullmatch("[" + pchar + r"/]*", uri.path, + re.ASCII) is None or + re.fullmatch("[" + pchar + r"/?]*", uri.query, + re.ASCII) is None or + re.fullmatch("[" + pchar + r"/?]*", uri.fragment, + re.ASCII) is None): + return False + if uri.scheme not in allowed_schemes: + return False + if uri.scheme == "urn": + return not uri.netloc and bool(uri.path) and ":" in uri.path + if (not uri.netloc or not uri.hostname or + "%" in uri.netloc or uri.netloc.count("@") > 1 or + uri.password is not None): + return False + port = uri.port + if port is not None and not (1 <= port <= 65535): + return False + if uri.username is not None and not ( + allow_git_ssh_user and uri.username == "git" and + (uri.scheme == "ssh" or uri.scheme.endswith("+ssh"))): + return False + host = uri.hostname + if ":" in host: + # RFC3986 supports both IPv6 and IPvFuture bracketed IP-literals. + if host.lower().startswith("v"): + if re.fullmatch( + r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+", + host, re.IGNORECASE) is None: + return False + else: + try: + ipaddress.IPv6Address(host) + except ValueError: + return False + else: + labels = host.split(".") + if (len(host) > 253 or not all(re.fullmatch( + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", + label) for label in labels)): + return False + return True + except ValueError: + return False + + +def valid_identity_uri(value: object) -> bool: + """SLSA identities support HTTP(S) and valid URNs, not user credentials.""" + return valid_uri_syntax(value, {"https", "http", "urn"}) + + +def valid_spdx_namespace(value: object) -> bool: + """SPDX namespace uses a valid identity URI without any # delimiter.""" + return valid_identity_uri(value) and "#" not in value + + +def _optional_object(container: dict, field: str) -> bool: + value = container.get(field) + return value is None or type(value) is dict + + +def _optional_string(container: dict, field: str) -> bool: + value = container.get(field) + return value is None or type(value) is str + + +def _optional_string_map(container: dict, field: str) -> bool: + value = container.get(field) + return (value is None or + (type(value) is dict and all( + type(key) is str and type(item) is str + for key, item in value.items()))) + + +def _resource_uri(value: object) -> bool: + """in-toto ResourceURI accepts both hierarchical and opaque RFC3986 URIs. + + BuildKit's actual Docker materials use Package URLs such as + pkg:docker/ubuntu@sha256%3A...; these do not have //network authorities. + Resource URIs are identifiers, never instructions to access a network. + """ + if type(value) is not str or not value: + return False + if re.fullmatch(r"[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", + value,re.ASCII) is None: + return False + if (re.search(r"%(?![0-9A-Fa-f]{2})",value) or + re.search(r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[fF])", + value,re.IGNORECASE)): + return False + try: + uri=urlsplit(value) + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9+.-]*",uri.scheme): + return False + if uri.netloc: + return valid_uri_syntax(value,{uri.scheme},allow_git_ssh_user=True) + if uri.scheme not in ("pkg","urn","git","oci","file","docker"): + return False + pchar=r"A-Za-z0-9._~!$&'()*+,;=:@%-" + if (not uri.path or uri.path.startswith("//") or + re.fullmatch("["+pchar+r"/]*",uri.path,re.ASCII) is None or + re.fullmatch("["+pchar+r"/?]*",uri.query,re.ASCII) is None or + re.fullmatch("["+pchar+r"/?]*",uri.fragment,re.ASCII) is None): + return False + return True + except ValueError: + return False + + +def _valid_digest_set(value: object) -> bool: + if type(value) is not dict or not value: + return False + sizes={"sha1":40,"sha256":64,"sha384":96,"sha512":128} + for algorithm,raw in value.items(): + if (type(algorithm) is not str or + re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,31}",algorithm) is None or + type(raw) is not str or + re.fullmatch(r"[0-9a-fA-F]+",raw) is None): + return False + expected=sizes.get(algorithm.lower()) + if expected is not None: + if len(raw)!=expected: + return False + elif not (16 <= len(raw) <= 128 and len(raw)%2==0): + return False + return True + + +def _valid_resource_descriptor(value: object) -> bool: + """Typed in-toto ResourceDescriptor with at least one meaningful identity. + + The normative minimum is one of uri/digest/content. We also validate + nonselected identity fields if present; combining one good URI with an + empty digest or malformed base64 cannot bypass the contract. + """ + if type(value) is not dict: + return False + if not all(_optional_string(value, name) for name in + ("uri", "name", "downloadLocation", "mediaType", "content", + "localName")): + return False + uri=value.get("uri") + digest=value.get("digest") + content=value.get("content") + has_uri=type(uri) is str and _resource_uri(uri) + has_digest=_valid_digest_set(digest) + has_content=type(content) is str + if "uri" in value and uri is not None and not has_uri: + return False + if "digest" in value and digest is not None and not has_digest: + return False + if has_content: + try: + base64.b64decode(content,validate=True) + except (ValueError,binascii.Error): + return False + if "content" in value and content is not None and not has_content: + return False + location=value.get("downloadLocation") + if location not in (None,"") and not _resource_uri(location): + return False + return bool(has_uri or has_digest or has_content) and _optional_object( + value,"annotations") + + +def _rfc3339_timestamp(value: object) -> bool: + if value in (None,""): + return True + if type(value) is not str or re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" + r"(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})",value) is None: + return False + try: + observed=datetime.fromisoformat(value.replace("Z","+00:00")) + except ValueError: + return False + return observed.tzinfo is not None and observed.utcoffset() is not None + + +def _optional_resource_list(container: dict, field: str) -> bool: + value = container.get(field) + return (value is None or + (type(value) is list and + all(_valid_resource_descriptor(item) for item in value))) + + +def _valid_slsa_metadata(value: object, *, version: str) -> bool: + if value is None: + return True + if type(value) is not dict: + return False + keys = ("invocationId", "startedOn", "finishedOn") if version == "v1" else ( + "buildInvocationId", "buildStartedOn", "buildFinishedOn") + if not all((_rfc3339_timestamp(value.get(field)) + if field in ("startedOn","finishedOn", + "buildStartedOn","buildFinishedOn") + else _optional_string(value,field)) + for field in keys): + return False + first=value.get("startedOn" if version=="v1" else "buildStartedOn") + last=value.get("finishedOn" if version=="v1" else "buildFinishedOn") + if first not in (None,"") and last not in (None,""): + start=datetime.fromisoformat(first.replace("Z","+00:00")) + finish=datetime.fromisoformat(last.replace("Z","+00:00")) + if finish < start: + return False + if version == "v0.2": + completeness = value.get("completeness") + if completeness is not None and ( + type(completeness) is not dict or + any(type(item) is not bool for item in completeness.values())): + return False + reproducible = value.get("reproducible") + if reproducible is not None and type(reproducible) is not bool: + return False + return True + + +def validate_slsa(kind: str, predicate: dict) -> None: + """Recognized SLSA v1/v0.2 fields must satisfy their JSON type contracts. + + Absent and null *optional* fields remain equivalent per SLSA parsing + rules. Unrecognized extension fields do not grant extra authority. + """ + require(type(predicate) is dict, "SLSA predicate not a JSON object") + if kind == "https://slsa.dev/provenance/v1": + build = predicate.get("buildDefinition") + run = predicate.get("runDetails") + require(type(build) is dict and type(run) is dict, + "SLSA provenance v1 predicate incomplete") + require(valid_identity_uri(build.get("buildType")), + "SLSA v1 build type missing or invalid") + require(type(build.get("externalParameters")) is dict and + _optional_object(build, "internalParameters") and + _optional_resource_list(build, "resolvedDependencies"), + "SLSA v1 build inputs contain malformed optional fields") + builder = run.get("builder") + # Field-level, fixed diagnostics; never include untrusted URI values. + require(type(builder) is dict, + "SLSA v1 builder object missing or invalid") + require(valid_identity_uri(builder.get("id")), + "SLSA v1 builder ID missing or invalid") + require(_optional_string_map(builder, "version"), + "SLSA v1 builder version map malformed") + require(_optional_resource_list(builder, "builderDependencies"), + "SLSA v1 builderDependencies malformed") + require(_valid_slsa_metadata(run.get("metadata"),version="v1") and + _optional_resource_list(run, "byproducts"), + "SLSA v1 run metadata/byproducts malformed") + elif kind == "https://slsa.dev/provenance/v0.2": + builder = predicate.get("builder") + invocation = predicate.get("invocation") + require(type(builder) is dict and + valid_identity_uri(builder.get("id")) and + valid_identity_uri(predicate.get("buildType")) and + type(invocation) is dict, + "SLSA v0.2 build type, builder or invocation missing") + require(_optional_string_map(builder, "version") and + _optional_resource_list(builder, "builderDependencies") and + _optional_object(predicate, "buildConfig") and + _optional_resource_list(predicate, "materials") and + _optional_object(invocation, "parameters") and + _optional_object(invocation, "environment") and + _optional_object(invocation, "configSource") and + _valid_slsa_metadata(predicate.get("metadata"),version="v0.2"), + "SLSA v0.2 recognized optional fields malformed") + source = invocation.get("configSource") + if type(source) is dict: + require(all(_optional_string(source, name) for name in + ("uri", "entryPoint")) and + _optional_string_map(source, "digest"), + "SLSA v0.2 config source malformed") + else: + raise ValueError("unsupported SLSA version") + + +# Supported canonical SPDX 2.2/2.3 checksum algorithms and output widths. +# MD6 is enumerated by SPDX but its length is parameterized; this release +# profile refuses MD6 rather than accepting an arbitrary-length hex digest. +SPDX_CHECKSUM_HEX_LENGTH = { + "SHA1": 40, "SHA224": 56, "SHA256": 64, "SHA384": 96, + "SHA512": 128, "SHA3-256": 64, "SHA3-384": 96, + "SHA3-512": 128, "BLAKE2b-256": 64, "BLAKE2b-384": 96, + "BLAKE2b-512": 128, "BLAKE3": 64, "MD2": 32, "MD4": 32, + "MD5": 32, "ADLER32": 8, +} + + +def valid_spdx_download_location(value: object) -> bool: + """Recognize SPDX special tokens or supported strictly valid URL/VCS URI.""" + if value in ("NONE", "NOASSERTION"): + return True + return valid_uri_syntax(value, { + "http", "https", "ftp", "ftps", "ssh", "git", "git+https", + "git+http", "git+ssh", "git+git", "svn", "svn+https", + "svn+ssh", "hg", "hg+https", "hg+ssh", + }, allow_git_ssh_user=True) + +def valid_spdx_checksums(value: object) -> bool: + """Check every listed digest against the supported SPDX algorithm width.""" + if type(value) is not list or not value: + return False + seen = set() + for checksum in value: + if not isinstance(checksum, dict): + return False + algorithm = checksum.get("algorithm") + digest = checksum.get("checksumValue") + width = SPDX_CHECKSUM_HEX_LENGTH.get(algorithm) if isinstance(algorithm, str) else None + if (width is None or algorithm in seen or + type(digest) is not str or len(digest) != width or + re.fullmatch(r"[0-9a-f]+", digest) is None): + return False + seen.add(algorithm) + return True + + +def validate_spdx_document(predicate: dict) -> None: + """Validate required SPDX 2.x document creation identity and inventory. + + This is schema/structural validation, not an authentication of the tool + that generated the SBOM or of a publisher's signature. + """ + creation = predicate.get("creationInfo") + creators = creation.get("creators") if isinstance(creation, dict) else None + created = creation.get("created") if isinstance(creation, dict) else None + valid_date = (isinstance(created, str) and + re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", + created) is not None) + if valid_date: + try: + datetime.fromisoformat(created.replace("Z", "+00:00")) + except ValueError: + valid_date = False + require( + predicate.get("spdxVersion") in ("SPDX-2.2", "SPDX-2.3") and + predicate.get("SPDXID") == "SPDXRef-DOCUMENT" and + predicate.get("dataLicense") == "CC0-1.0" and + isinstance(predicate.get("name"), str) and + bool(predicate["name"].strip()) and + valid_spdx_namespace(predicate.get("documentNamespace")) and + valid_date and + isinstance(creators, list) and bool(creators) and + all(isinstance(creator, str) and + creator.startswith(("Tool: ", "Person: ", "Organization: ")) and + bool(creator.partition(": ")[2].strip()) for creator in creators), + "SPDX SBOM lacks complete document creation identity") + # SPDX 2.3 distinguishes package.name from file.fileName. + # Preserve the stricter SPDX 2.2 mandatory package fields separately. + version=predicate["spdxVersion"] + # A single shared 'name' predicate wrongly rejected genuine Syft files. + packages = predicate.get("packages", []) + files = predicate.get("files", []) + require(type(packages) is list and type(files) is list and + bool(packages or files), + "SPDX SBOM lacks a usable package/file inventory") + unique = {"SPDXRef-DOCUMENT"} + + def spdx_id(item): + identifier = item.get("SPDXID") if isinstance(item, dict) else None + require(isinstance(identifier, str) and + re.fullmatch(r"SPDXRef-[A-Za-z0-9.-]+", identifier) is not None and + identifier not in unique, + "SPDX SBOM inventory has malformed or duplicate SPDXID") + unique.add(identifier) + + for item in packages: + spdx_id(item) + require(isinstance(item.get("name"), str) and + bool(item["name"].strip()) and + valid_spdx_download_location(item.get("downloadLocation")), + "SPDX SBOM package has invalid downloadLocation") + # SPDX 2.2 requires copyright and concluded/declared licenses. + # SPDX 2.3 permits omission with NOASSERTION semantics. This verifier + # validates present expressions but never grants distribution rights. + mandatory=("copyrightText","licenseConcluded","licenseDeclared") + if version=="SPDX-2.2": + require(all(field in item for field in mandatory), + "SPDX 2.2 package mandatory fields absent") + if "copyrightText" in item: + require(type(item["copyrightText"]) is str and + bool(item["copyrightText"].strip()), + "SPDX package copyrightText malformed") + for field in ("licenseConcluded","licenseDeclared"): + if field in item: + require(valid_spdx_license_expression(item[field]), + "SPDX package license expression malformed") + analyzed=item.get("filesAnalyzed",True) + require(type(analyzed) is bool, + "SPDX package filesAnalyzed must be boolean") + verification=item.get("packageVerificationCode") + if not analyzed: + require(verification is None and "licenseInfoFromFiles" not in item, + "SPDX unanalyzed package must omit verification code and file licenses") + else: + if version=="SPDX-2.2": + require(verification is not None, + "SPDX 2.2 analyzed package requires verification code") + if verification is not None: + require(type(verification) is dict and + type(verification.get("packageVerificationCodeValue")) is str and + re.fullmatch(r"[0-9a-f]{40}", + verification["packageVerificationCodeValue"]) is not None and + (verification.get("packageVerificationCodeExcludedFiles") is None + or (type(verification["packageVerificationCodeExcludedFiles"]) is list + and all(type(name) is str and bool(name) + for name in verification["packageVerificationCodeExcludedFiles"]))), + "SPDX supplied package verification code malformed") + if "licenseInfoFromFiles" in item: + require(type(item["licenseInfoFromFiles"]) is list and + all(valid_spdx_license_expression(v) + for v in item["licenseInfoFromFiles"]), + "SPDX package licenseInfoFromFiles malformed") + if "checksums" in item: + require(valid_spdx_checksums(item["checksums"]), + "SPDX SBOM package contains invalid checksums") + for item in files: + spdx_id(item) + checksums=item.get("checksums") + require(isinstance(item.get("fileName"), str) and + bool(item["fileName"].strip()) and + valid_spdx_checksums(checksums) and + sum(c.get("algorithm")=="SHA1" for c in checksums)==1, + "SPDX file requires a valid SHA1 checksum") + if "licenseConcluded" in item: + require(valid_spdx_license_expression(item["licenseConcluded"]), + "SPDX file licenseConcluded malformed") + if "licenseInfoInFiles" in item: + require(type(item["licenseInfoInFiles"]) is list and + all(valid_spdx_license_expression(value) + for value in item["licenseInfoInFiles"]), + "SPDX file licenseInfoInFiles malformed") + + +def validate_target_platform(platform: object) -> None: + """Fail closed on unqualified runtime requirements for linux/amd64 v1.""" + require(type(platform) is dict and + platform.get("os") == "linux" and + platform.get("architecture") == "amd64", + "OCI runnable platform must be linux/amd64") + allowed = {"os", "architecture", "variant", "os.version", + "os.features", "features"} + require(not (set(platform) - allowed), + "OCI runnable platform contains unsupported requirements") + if "variant" in platform: + require(type(platform["variant"]) is str and + platform["variant"] == "v1", + "OCI runnable platform variant not qualified") + if "os.version" in platform: + require(type(platform["os.version"]) is str and + platform["os.version"] == "", + "OCI runnable platform OS version requirement not qualified") + for field in ("os.features", "features"): + if field in platform: + require(type(platform[field]) is list and + all(type(item) is str and bool(item.strip()) + for item in platform[field]) and + not platform[field], + "OCI runnable platform feature requirements not qualified") + + +def verify_attestation_subjects(attestations: list[dict], image: dict) -> set[str]: + """Bind full-image attestations to the exact policy's SLSA v1 builder. + + A scanner-only compatibility probe is *not* provenance for the separately + exported application image. Attestations are not publisher signatures. + """ + policy=json.loads((Path(__file__).resolve().parents[1] / + "qualification/release-image-v1.json").read_text()) + required_type=policy.get("slsa_provenance_predicate_type") + required_builder=policy.get("slsa_builder_id") + require(policy.get("public_bot_release") is False and + required_type=="https://slsa.dev/provenance/v1" and + type(required_builder) is str and + valid_identity_uri(required_builder), + "full-image SLSA identity policy missing or invalid") + image_digest = image["manifest_digest"] + predicates = set() + provenance_count=0 + require(attestations, "OCI archive has no attestation manifests") + for item in attestations: + descriptor, statement = item["descriptor"], item["statement"] + annotations = descriptor.get("annotations") or {} + require(annotations.get("vnd.docker.reference.type") == "attestation-manifest" + and annotations.get("vnd.docker.reference.digest") == image_digest, + "OCI attestation descriptor refers to a different application image") + platform = descriptor.get("platform") or {} + require(platform.get("architecture") == "unknown" + and platform.get("os") == "unknown", + "OCI attestation artifact uses invalid image platform") + manifest_subject = item.get("manifest_subject") + if manifest_subject is not None: + require(isinstance(manifest_subject, dict) and + manifest_subject.get("digest") == image_digest and + manifest_subject.get("mediaType") == image["manifest_media_type"] and + type(manifest_subject.get("size")) is int and + manifest_subject["size"] == image["manifest_size"], + "OCI attestation manifest subject differs from application image") + require(item.get("layer_media_type") == "application/vnd.in-toto+json", + "unexpected attestation statement layer type") + require(isinstance(statement, dict), "OCI attestation statement missing") + # Absent wrapper means BuildKit's valid singleton in-toto statement. + # An explicitly present null/object/string/empty wrapper is invalid. + if "statements" in statement: + statements = statement["statements"] + else: + statements = [statement] + require(type(statements) is list and bool(statements), + "OCI attestation has invalid in-toto statements wrapper") + for row in statements: + require(isinstance(row, dict) and row.get("_type") == STATEMENT, + "OCI attestation is not a supported in-toto statement") + kind = row.get("predicateType") + require(kind == SPDX or kind in SLSA, + "unknown attestation predicate type") + subjects = row.get("subject") + require(isinstance(subjects, list) and bool(subjects), + "OCI attestation has no image subjects") + for subject in subjects: + if not isinstance(subject, dict): + raise ValueError("malformed in-toto image subject") + digest = subject.get("digest") + require(isinstance(subject.get("name"), str) and + bool(subject["name"].strip()) and + isinstance(digest, dict) and + set(digest) == {"sha256"} and + digest["sha256"] == image_digest.removeprefix("sha256:"), + "OCI attestation subject does not name application manifest") + predicate = row.get("predicate") + require(isinstance(predicate, dict) and bool(predicate), + "OCI attestation predicate document empty") + if kind == SPDX: + validate_spdx_document(predicate) + else: + require(kind == required_type, + "full-image SLSA provenance must use pinned v1 predicate") + validate_slsa(kind, predicate) + require(predicate["runDetails"]["builder"]["id"] == + required_builder, + "full-image SLSA builder differs from pinned release policy") + provenance_count += 1 + predicates.add(kind) + require(SPDX in predicates, "required image SBOM attestation not exported") + require(provenance_count == 1, + "full image requires exactly one pinned SLSA v1 provenance statement") + return predicates + def verify_archive(archive: Path, metadata: Path, *, docker_inspect: Path | None = None): meta = json.loads(metadata.read_text(encoding="utf-8")) blobs = {} - with tarfile.open(archive, "r:*") as inp: - members = inp.getmembers() - by_name = {} - for obj in members: - name = str(PurePosixPath(obj.name)) - require(not obj.issym() and not obj.islnk() and - not PurePosixPath(name).is_absolute() and - ".." not in PurePosixPath(name).parts and - name not in by_name, - "OCI archive includes unsafe or duplicate path") - by_name[name] = obj - require("oci-layout" in by_name and "index.json" in by_name, - "not an OCI-layout archive") - require(json.load(inp.extractfile(by_name["oci-layout"])).get("imageLayoutVersion") == - "1.0.0", "unsupported OCI layout") - index_member = inp.extractfile(by_name["index.json"]) - require(index_member is not None, "OCI index not readable") - index_raw = index_member.read() - index_digest = "sha256:" + _digest(index_raw) - index = json.loads(index_raw) - require(index.get("schemaVersion") == 2 and - index.get("mediaType") in (None,"application/vnd.oci.image.index.v1+json"), - "invalid OCI index") - referenced = set() - def get(oid): - if not isinstance(oid, str) or not oid.startswith("sha256:") or not HEX.fullmatch(oid[7:]): - raise ValueError("invalid OCI digest") - name = "blobs/sha256/" + oid[7:] - if name in blobs: - return blobs[name] - require(name in by_name and by_name[name].isfile(), "missing OCI blob "+oid) - f = inp.extractfile(by_name[name]) - require(f is not None, "unreadable OCI blob") - sha = hashlib.sha256() - body = bytearray() if by_name[name].size <= 8 * 1024 * 1024 else None - while True: - data = f.read(1 << 20) - if not data: break - sha.update(data) - if body is not None: body.extend(data) - require(sha.hexdigest() == oid[7:], "OCI blob hash mismatch "+oid) - referenced.add(name) - if body is not None: - blobs[name] = bytes(body) - return blobs[name] - return None - - images, attestations = [], [] - def inspect_descriptor(descriptor, depth=0): - require(depth < 4 and isinstance(descriptor, dict), - "OCI descriptor nesting invalid") - raw = get(descriptor.get("digest")) - require(raw is not None, "OCI descriptor JSON too large or missing") - document = json.loads(raw) - typ = descriptor.get("mediaType") - if typ == "application/vnd.oci.image.index.v1+json": - for next in document.get("manifests", []): - inspect_descriptor(next, depth+1) - return - require(typ in ("application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json"), - "unsupported OCI descriptor kind") - cfg = document.get("config") or {} - config_bytes = get(cfg.get("digest")) - require(config_bytes is not None, "OCI config cannot be independently parsed") - details = json.loads(config_bytes) - layers = document.get("layers") - require(isinstance(layers, list) and bool(layers), "OCI image missing layers") - for layer in layers: - get(layer.get("digest")) - if descriptor.get("annotations", {}).get("vnd.docker.reference.type") == "attestation-manifest": + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(archive, flags) + with os.fdopen(fd, "rb") as archive_fp: + opened = os.fstat(archive_fp.fileno()) + require(stat.S_ISREG(opened.st_mode), + "OCI archive is not a regular immutable file") + with tarfile.open(fileobj=archive_fp, mode="r:*") as inp: + members = inp.getmembers() + by_name = {} + for obj in members: + name = str(PurePosixPath(obj.name)) + require(not obj.issym() and not obj.islnk() and + not PurePosixPath(name).is_absolute() and + ".." not in PurePosixPath(name).parts and + name not in by_name, + "OCI archive includes unsafe or duplicate path") + by_name[name] = obj + require("oci-layout" in by_name and "index.json" in by_name, + "not an OCI-layout archive") + require(json.load(inp.extractfile(by_name["oci-layout"])).get("imageLayoutVersion") == + "1.0.0", "unsupported OCI layout") + index_member = inp.extractfile(by_name["index.json"]) + require(index_member is not None, "OCI index not readable") + index_raw = index_member.read() + index_digest = "sha256:" + _digest(index_raw) + index = json.loads(index_raw) + require(index.get("schemaVersion") == 2 and + index.get("mediaType") in (None,"application/vnd.oci.image.index.v1+json"), + "invalid OCI index") + referenced = set() + + def get(oid, expected_size): + """Hash the actual tar member and validate its descriptor byte length.""" + require(isinstance(oid, str) and oid.startswith("sha256:") and + HEX.fullmatch(oid[7:]) is not None, "invalid OCI digest") + require(type(expected_size) is int and expected_size >= 0, + "missing or invalid OCI descriptor size") + name = "blobs/sha256/" + oid[7:] + member = by_name.get(name) + require(member is not None and member.isfile(), "missing OCI blob " + oid) + require(member.size == expected_size, + "OCI descriptor size differs from referenced blob") + if name in blobs: + return blobs[name] + stream = inp.extractfile(member) + require(stream is not None, "unreadable OCI blob") + sha = hashlib.sha256() + body = bytearray() if member.size <= 8 * 1024 * 1024 else None + with stream: + while True: + data = stream.read(1 << 20) + if not data: + break + sha.update(data) + if body is not None: + body.extend(data) + require(sha.hexdigest() == oid[7:], "OCI blob hash mismatch " + oid) + referenced.add(name) + if body is not None: + blobs[name] = bytes(body) + return blobs[name] + return None + + INDEX_TYPE = "application/vnd.oci.image.index.v1+json" + MANIFEST_TYPES = ("application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json") + CONFIG_TYPES = ("application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", + "application/vnd.oci.empty.v1+json") + LAYER_TYPES = ("application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.docker.image.rootfs.diff.tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip") + top = index.get("manifests") + require(isinstance(top, list) and bool(top), + "OCI layout has no root descriptors") + reported_manifest = meta.get("containerimage.digest") + matches = [d for d in top if isinstance(d, dict) + and d.get("digest") == reported_manifest] + require(len(matches) == 1, + "BuildKit descriptor digest does not match exported OCI root") + root_descriptor = matches[0] + root_type = root_descriptor.get("mediaType") + require(root_type == INDEX_TYPE or root_type in MANIFEST_TYPES, + "BuildKit root descriptor has unsupported media type") + if root_type == INDEX_TYPE: + require(len(top) == 1, + "nested BuildKit root must be the sole layout root") + else: + require(all(d is root_descriptor or + (isinstance(d, dict) and + (d.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest") + for d in top), + "flat OCI layout has unrelated root descriptors") + reported_descriptor = meta.get("containerimage.descriptor") + require(isinstance(reported_descriptor, dict) and + type(reported_descriptor.get("size")) is int and + all(reported_descriptor.get(k) == root_descriptor.get(k) + for k in ("digest", "mediaType", "size")) and + type(root_descriptor.get("size")) is int, + "BuildKit image descriptor identity/size differs from OCI root") + images, attestations = [], [] + visited_nodes = [0] + + def inspect_descriptor(descriptor, depth=0, ancestors=frozenset()): + require(depth <= 8 and isinstance(descriptor, dict), + "OCI descriptor nesting invalid") + visited_nodes[0] += 1 + require(visited_nodes[0] <= 256, + "OCI descriptor graph exceeds node budget") + typ = descriptor.get("mediaType") + require(typ == INDEX_TYPE or typ in MANIFEST_TYPES, + "unsupported OCI descriptor kind") + oid = descriptor.get("digest") + require(oid not in ancestors, "OCI descriptor graph contains a cycle") + raw = get(oid, descriptor.get("size")) + require(raw is not None, "OCI descriptor JSON too large or missing") + document = json.loads(raw) + require(isinstance(document, dict) and + document.get("schemaVersion") == 2, + "invalid nested OCI index or image manifest") + require(document.get("mediaType") in (None, typ), + "OCI document media type differs from descriptor") + if typ == INDEX_TYPE: + children = document.get("manifests") + require(isinstance(children, list) and bool(children), + "nested OCI index has no child descriptors") + # Validate every *advertised* index platform on the path to + # the runnable image. Attestation-only subindexes do not + # determine the runner's platform. + contains_application = False + for child in children: + child_application = inspect_descriptor( + child, depth + 1, ancestors | {oid}) + contains_application = child_application or contains_application + if contains_application and "platform" in descriptor: + validate_target_platform(descriptor["platform"]) + return contains_application + cfg = document.get("config") + require(isinstance(cfg, dict) and + cfg.get("mediaType") in CONFIG_TYPES, + "OCI image has invalid configuration media type") + config_bytes = get(cfg.get("digest"), cfg.get("size")) + require(config_bytes is not None, + "OCI config cannot be independently parsed") + details = json.loads(config_bytes) + require(isinstance(details, dict), "malformed OCI configuration") + layers = document.get("layers") + require(isinstance(layers, list) and bool(layers), + "OCI image missing layers") + attested = (descriptor.get("annotations") or {}).get( + "vnd.docker.reference.type") == "attestation-manifest" for layer in layers: - content = get(layer["digest"]) - require(content is not None, "oversized attestation refused") - attestations.append(json.loads(content)) - elif details.get("architecture") == "amd64" and details.get("os") == "linux": - images.append({"manifest_digest":descriptor["digest"], - "config_digest":cfg["digest"], - "config":details, - "layer_count":len(layers)}) - for x in index.get("manifests",[]): - inspect_descriptor(x) - require(len(images)==1, "require exactly one linux/amd64 application image") - image=images[0] - reported_manifest=meta.get("containerimage.digest") - reported_config=meta.get("containerimage.config.digest") - # BuildKit OCI index metadata can omit this config digest when - # SBOM/provenance attestation manifests are attached. In that case - # use the independent loaded-image inspection as the mandatory link. - inspection = None - if docker_inspect: - inspection = json.loads(docker_inspect.read_text(encoding="utf-8")) - if isinstance(inspection,list): - require(len(inspection) == 1, "ambiguous Docker inspection") - inspection = inspection[0] - verify_image_config_link( - image_config_digest=image["config_digest"], - metadata_config_digest=reported_config, - docker_inspection=inspection, - ) - # BuildKit reports a descriptor digest, not the sha256 of OCI tar bytes. - accepted_digests = {image["manifest_digest"], index_digest} - accepted_digests.update(x.get("digest") for x in index.get("manifests",[])) - require(reported_manifest in accepted_digests, - "BuildKit descriptor digest does not match OCI index or image") - config=image["config"].get("config") or {} - require(config.get("User") in ("10001:10001", "10001") and - config.get("Entrypoint")==[ - "/opt/online3b/bin/python", - "/opt/release-tools/deploy/release/entrypoint.py"], - "OCI image launched with unapproved identity/entrypoint") - require(not config.get("ExposedPorts"), "offline candidate must not expose ports") - predicates=set() - for att in attestations: - if isinstance(att,dict): - if att.get("predicateType"): - predicates.add(att["predicateType"]) - for row in att.get("statements",[]): - if isinstance(row,dict) and row.get("predicateType"): - predicates.add(row["predicateType"]) - require(any("spdx" in x.lower() for x in predicates), - "required image SBOM attestation not exported") - require(any("slsa" in x.lower() for x in predicates), - "required image provenance attestation not exported") - # The OCI layout may contain unreferenced internal artifacts that are - # independently hashed but cannot grant authority. - return { - "schema_version":1,"oci_archive_sha256":file_digest(archive), - "oci_image_manifest_digest":image["manifest_digest"], - "oci_index_digest":index_digest, - "oci_config_digest":image["config_digest"], - "buildkit_digest":reported_manifest, - "buildkit_config_digest_metadata_present": reported_config is not None, - "independent_loaded_image_digest_verified": inspection is not None, - "buildkit_digest_kind":("oci-index" if reported_manifest == index_digest - else "image-or-index-child"), - "attestation_predicate_types":sorted(predicates), - "oci_layers":image["layer_count"], - "integrity_verified":True,"publisher_signature_verified":False, - "public_bot_release":False,"artifact_distribution_authorized":False, - } + require(isinstance(layer, dict) and + layer.get("mediaType") in ( + ("application/vnd.in-toto+json",) if attested + else LAYER_TYPES), + "unsupported OCI statement or application layer mediaType") + get(layer.get("digest"), layer.get("size")) + if attested: + for layer in layers: + content = get(layer["digest"], layer["size"]) + require(content is not None, + "oversized attestation statement refused") + attestations.append({ + "descriptor": descriptor, + "manifest_subject": document.get("subject"), + "layer_media_type": layer.get("mediaType"), + "statement": json.loads(content)}) + return False + else: + # The index descriptor is how an OCI consumer selects a platform. + # A flat manifest may not advertise one, but any advertised + # platform must agree with the configuration bytes. + advertised = descriptor.get("platform") + if advertised is None: + require(root_type in MANIFEST_TYPES, + "nested OCI application descriptor lacks platform") + else: + validate_target_platform(advertised) + # Only OCI configuration platform fields participate in + # platform selection. Legitimate config/rootfs/history data + # must never be mistaken for descriptor platform extras. + image_platform = { + field: details[field] for field in + ("os", "architecture", "variant", "os.version", "os.features") + if field in details + } + validate_target_platform(image_platform) + images.append({"manifest_digest": oid, + "manifest_media_type": typ, + "manifest_size": descriptor["size"], + "config_digest": cfg["digest"], + "config": details, "layers": layers, + "layer_count": len(layers)}) + return True + + for entry in top: + inspect_descriptor(entry) + require(len(images) == 1, + "require exactly one linux/amd64 application image") + image = images[0] + if root_type in MANIFEST_TYPES: + require(image["manifest_digest"] == root_descriptor["digest"], + "flat OCI root is not the runnable application") + diff_ids=verify_layer_diff_ids(inp,by_name,image["layers"], + image["config"].get("rootfs") or {}) + reported_manifest=meta.get("containerimage.digest") + reported_config=meta.get("containerimage.config.digest") + # BuildKit OCI index metadata can omit this config digest when + # SBOM/provenance attestation manifests are attached. In that case + # use the independent loaded-image inspection as the mandatory link. + inspection = None + if docker_inspect: + inspection = json.loads(docker_inspect.read_text(encoding="utf-8")) + if isinstance(inspection,list): + require(len(inspection) == 1, "ambiguous Docker inspection") + inspection = inspection[0] + verify_image_config_link( + image_config_digest=image["config_digest"], + metadata_config_digest=reported_config, + docker_inspection=inspection, + ) + # The identity was selected from the exported layout root descriptor, + # not from an unrestricted set of digests reachable in the archive. + require(reported_manifest == root_descriptor["digest"], + "BuildKit root descriptor changed during traversal") + config=image["config"].get("config") or {} + require(isinstance(config, dict), "malformed runnable OCI configuration") + require(config.get("User") in ("10001:10001", "10001") and + config.get("Entrypoint")==[ + "/opt/online3b/bin/python", + "/opt/release-tools/deploy/release/entrypoint.py"], + "OCI image launched with unapproved identity/entrypoint") + require(not config.get("ExposedPorts"), "offline candidate must not expose ports") + predicates=verify_attestation_subjects(attestations,image) + # The OCI layout may contain unreferenced internal artifacts that are + # independently hashed but cannot grant authority. + return { + "schema_version":1,"oci_archive_sha256":digest_verified_archive(archive_fp,archive,opened), + "oci_image_manifest_digest":image["manifest_digest"], + "oci_index_digest":index_digest, + "oci_exported_root_digest":root_descriptor["digest"], + "oci_exported_root_size":root_descriptor["size"], + "oci_config_digest":image["config_digest"], + "buildkit_digest":reported_manifest, + "buildkit_config_digest_metadata_present": reported_config is not None, + "independent_loaded_image_digest_verified": inspection is not None, + "buildkit_digest_kind":("oci-index" if root_type == INDEX_TYPE + else "application-manifest"), + "oci_application_identity_verified":True, + "oci_attestation_references_verified":True, + "oci_sbom_verified":SPDX in predicates, + "oci_provenance_verified":bool(predicates.intersection(SLSA)), + "oci_layers_verified":True, + "attestation_predicate_types":sorted(predicates), + "oci_layers":image["layer_count"], + "oci_diff_ids_verified":True, + "oci_diff_id_count":len(diff_ids), + "oci_attestation_subjects_verified":True, + "integrity_verified":True,"publisher_signature_verified":False, + "public_bot_release":False,"artifact_distribution_authorized":False, + } def main(): @@ -215,7 +1010,8 @@ def main(): a.output.parent.mkdir(parents=True,exist_ok=True) a.output.write_text(json.dumps(report,sort_keys=True,indent=2)+"\n") print(json.dumps(report,sort_keys=True)) - except (OSError,ValueError,KeyError,TypeError,tarfile.TarError) as exc: + except (OSError,ValueError,KeyError,TypeError,EOFError, + zlib.error,tarfile.TarError) as exc: p.exit(2,"RELEASE-1 OCI attestation refused: "+str(exc)+"\n") diff --git a/scripts/verify_assurance_contract.py b/scripts/verify_assurance_contract.py new file mode 100644 index 00000000..b46bf3e5 --- /dev/null +++ b/scripts/verify_assurance_contract.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Independently reconstruct necessary boolean implications in offline verdicts. + +A green bit is NOT authorization. The JSON contract enumerates prerequisites; +these verifications reject internally inconsistent positive claims after all +individual source, game, journal and OCI validators have run. +""" +from __future__ import annotations +import argparse +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +POLICY=ROOT/"qualification/assurance-contract-v1.json" + + +def require(ok: bool, reason: str) -> None: + if not ok: + raise ValueError("assurance claim contract: "+reason) + + +def validate_contract() -> dict: + contract=json.loads(POLICY.read_text(encoding="utf-8")) + require(contract.get("schema_version")==1 and + contract.get("public_bot_release") is False and + contract.get("authority",{}).get("public_distribution") is False, + "policy permits public authority") + for claim in ("harness_qualified","containment_qualified_offline", + "recovery_qualified_offline","offline_smoke_qualified", + "oci_sbom_verified","oci_provenance_verified", + "image_integrity_qualified","offline_candidate_qualified"): + item=contract.get("claims",{}).get(claim) + require(type(item) is dict and type(item.get("requires")) is list + and bool(item["requires"]), "missing dependencies for "+claim) + return contract + + +def verify_report(report: dict, kind: str) -> dict: + validate_contract() + require(type(report) is dict and + report.get("public_bot_release") is False and + report.get("network_recovery_qualified") is not True and + report.get("artifact_distribution_authorized") is not True, + "release authority was raised") + if kind=="online4a": + valid=report.get("harness_qualified") + rows=report.get("scenario_results") + failures=report.get("harness_failures") + if valid: + require(type(rows) is list and type(failures) is list and not failures and + report.get("required_run_count")==len(rows) and + report.get("validated_run_count")==len(rows) and + all(type(r) is dict and r.get("harness_integrity") is True + for r in rows), "ONLINE-4A harness lacks full evidence") + require(report.get("recovery_behavior_qualified") is False, + "unsafe unguarded bridge was called recovered") + return {"claim":"harness_qualified","consistent":True} + if kind=="online4b": + rows=report.get("scenario_results") + failures=report.get("qualification_failures") + harness=report.get("harness_qualified") is True + if harness: + require(type(rows) is list and type(failures) is list and not failures and + len(rows)==report.get("required_run_count") and + len(rows)==report.get("validated_run_count") and + all(type(r) is dict and r.get("harness_valid") is True + for r in rows), "ONLINE-4B harness lacks full evidence") + if report.get("containment_qualified_offline") is True: + require(harness and all(r.get("status") in ( + "NORMAL_COMPLETION","SAFE_CONTINUATION","RECOVERED", + "SAFE_SUSPENSION") for r in rows), + "ONLINE-4B containment has unsafe or unverified outcome") + if report.get("recovery_qualified_offline") is True: + require(report.get("containment_qualified_offline") is True, + "offline recovery without containment") + needed=("R02","R03","R08","R13") + require(all(any(r.get("scenario")==name and + r.get("status")=="RECOVERED" and + r.get("recovered") is True for r in rows) + for name in needed), "missing fresh recovery witness") + return {"claim":"recovery_qualified_offline","consistent":True} + if kind=="release_smoke": + if report.get("offline_smoke_qualified") is True: + cases=report.get("verified_cases") + require(type(cases) is dict and set(cases)=={ + "r00-white","r00-black","r02-white","r10-white"} and + report.get("failures")==[] and + all(type(row) is dict and + row.get("independent_qualified") is True and + row.get("persisted_sqlite_verified") is True + for row in cases.values()), + "RELEASE-1 smoke lacks independent chess/SQLite evidence") + return {"claim":"offline_smoke_qualified","consistent":True} + if kind=="release_aggregate": + require(report.get("release_blocked") is True and + report.get("release_promotion_authorized") is False and + report.get("license_approved") is False and + report.get("destination_host_qualified") is False, + "RELEASE-1 no-publication boundary changed") + if report.get("image_integrity_qualified") is True: + import re + evidence=report.get("oci_evidence") + require(type(evidence) is dict and + all(evidence.get(key) is True for key in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified", + "oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")) and + type(report.get("oci_archive_sha256")) is str and + re.fullmatch(r"[0-9a-f]{64}", + report["oci_archive_sha256"]) is not None and + type(report.get("image_manifest_digest")) is str and + re.fullmatch(r"sha256:[0-9a-f]{64}", + report["image_manifest_digest"]) is not None, + "image integrity missing OCI archive, SBOM or provenance proof") + smoke=report.get("smoke") + require(type(smoke) is dict and set(smoke)=={ + "r00-white","r00-black","r02-white","r10-white"} and + all(type(row) is dict and row.get("passed") is True and + type(row.get("independent_evidence")) is dict and + row["independent_evidence"].get("independent_qualified") is True + for row in smoke.values()), + "image integrity lacks verified packaged games") + if report.get("offline_candidate_qualified") is True: + require(not report.get("blockers") and + report.get("frozen_dependency_lock_qualified") is True and + report.get("license_approved") is True and + report.get("destination_host_qualified") is True and + report.get("network_recovery_qualified") is True, + "candidate green without authorization prerequisites") + return {"claim":"image_integrity_qualified","consistent":True} + raise ValueError("unknown assurance verdict kind") + + +def main() -> int: + ap=argparse.ArgumentParser() + ap.add_argument("--kind",required=True,choices=( + "online4a","online4b","release_smoke","release_aggregate")) + ap.add_argument("--report",type=Path,required=True) + args=ap.parse_args() + verdict=verify_report(json.loads(args.report.read_text()),args.kind) + print(json.dumps(verdict,sort_keys=True)) + return 0 + + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/scripts/verify_source_closure.py b/scripts/verify_source_closure.py new file mode 100644 index 00000000..1e4c0ece --- /dev/null +++ b/scripts/verify_source_closure.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Offline source dependency closure and Git committed-byte verification. + +This is independent of producer manifests. AST imports are discovered from +each executable entrypoint; dynamic imports and non-Python build inputs have +explicit roots. Neither a correct SHA string nor a producer-generated hash +establishes equality with Git's committed source bytes. +""" +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROFILES = { + "online-recovery-v1": { + "policy": "qualification/online-recovery-v1.json", + "field": "attested_source_paths", + "roots": ("deploy/lichess/run_recovery.py", + "scripts/qualify-online-recovery.py", + "tests/lichess/fake_lichess_api.py", + "scripts/verify_source_closure.py"), + "inputs": ("deploy/lichess/Dockerfile.offline", + ".github/workflows/online-recovery.yml", + "qualification/assurance-contract-v1.json", + "qualification/online-recovery-v1.json", + "qualification/http-evidence-contract-v1.json", + "tests/fixtures/lichess/recovery_scenarios.json", + "deploy/lichess/bridge.lock.json"), + }, + "online-recovery-v2": { + "policy": "qualification/online-recovery-v2.json", + "field": "attested_paths", + "roots": ("deploy/lichess/run_recovery_v2.py", + "scripts/qualify-online-recovery-v2.py", + # v2 dynamically loads the independent v1 verifier + "scripts/qualify-online-recovery.py", + "tests/lichess/fake_lichess_api.py", + "scripts/verify_source_closure.py"), + "inputs": ("deploy/lichess/Dockerfile.offline", + ".github/workflows/online-recovery-v2.yml", + "qualification/assurance-contract-v1.json", + "qualification/online-recovery-v1.json", + "qualification/online-recovery-v2.json", + "qualification/http-evidence-contract-v1.json", + "tests/fixtures/lichess/recovery_scenarios.json", + "deploy/lichess/bridge.lock.json"), + }, +} + + +def require(condition: bool, why: str) -> None: + if not condition: + raise ValueError(why) + + +def imported_sources(path: Path, root: Path) -> set[str]: + """Visit local imports; no module imports or application code executed.""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + names = set() + for node in ast.walk(tree): + candidates = [] + if isinstance(node, ast.Import): + candidates.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + module = node.module or "" + candidates.append(module) + # from deploy.lichess import foo imports executable foo.py + candidates.extend(module+"."+alias.name for alias in node.names + if alias.name != "*") + for name in candidates: + if name.startswith(("tests.lichess.", "deploy.lichess.", + "scripts.")): + candidate = name.replace(".", "/") + ".py" + if (root / candidate).is_file(): + names.add(candidate) + return names + + +def expected_sources(profile: str, root: Path = ROOT) -> set[str]: + require(profile in PROFILES, "unknown source closure profile") + setup = PROFILES[profile] + required = set(setup["roots"]) | set(setup["inputs"]) + queue = list(setup["roots"]) + while queue: + path = queue.pop() + require((root / path).is_file(), "source closure import missing: "+path) + for child in imported_sources(root / path, root): + if child not in required: + required.add(child) + queue.append(child) + return required + + +def verify_profile(profile: str, root: Path = ROOT) -> dict: + require(profile in PROFILES, "unknown source closure profile") + setup = PROFILES[profile] + doc = json.loads((root / setup["policy"]).read_text(encoding="utf-8")) + listed = doc.get(setup["field"]) + require(type(listed) is list and len(listed) == len(set(listed)) and + all(type(name) is str for name in listed), + "source closure attestation set is malformed") + required = expected_sources(profile, root) + missing = sorted(required - set(listed)) + require(not missing, "unattested executable/source input: " + ",".join(missing)) + return {"profile":profile,"source_closure_verified":True, + "source_paths_checked":len(listed), + "reachable_executable_paths":len(required), + "deployment_authorized":False} + + +def verify_committed_bytes(source_sha: str, paths: list[str], + root: Path = ROOT) -> None: + """Compare actual loaded files against independently retrieved Git bytes.""" + require(type(source_sha) is str and + re.fullmatch(r"[0-9a-f]{40}", source_sha) is not None, + "source Git commit SHA malformed") + require(type(paths) is list and len(paths) == len(set(paths)), + "source Git byte input list invalid") + for name in paths: + require(type(name) is str and name and + (root/name).is_file() and not (root/name).is_symlink() and + (root/name).resolve().is_relative_to(root.resolve()), + "source Git path invalid/missing/linked") + git = subprocess.run( + ["git", "-C", str(root), "show", source_sha+":"+name], + capture_output=True, timeout=8, check=False) + require(git.returncode == 0, "committed source absent: "+name) + require(hashlib.sha256((root/name).read_bytes()).digest() == + hashlib.sha256(git.stdout).digest(), + "executed source differs from Git commit: "+name) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--profile", choices=sorted(PROFILES), required=True) + ap.add_argument("--source-sha", required=False) + args = ap.parse_args() + report = verify_profile(args.profile) + if args.source_sha: + cfg = PROFILES[args.profile] + paths = json.loads((ROOT/cfg["policy"]).read_text())[cfg["field"]] + verify_committed_bytes(args.source_sha, paths) + report["committed_bytes_verified"] = True + print(json.dumps(report, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/lichess/fake_lichess_api.py b/tests/lichess/fake_lichess_api.py index 07169578..6a83ae2e 100644 --- a/tests/lichess/fake_lichess_api.py +++ b/tests/lichess/fake_lichess_api.py @@ -21,6 +21,8 @@ from tests.lichess.fault_plan import FaultScheduler from tests.lichess.stream_sessions import StreamSessions from tests.lichess.transport_trace import TransportTrace, state_digest +from tests.lichess.http_target_contract import ( + canonical_target, path_of, safe_audit_fields) TOKEN = "offline-fixture-token" GAME_ID = "off3b001" @@ -60,6 +62,25 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass + def parse_request(self): + # CPython normalizes a leading // in BaseHTTPRequestHandler + # *before* do_GET/do_POST. Capture the original wire target + # from raw_requestline to prevent normalization-based bypass. + self._raw_http_target = "" + try: + line = self.raw_requestline.decode("iso-8859-1").rstrip("\r\n") + parts = line.split(" ") + if (len(parts) == 3 and all(parts) and + parts[0] in ("GET", "POST") and + parts[2] in ("HTTP/1.0", "HTTP/1.1")): + self._raw_http_target = parts[1] + except UnicodeError: + pass + return super().parse_request() + + def raw_target(self) -> str: + return getattr(self, "_raw_http_target", "") + def respond(self, status: int, obj: dict): data = json.dumps(obj, separators=(",", ":")).encode() self.send_response(status) @@ -69,13 +90,18 @@ def respond(self, status: int, obj: dict): self.wfile.write(data) self.wfile.flush() if server.trace is not None: + identity = getattr(self, "_move_request_id", None) server.trace.record("response_write_completed", status=status, - path=urllib.parse.urlparse(self.path).path) + path=path_of(self.raw_target()), + **({"request_id": identity} if identity else {})) def drop_connection(self): """An intentional server-side close, not proof of what the client read.""" - server.trace and server.trace.record("connection_close_requested", - path=urllib.parse.urlparse(self.path).path) + if server.trace is not None: + identity = getattr(self, "_move_request_id", None) + server.trace.record("connection_close_requested", + path=path_of(self.raw_target()), + **({"request_id": identity} if identity else {})) self.close_connection = True try: self.connection.shutdown(socket.SHUT_RDWR) @@ -108,12 +134,22 @@ def emit_faultable(self, payload: dict, subscriber) -> bool: server.trace.record("stream_partial_chunk", sid=subscriber.sid) self.drop_connection() return False + if action in ("delay_state", "stale_state"): + predecessor = (server.state_predecessors.get(state_digest(payload)) + if action == "stale_state" else None) + if action == "stale_state" and predecessor is None: + raise RuntimeError("fault state has no authoritative predecessor") + server.trace.record( + "stream_fault_source", sid=subscriber.sid, + action=action, original_sha256=state_digest(payload), + original_moves=payload.get("moves"), + predecessor_sha256=(state_digest(predecessor) + if predecessor is not None else None), + predecessor_moves=(predecessor.get("moves") + if predecessor is not None else None)) if action == "delay_state": - time.sleep(0.4) # bounded transport delay; not a virtual game clock + time.sleep(0.4) # real monotonic delay, not a game-clock mutation if action == "stale_state": - predecessor = server.state_predecessors.get(state_digest(payload)) - if predecessor is None: - raise RuntimeError("fault state has no authoritative predecessor") payload = copy.deepcopy(predecessor) if action == "wrong_game": payload = dict(payload, gameId="untrusted-other-game") @@ -141,6 +177,7 @@ def stream(self, channel: queue.Queue | None, ready: threading.Event, self.send_header("Cache-Control", "no-cache") self.end_headers() ready.set() + partial_chunk_broken = False try: if subscriber is None: if first is not None: @@ -158,11 +195,19 @@ def stream(self, channel: queue.Queue | None, ready: threading.Event, self.chunk(None) continue if not self.emit_faultable(payload, subscriber): + partial_chunk_broken = True break except (BrokenPipeError, ConnectionResetError, OSError): return finally: if subscriber is not None: + if not partial_chunk_broken: + try: + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, OSError): + pass + self.close_connection = True server.sessions.unsubscribe(subscriber) def chunk(self, payload): @@ -172,21 +217,34 @@ def chunk(self, payload): def auth(self) -> bool: if self.headers.get("Authorization") != "Bearer " + TOKEN: + # Even an early rejected request remains an HTTP witness. + self.record(self.command, path_of(self.raw_target()), 401) self.respond(401, {"error": "synthetic token required"}) return False return True - def record(self, kind: str, path: str, status: int): + def record(self, kind: str, path: str, status: int, + request_id: str | None = None): with server.lock: - server.requests.append({ + row = { "monotonic_ms": round((time.monotonic() - server.start_time) * 1000), - "method": kind, "path": path, "status": status - }) + "method": kind, "status": status, + **safe_audit_fields(kind, self.raw_target()), + } + if request_id is not None: + row["request_id"] = request_id + server.requests.append(row) def do_GET(self): + self._move_request_id = None # never reuse an HTTP/1.1 request identity if not self.auth(): return - path = urllib.parse.urlparse(self.path).path + path = path_of(self.raw_target()) + if not canonical_target("GET", self.raw_target()): + safe = safe_audit_fields("GET", self.raw_target())["path"] + self.record("GET", safe, 403) + server.attempted_forbidden.append("GET " + safe) + return self.respond(403, {"error": "offline request-target prohibited"}) if path == "/api/stream/event": self.record("GET", path, 200) if server.sessions is not None: @@ -238,6 +296,7 @@ def do_GET(self): return self.respond(403, {"error": "offline route prohibited"}) def do_POST(self): + self._move_request_id = None # reset on every request, even on reused sockets if not self.auth(): return # requests.Session reuses HTTP/1.1 connections. POST bodies @@ -250,7 +309,13 @@ def do_POST(self): return self.respond(413, {"error": "body too large"}) if length: self.rfile.read(length) - path = urllib.parse.urlparse(self.path).path + path = path_of(self.raw_target()) + if not canonical_target("POST", self.raw_target()): + safe = safe_audit_fields("POST", self.raw_target())["path"] + with server.lock: + server.attempted_forbidden.append("POST " + safe) + self.record("POST", safe, 403) + return self.respond(403, {"error": "offline request-target prohibited"}) if path == "/api/token/test": self.record("POST", path, 200) return self.respond(200, {TOKEN: {"scopes": "bot:play"}}) @@ -288,6 +353,7 @@ def do_POST(self): seq = server.trace.record("move_attempt", path=path, uci=move_text, game_id=GAME_ID, ply_at_receipt=ply) request_id = f"request-{seq}" + self._move_request_id = request_id action = (server.fault.hit("move_post", ply=ply, request_id=request_id) if server.fault is not None else None) if action: @@ -295,23 +361,23 @@ def do_POST(self): endpoint="move_post", request_id=request_id, ply=ply) if action == "drop_before_commit": - self.record("POST", path, 0) + self.record("POST", path, 0, request_id=request_id) return self.drop_connection() if action == "rate_limit_429": - self.record("POST", path, 429) + self.record("POST", path, 429, request_id=request_id) return self.respond(429, {"error": "fault-injected rate limit"}) if not server.started or server.finished.is_set(): - self.record("POST", path, 409) + self.record("POST", path, 409, request_id=request_id) return self.respond(409, {"error": "game inactive"}) if server.board.turn != (server.color == "white"): - self.record("POST", path, 409) + self.record("POST", path, 409, request_id=request_id) return self.respond(409, {"error": "not bot turn"}) try: move = chess.Move.from_uci(move_text) except ValueError: move = None if move is None or move not in server.board.legal_moves: - self.record("POST", path, 400) + self.record("POST", path, 400, request_id=request_id) return self.respond(400, {"error": "illegal move"}) if action == "low_clock_close": side = "white" if server.board.turn else "black" @@ -321,7 +387,8 @@ def do_POST(self): forced_clock_after_ms=(1500 if action == "low_clock_close" else None)) server.accepted.append({"ply": len(server.board.move_stack), "uci": move.uci()}) - self.record("POST", path, 200 if action != "commit_then_504" else 504) + self.record("POST", path, 200 if action != "commit_then_504" else 504, + request_id=request_id) if not server.finished.is_set(): threading.Thread(target=server.opponent_move, daemon=True).start() if action in ("commit_then_close", "low_clock_close", "terminate_bridge"): diff --git a/tests/lichess/http_target_contract.py b/tests/lichess/http_target_contract.py new file mode 100644 index 00000000..07865fff --- /dev/null +++ b/tests/lichess/http_target_contract.py @@ -0,0 +1,76 @@ +"""Canonical HTTP request-target policy for the pinned offline Lichess fixture. + +This is used by the fake server, RELEASE-1 smoke verifier and aggregate. +Do not store arbitrary raw query strings; unexpected values may be secrets. +""" +from __future__ import annotations + +import hashlib +import re + +GAME_ID = "off3b001" +MOVE_PREFIX = f"/api/bot/game/{GAME_ID}/move/" +APPROVED_GETS = frozenset({ + "/api/stream/event", f"/api/bot/game/stream/{GAME_ID}", + "/api/account", "/api/account/playing", "/api/users/status", + f"/game/export/{GAME_ID}", +}) +APPROVED_POSTS = frozenset({ + "/api/token/test", f"/api/challenge/{GAME_ID}/accept", + f"/api/challenge/{GAME_ID}/decline", +}) +UCI = re.compile(r"[a-h][1-8][a-h][1-8][qrbn]?\Z") +SAFE_PATH = re.compile(r"/[A-Za-z0-9_./-]{0,220}\Z") + + +def path_of(target: object) -> str: + """Retain only known whole endpoints, even for rejected raw targets. + + canonical_target separately adjudicates the original wire target. + """ + if type(target) is not str: + return "__invalid_path__" + path = target.split("?", 1)[0].split("#", 1)[0] + if path in APPROVED_GETS or path in APPROVED_POSTS: + return path + if path.startswith(MOVE_PREFIX) and UCI.fullmatch(path[len(MOVE_PREFIX):]): + return path + return "__invalid_path__" + + +def canonical_target(method: object, raw: object) -> bool: + """Admit only raw HTTP origin-form requests matched to pinned bridge usage.""" + if (method not in ("GET", "POST") or not isinstance(raw, str) + or not 1 <= len(raw) <= 255 or not raw.startswith("/") + or raw.startswith("//") or raw.count("?") > 1 + or any(ord(ch) < 33 or ord(ch) > 126 for ch in raw) + or "#" in raw or "%" in raw or "\\" in raw): + return False + path, sep, query = raw.partition("?") + if not SAFE_PATH.fullmatch(path): + return False + if method == "GET": + if path not in APPROVED_GETS: + return False + if not sep: + return True + # The frozen upstream is_online() sends params={"ids": user_id}. + return path == "/api/users/status" and query == "ids=allfatherfixture" + if path in APPROVED_POSTS: + return not sep + if not path.startswith(MOVE_PREFIX) or not UCI.fullmatch( + path[len(MOVE_PREFIX):]): + return False + # Frozen upstream make_move() calls params={"offeringDraw":"true|false"}. + return not sep or query in ("offeringDraw=true", "offeringDraw=false") + + +def safe_audit_fields(method: object, raw: object) -> dict: + """An approved raw target is retained; rejected raw values are hashed.""" + path = path_of(raw) + if canonical_target(method, raw): + return {"path": path, "request_target": raw, + "target_canonical": True} + body = raw.encode("utf-8", errors="replace") if isinstance(raw, str) else b"" + return {"path": path, "request_target": None, "target_canonical": False, + "request_target_sha256": hashlib.sha256(body).hexdigest()} diff --git a/tests/lichess/test_bridge_source_export.py b/tests/lichess/test_bridge_source_export.py new file mode 100644 index 00000000..1dfd2b11 --- /dev/null +++ b/tests/lichess/test_bridge_source_export.py @@ -0,0 +1,66 @@ +"""Untracked, ignored, or modified upstream Python may never run.""" +from __future__ import annotations +import subprocess +import tempfile +import unittest +from pathlib import Path + +from deploy.lichess.fetch_bridge import copy_tracked,verify + + +def git(root,*args): + return subprocess.check_output(["git","-C",str(root),*args], + stderr=subprocess.PIPE,text=True).strip() + + +class BridgeExportTests(unittest.TestCase): + def test_unknown_files_never_enter_tracked_source_export(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d)/"upstream" + root.mkdir() + git(root,"init","--quiet") + (root/"safe.py").write_text("VALUE=1\n") + (root/".gitignore").write_text("ignored.py\n") + git(root,"add","safe.py",".gitignore") + git(root,"-c","user.email=test@example.org","-c","user.name=Test", + "commit","--quiet","-m","frozen") + git(root,"remote","add","origin","https://example.invalid/fixture.git") + lock={"commit":git(root,"rev-parse","HEAD"), + "origin":"https://example.invalid/fixture.git", + "tracked_blobs":{"safe.py":git(root,"hash-object","safe.py")}, + "scope":"ONLINE-3B-OFFLINE-ONLY","live_api_permitted":False} + verify(root,lock) + (root/"ignored.py").write_text("SHADOW=1\n") + (root/"injected.py").write_text("SHADOW=2\n") + with self.assertRaisesRegex(ValueError,"untracked, or ignored"): + verify(root,lock) + dest=Path(d)/"export" + copy_tracked(root,dest,verify_lock=False) + self.assertTrue((dest/"safe.py").is_file()) + self.assertFalse((dest/"ignored.py").exists()) + self.assertFalse((dest/"injected.py").exists()) + (root/"safe.py").write_text("VALUE=999\n") + with self.assertRaisesRegex(ValueError,"modified"): + verify(root,lock) + + + def test_export_prefers_indexed_blob_over_filtered_worktree(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d)/"upstream" + root.mkdir() + git(root,"init","--quiet") + (root/".gitattributes").write_text("*.md text eol=crlf\n") + (root/"README.md").write_bytes(b"original\r\ntracked\r\n") + git(root,"add",".gitattributes","README.md") + git(root,"-c","user.email=test@example.org","-c","user.name=Test", + "commit","--quiet","-m","crlf") + exported=Path(d)/"export" + copy_tracked(root,exported,verify_lock=False) + canonical=subprocess.check_output( + ["git","-C",str(root),"show","HEAD:README.md"]) + self.assertEqual(exported.joinpath("README.md").read_bytes(), canonical) + self.assertEqual(canonical,b"original\ntracked\n") + + +if __name__=="__main__": + unittest.main() diff --git a/tests/lichess/test_fake_api.py b/tests/lichess/test_fake_api.py index 199327fc..d9bfdad1 100644 --- a/tests/lichess/test_fake_api.py +++ b/tests/lichess/test_fake_api.py @@ -39,6 +39,112 @@ def test_post_body_and_session_reuse(self): self.assertEqual(p.status_code, 200) self.assertEqual(p.json()["title"], "BOT") + def test_complete_canonical_target_and_query_values_are_observed(self): + self.assertEqual(self.call("GET", + "/api/users/status?ids=allfatherfixture"), 200) + good = self.fake.requests[-1] + self.assertEqual(good["request_target"], + "/api/users/status?ids=allfatherfixture") + self.assertTrue(good["target_canonical"]) + self.assertEqual(good["path"], "/api/users/status") + # A rejected query is never stored verbatim in downloadable evidence. + for target in ("/api/account?access_token=private-test-secret", + "/api/users/status?ids=unapproved", + "/api/users/status?ids=allfatherfixture&other=yes", + "/api/account?x=1"): + self.assertEqual(self.call("GET", target), 403) + record = self.fake.requests[-1] + self.assertFalse(record["target_canonical"]) + self.assertIsNone(record["request_target"]) + self.assertIn("request_target_sha256", record) + self.assertNotIn("private-test-secret", str(record)) + self.assertEqual(self.call( + "POST", "/api/challenge/"+GAME_ID+"/accept"), 200) + for target in ("/api/bot/game/"+GAME_ID+"/move/e2e4?offeringDraw=false", + "/api/bot/game/"+GAME_ID+"/move/e2e4?offeringDraw=true"): + self.assertIn(self.call("POST", target), (200,400,409)) + record=self.fake.requests[-1] + self.assertTrue(record["target_canonical"]) + self.assertEqual(record["request_target"], target) + + def test_rejected_move_suffixes_not_published(self): + prefix=f"/api/bot/game/{GAME_ID}/move/" + for target in (prefix+"garbage", prefix+"e2e4PRIVATE_CANARY", + prefix+"e2e4/PRIVATE_CANARY", + prefix+"e2e4?secret=PRIVATE_CANARY"): + with self.subTest(target=target): + self.assertEqual(self.call("POST",target),403) + record=self.fake.requests[-1] + self.assertEqual(record["path"], + prefix+"e2e4" if "?" in target + else "__invalid_path__") + self.assertFalse(record["target_canonical"]) + self.assertIsNone(record["request_target"]) + self.assertNotIn("PRIVATE_CANARY",str(record)) + self.assertNotIn("PRIVATE_CANARY", + str(self.fake.attempted_forbidden)) + + def test_raw_trace_artifact_does_not_retain_malformed_path_canaries(self): + from pathlib import Path + from tests.lichess.fault_plan import load_scenarios + self.fake.close() + scenarios=Path(__file__).resolve().parents[2] / ( + "tests/fixtures/lichess/recovery_scenarios.json") + self.fake=FakeLichess("white",load_scenarios(scenarios)["R00"]) + self.fake.start() + for target in ("/api/account/PRIVATE_CANARY", + f"/api/bot/game/{GAME_ID}/move/PRIVATE_CANARY", + "//api/account?secret=PRIVATE_CANARY"): + self.assertEqual(self.call("GET",target),403) + report=json.dumps(self.fake.evidence(),sort_keys=True) + self.assertNotIn("PRIVATE_CANARY",report) + self.assertIn("__invalid_path__",report) + + def test_network_path_bypasses_cpython_normalization_only_if_not_observed(self): + import socket + for target in ("//api/account", "///api/account", + "//api/users/status?ids=allfatherfixture"): + with self.subTest(target=target): + with socket.create_connection(self.fake.http.server_address, + timeout=4) as connection: + connection.settimeout(4) + query=(f"GET {target} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + f"Authorization: Bearer {TOKEN}\r\n" + "Connection: close\r\n\r\n").encode() + connection.sendall(query) + answer=connection.recv(1024) + self.assertIn(b"403",answer) + observed=self.fake.requests[-1] + self.assertEqual(observed["method"],"GET") + self.assertEqual(observed["status"],403) + self.assertFalse(observed["target_canonical"]) + self.assertIsNone(observed["request_target"]) + self.assertEqual(len(observed["request_target_sha256"]),64) + self.assertIn("GET __invalid_path__", + self.fake.attempted_forbidden) + self.assertNotIn(target, str(self.fake.requests)) + self.assertNotIn(target, str(self.fake.attempted_forbidden)) + + def test_raw_double_slash_post_never_becomes_valid_token_endpoint(self): + import socket + with socket.create_connection(self.fake.http.server_address, + timeout=4) as connection: + connection.settimeout(4) + connection.sendall(( + "POST //api/token/test HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + f"Authorization: Bearer {TOKEN}\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n" + ).encode()) + answer=connection.recv(1024) + self.assertIn(b"403",answer) + record=self.fake.requests[-1] + self.assertEqual(record["status"],403) + self.assertFalse(record["target_canonical"]) + self.assertIsNone(record["request_target"]) + def test_duplicate_accept_rejected(self): path = f"/api/challenge/{GAME_ID}/accept" self.assertEqual(self.call("POST", path), 200) diff --git a/tests/lichess/test_recovery_evidence.py b/tests/lichess/test_recovery_evidence.py index 140a7840..25f6a487 100644 --- a/tests/lichess/test_recovery_evidence.py +++ b/tests/lichess/test_recovery_evidence.py @@ -6,6 +6,7 @@ from __future__ import annotations import copy import importlib.util +import json import tempfile import unittest from pathlib import Path @@ -23,13 +24,82 @@ class IndependentEvidenceTests(unittest.TestCase): + def test_replay_manifests_and_routes_cannot_escape_case_directory(self): + import hashlib + with tempfile.TemporaryDirectory() as td: + root=Path(td) + case=root/"case" + entry=case/"replays"/"generation-1" + entry.mkdir(parents=True) + manifest=entry/"manifest.json" + content={"run_id":"offline-test","generation":1, + "position":{"position_id":"0"*64}} + manifest.write_text(json.dumps(content),encoding="utf-8") + route=entry/"route.json" + route.write_text("{}",encoding="utf-8") + record={"manifest":"replays/generation-1/manifest.json", + "sha256":QUALIFIER.digest(manifest), + "run_id":"offline-test","generation":1, + "position_id":"0"*64, + "route_sha256":QUALIFIER.digest(route)} + (case/"replay-index.json").write_text(json.dumps([record])) + QUALIFIER.validate_replays(case) + for bad in ("../external.json","replays/../external.json", + "replays//generation-1/manifest.json"): + with self.subTest(path=bad): + (case/"replay-index.json").write_text( + json.dumps([{**record,"manifest":bad}])) + with self.assertRaises(ValueError): + QUALIFIER.validate_replays(case) + (case/"replay-index.json").write_text(json.dumps([record])) + outside=root/"outside.json" + outside.write_bytes(manifest.read_bytes()) + manifest.unlink() + manifest.symlink_to(outside) + with self.assertRaisesRegex(ValueError,"symlink"): + QUALIFIER.validate_replays(case) + manifest.unlink() + manifest.write_bytes(outside.read_bytes()) + external_route=root/"route.json" + external_route.write_bytes(route.read_bytes()) + route.unlink() + route.symlink_to(external_route) + with self.assertRaisesRegex(ValueError,"symlink"): + QUALIFIER.validate_replays(case) + + def test_independent_online4a_imports_only_from_git_worktree_after_closure(self): + yml=(ROOT/".github/workflows/online-recovery.yml").read_text() + step="Establish Git-committed independent verifier source before imports" + self.assertIn(step,yml) + self.assertIn('git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn('--profile online-recovery-v1 --source-sha "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn( + '"$ONLINE4A_VERIFIED_ROOT/scripts/qualify-online-recovery.py"',yml) + self.assertLess(yml.index(step), + yml.index("Independently reconstruct commits and fault witnesses")) + + def test_http_target_policy_is_source_attested_and_tamper_detected(self): + exact_sha="a"*40 + names=json.loads((ROOT/"qualification/online-recovery-v1.json") + .read_text())["attested_source_paths"] + path="tests/lichess/http_target_contract.py" + self.assertEqual(names.count(path),1) + file_hashes={name:QUALIFIER.digest(ROOT/name) for name in names} + document={"source_commit":exact_sha,"files":file_hashes} + QUALIFIER.verify_sources(document,exact_sha) + forged=copy.deepcopy(document) + forged["files"][path]="0"*64 + with self.assertRaisesRegex(ValueError,"source file identity drift"): + QUALIFIER.verify_sources(forged,exact_sha) + def trace(self): board = chess.Board() before = board.fen() board.push_uci("e2e4") return [ {"seq": 1, "event": "move_attempt", "game_id": "off3b001", - "uci": "e2e4", "ply_at_receipt": 0}, + "uci": "e2e4", "ply_at_receipt": 0, + "path": "/api/bot/game/off3b001/move/e2e4"}, {"seq": 2, "event": "board_commit", "game_id": "off3b001", "origin": "allfather", "request_id": "request-1", "uci": "e2e4", "ply_before": 0, "ply_after": 1, @@ -38,13 +108,24 @@ def trace(self): def game(self): return {"moves": ["e2e4"], "accepted": [{"ply": 1, "uci": "e2e4"}], - "complete": False} + "complete": False, "color":"white"} def test_independently_replays_accepted_move(self): x = QUALIFIER.verify_trace(self.trace(), self.game()) self.assertEqual(x["bot_commits"], 1) self.assertEqual(x["http_attempts"], 1) + def test_legal_trace_move_must_match_actual_url(self): + for path in ("/api/bot/game/off3b001/move/d2d4", + "/api/bot/game/off3b001/move/%65%32e4", + "/api/bot/game/off3b001/move/e2e4?x=1", + "/api/bot/game/off3b001/move/0000"): + rows=self.trace() + rows[0]["path"]=path + with self.subTest(path=path),self.assertRaisesRegex( + ValueError,"move path differs"): + QUALIFIER.verify_trace(rows,self.game()) + def test_rejects_invalid_sequence_and_illegal_commit(self): t = self.trace() for bad in ( @@ -59,6 +140,182 @@ def test_rejects_invalid_sequence_and_illegal_commit(self): with self.subTest(bad=bad), self.assertRaises(ValueError): QUALIFIER.verify_trace(changed, self.game()) + def test_position_illegal_syntactically_valid_attempt_is_not_safe(self): + rows=self.trace() + illegal={"seq":1,"event":"move_attempt","game_id":"off3b001", + "uci":"e7e5","path":"/api/bot/game/off3b001/move/e7e5", + "ply_at_receipt":0} + rows[0]["seq"]=2 + rows[1]["seq"]=3 + rows[1]["request_id"]="request-2" + rows.insert(0,illegal) + result=QUALIFIER.verify_trace(rows,self.game()) + self.assertEqual(len(result["illegal_attempts"]),1) + self.assertEqual(result["illegal_attempts"][0]["uci"],"e7e5") + self.assertEqual(result["illegal_attempts"][0]["reason"], + "position_illegal_or_wrong_turn") + self.assertEqual(result["stale_retry_attempts"],[]) + # A coherent, fully committed R00 game does not erase the illegal + # earlier HTTP attempt, even when it returns HTTP 400. + self.assertFalse(not result["illegal_attempts"]) + + def test_wrong_receipt_ply_is_evidence_corruption(self): + rows=self.trace() + rows[0]["ply_at_receipt"]=1 + with self.assertRaisesRegex(ValueError,"receipt ply"): + QUALIFIER.verify_trace(rows,self.game()) + + def test_second_position_legal_request_may_commit_but_is_unsafe_retry(self): + # R01's first HTTP request drops before commit; the frozen upstream + # may submit a second move on the same unchanged position. The second + # request can commit legally, but doing it blindly is an unsafe witness, + # not an invalid trace or a safe-continuation positive. + rows=self.trace() + first=dict(rows[0]) + rows[0]["seq"]=2 + rows[1]["seq"]=3 + rows[1]["request_id"]="request-2" + rows.insert(0,first) + result=QUALIFIER.verify_trace(rows,self.game()) + self.assertEqual(result["bot_commits"],1) + self.assertEqual(result["http_attempts"],2) + self.assertEqual(len(result["illegal_attempts"]),1) + self.assertEqual(result["illegal_attempts"][0]["reason"], + "repeated_move_submission_same_ply") + self.assertEqual(result["illegal_attempts"][0]["original_request_id"], + "request-1") + + def test_stale_retry_requires_prior_committed_legal_move_and_lost_ack(self): + rows=self.trace() + rows[1]["seq"]=3 + rows.insert(1,{"seq":2,"event":"fault_activated", + "action":"commit_then_close","endpoint":"move_post", + "request_id":"request-1"}) + rows.extend([ + {"seq":4,"event":"connection_close_requested", + "request_id":"request-1"}, + {"seq":5,"event":"move_attempt","game_id":"off3b001", + "uci":"e2e4","path":"/api/bot/game/off3b001/move/e2e4", + "ply_at_receipt":1}, + ]) + observed=QUALIFIER.verify_trace(rows,self.game()) + self.assertEqual(observed["illegal_attempts"],[]) + self.assertEqual(len(observed["stale_retry_attempts"]),1) + self.assertEqual(observed["stale_retry_attempts"][0]["original_request_id"], + "request-1") + without_loss=[r for r in rows if r["event"]!="connection_close_requested"] + # Fill the removed sequence gap to maintain an otherwise valid trace. + without_loss[-1]["seq"]=4 + rejected=QUALIFIER.verify_trace(without_loss,self.game()) + self.assertEqual(rejected["stale_retry_attempts"],[]) + self.assertEqual(len(rejected["illegal_attempts"]),1) + + def test_offline_fault_http_statuses_require_correlated_witness(self): + from scripts.http_evidence_verdict import ( + approved_status, unexpected_statuses) + move="/api/bot/game/off3b001/move/e2e4" + sample={"method":"POST","path":move,"request_id":"request-1", + "status":429} + trace=[{"event":"fault_activated","action":"rate_limit_429", + "endpoint":"move_post","request_id":"request-1"}] + self.assertTrue(approved_status(sample,trace,"rate_limit_429")) + self.assertFalse(approved_status( + {**sample,"status":200},trace,"rate_limit_429")) + for fault,outcome in (("drop_before_commit",0),("commit_then_504",504)): + witnessed=[{"event":"fault_activated","action":fault, + "endpoint":"move_post","request_id":"request-1"}] + self.assertTrue(approved_status( + {**sample,"status":outcome},witnessed,fault)) + self.assertFalse(approved_status( + {**sample,"status":200},witnessed,fault)) + self.assertFalse(approved_status(sample,[],"rate_limit_429")) + self.assertFalse(approved_status(sample,trace,"none")) + self.assertEqual(unexpected_statuses([ + {"method":"GET","path":"/api/account","status":401}, + {"method":"POST","path":"/api/token/test","status":500}, + ],[],"none")[0]["reason"],"unapproved_http_status") + + def test_safe_path_requires_complete_approved_endpoint(self): + from scripts.sanitize_qualification_evidence import safe_path + prefix = "/api/bot/game/off3b001/move/" + for good in ("/api/account", "/api/users/status", "/api/token/test", + prefix + "e2e4", prefix + "e7e8q"): + self.assertEqual(safe_path(good), good) + for bad in (prefix + "garbage", prefix + "0000", + prefix + "e2e4PRIVATE_CANARY", prefix + "e2e4/more", + prefix + "e2e9", "//api/account", + "/arbitrary/path/CANARY", "/api/account%2fprivate"): + with self.subTest(bad=bad): + self.assertEqual(safe_path(bad), "__invalid_path__") + self.assertEqual(safe_path("/api/account?secret=PRIVATE_CANARY"), + "/api/account") + + def test_retained_report_serializer_recursively_removes_canaries(self): + from scripts.sanitize_qualification_evidence import sanitize_report + sample={ + "harness_qualified":False, + "unsafe_witnesses":[{ + "behavior":"UNSAFE_WITNESS", + "early_rate_limit_requests":[ + {"path":"/api/account?access_token=PRIVATE-CANARY", + "request_target":"//private?PRIVATE-CANARY", + "reason":"bad","status":403}], + "unusable_move_submissions":[{ + "path":"/api/account?access_token=PRIVATE-CANARY", + "uci":"PRIVATE-CANARY", "status":400}], + "runner_errors":["PRIVATE-CANARY"], + }], + "harness_failures":[{"error":"PRIVATE-CANARY"}], + "malicious":{"exception":"PRIVATE-CANARY"}, + } + result=sanitize_report(sample) + data=json.dumps(result) + self.assertNotIn("PRIVATE-CANARY",data) + self.assertIn("/api/account",data) + self.assertEqual(sanitize_report({"path": + "/api/bot/game/off3b001/move/garbage"})["path"], + "__invalid_path__") + self.assertTrue(result["unsafe_witnesses"][0]["behavior"]=="UNSAFE_WITNESS") + self.assertFalse(result["harness_qualified"]) + + def test_noncanonical_http_query_never_supports_safe_continuation(self): + good={"method":"GET","path":"/api/account","status":200, + "request_target":"/api/account","target_canonical":True} + self.assertEqual(QUALIFIER.unexpected_raw_targets([good]),[]) + malformed={ + **good,"request_target":"/api/account?access_token=PRIVATE-CANARY", + } + observed=QUALIFIER.unexpected_raw_targets([malformed]) + self.assertEqual(len(observed),1) + self.assertEqual(observed[0]["reason"],"noncanonical_request_target") + self.assertNotIn("PRIVATE-CANARY",str(observed)) + self.assertEqual(QUALIFIER.unexpected_raw_targets([ + {**good,"target_canonical":False}])[0]["reason"], + "noncanonical_request_target") + + def test_move_audit_requires_exact_request_id_and_multiset(self): + first="/api/bot/game/off3b001/move/e2e4" + second="/api/bot/game/off3b001/move/d2d4" + trace=[{"seq":1,"event":"move_attempt", + "path":first,"uci":"e2e4","game_id":"off3b001"}] + http=[{"method":"POST","path":first, + "request_id":"request-1","status":200}] + self.assertEqual(QUALIFIER.verify_exact_http_move_audit(http,trace),1) + invalid=[ + http+[{"method":"POST","path":second,"request_id":"request-7", + "status":200}], + http+[dict(http[0])], + [{**http[0],"request_id":"request-2"}], + [], + ] + for rows in invalid: + with self.subTest(rows=rows),self.assertRaisesRegex( + ValueError,"HTTP move records and trace attempts differ"): + QUALIFIER.verify_exact_http_move_audit(rows,trace) + with self.assertRaisesRegex(ValueError,"traced move endpoint identity"): + QUALIFIER.verify_exact_http_move_audit( + http,[{**trace[0],"uci":"d2d4"}]) + def test_does_not_confuse_http_504_with_uncommitted_move(self): t = self.trace() t.insert(1, {"seq": 2, "event": "fault_activated", "action": "commit_then_504", @@ -96,7 +353,8 @@ def test_null_move_submission_is_unsafe_but_valid_retry_is_not_misclassified(sel {"method": "POST", "path": "/api/bot/game/off3b001/move/garbage", "status": 400}, ] bad = QUALIFIER.invalid_move_submissions(attempts) - self.assertEqual([r["uci"] for r in bad], ["0000", "garbage"]) + self.assertEqual([r["uci"] for r in bad], ["0000", "__redacted__"]) + self.assertNotIn("garbage", repr(bad)) self.assertEqual(QUALIFIER.invalid_move_submissions([attempts[1]]), []) def test_sha256_of_stream_payload_is_stable(self): @@ -104,5 +362,49 @@ def test_sha256_of_stream_payload_is_stable(self): self.assertEqual(state_digest(x), state_digest(dict(reversed(list(x.items()))))) + def test_delay_state_requires_actual_monotonic_gap(self): + witnessed = {"seq": 1, "sid": 4} + base = [ + {**witnessed, "event": "fault_activated", "action": "delay_state"}, + {"seq": 2, "sid": 4, "event": "stream_fault_source", + "action": "delay_state", "monotonic_ns": 1000000000, + "original_sha256": "a"*64, "original_moves": "e2e4"}, + {"seq": 3, "sid": 4, "event": "stream_emit", "action": "delay_state", + "monotonic_ns": 1400000000, "payload_sha256": "a"*64, + "moves": "e2e4"}, + ] + QUALIFIER.verify_state_fault("delay_state", witnessed, [base[-1]], base) + stale = copy.deepcopy(base) + stale[-1]["monotonic_ns"] = 1100000000 + with self.assertRaisesRegex(ValueError, "delay"): + QUALIFIER.verify_state_fault("delay_state", witnessed, [stale[-1]], stale) + + def test_stale_state_must_deliver_authoritative_predecessor(self): + before = chess.Board() + prefen = before.fen() + before.push_uci("e2e4") + trace = [ + {"seq": 1, "event": "board_commit", "uci": "e2e4"}, + {"seq": 2, "sid": 4, "event": "fault_activated", "action": "stale_state"}, + {"seq": 3, "sid": 4, "event": "stream_fault_source", + "action": "stale_state", "monotonic_ns": 2000000000, + "original_sha256": "a"*64, "original_moves": "e2e4", + "predecessor_sha256": "b"*64, "predecessor_moves": ""}, + {"seq": 4, "sid": 4, "event": "stream_emit", "action": "stale_state", + "monotonic_ns": 2000000001, + "payload_sha256": "b"*64, "moves": ""}, + ] + witnessed=trace[1] + QUALIFIER.verify_state_fault("stale_state", witnessed, [trace[-1]], trace) + for change in ( + {"payload_sha256":"a"*64, "moves":"e2e4"}, + {"payload_sha256":"b"*64, "moves":"e2e4"}, + ): + bad=copy.deepcopy(trace) + bad[-1].update(change) + with self.assertRaises(ValueError): + QUALIFIER.verify_state_fault("stale_state", witnessed, [bad[-1]], bad) + + if __name__ == "__main__": unittest.main() diff --git a/tests/lichess/test_recovery_http.py b/tests/lichess/test_recovery_http.py index 5f8e9144..2e635c91 100644 --- a/tests/lichess/test_recovery_http.py +++ b/tests/lichess/test_recovery_http.py @@ -37,6 +37,22 @@ def run_fault(self, case, expected_commit, expected_status=None): finally: fake.close() + def test_attempt_record_retains_actual_upstream_query_target(self): + fake=FakeLichess("white") + fake.start() + try: + reply=requests.get(fake.url+"api/users/status", + params={"ids":"allfatherfixture"}, + headers=HEADERS,timeout=4) + self.assertEqual(reply.status_code,200) + row=fake.evidence()["requests"][-1] + self.assertEqual(row["path"],"/api/users/status") + self.assertEqual(row["request_target"], + "/api/users/status?ids=allfatherfixture") + self.assertTrue(row["target_canonical"]) + finally: + fake.close() + def test_request_reaches_handler_but_drops_before_commit(self): self.run_fault("R01", 0) @@ -143,5 +159,34 @@ def test_terminal_event_repetition_is_observable_not_a_second_commit(self): fake.close() + def test_fenced_http_stream_reaches_eof(self): + fake=FakeLichess("white",load_scenarios(SCENARIOS)["R09"]) + fake.start() + try: + url=fake.url+"api/stream/event" + with requests.get(url,headers=HEADERS,stream=True,timeout=4) as first: + with requests.get(url,headers=HEADERS,stream=True,timeout=4) as second: + self.assertEqual(first.status_code,200) + self.assertEqual(second.status_code,200) + # Must finish rather than hang on an unterminated HTTP/1.1 chunk. + self.assertFalse(any(first.iter_lines(chunk_size=1))) + fake.issue_challenge() + event=json.loads(next(x for x in second.iter_lines(chunk_size=1) if x)) + self.assertEqual(event["type"],"challenge") + finally: + fake.close() + + + def test_every_simulated_move_post_has_correlated_http_request_id(self): + _,data=self.run_fault("R02",1) + attempts=[r for r in data["transport_trace"] if r["event"]=="move_attempt"] + http=[r for r in data["requests"] if + r["method"]=="POST" and "/move/" in r["path"]] + self.assertTrue(attempts) + self.assertEqual( + {f"request-{r['seq']}" for r in attempts}, + {r.get("request_id") for r in http}) + self.assertTrue(all(r["status"]==200 for r in http)) + if __name__ == "__main__": unittest.main() diff --git a/tests/lichess/test_recovery_v2_evidence.py b/tests/lichess/test_recovery_v2_evidence.py index 90dfd12a..a9be670a 100644 --- a/tests/lichess/test_recovery_v2_evidence.py +++ b/tests/lichess/test_recovery_v2_evidence.py @@ -1,7 +1,9 @@ """Independent OFFLINE-4B evidence checks: actual SQLite bytes, not producer flags.""" from __future__ import annotations import copy +import hashlib import importlib.util +import json import tempfile import unittest from pathlib import Path @@ -16,6 +18,61 @@ class RecoveryV2EvidenceTests(unittest.TestCase): + def test_phase_logs_are_unique_exact_and_contained(self): + with tempfile.TemporaryDirectory() as td: + folder=Path(td)/"case" + folder.mkdir() + for phase in (1,2): + (folder/f"bridge-phase-{phase}.log").write_text( + f"sanitized phase {phase}\n",encoding="utf-8") + def record(phase): + path=folder/f"bridge-phase-{phase}.log" + return {"phase":phase,"path":path.name, + "capture":{"sanitized_sha256":qual.hash_file(path), + "sanitized_bytes":path.stat().st_size}} + good=[record(1),record(2)] + qual.verify_phase_logs(folder,good,[{},{}]) + for bad in ( + [good[0],good[0]], + [{**good[0],"phase":2},good[1]], + [{**good[0],"path":"../private.log"},good[1]], + [{**good[0],"path":"/tmp/private.log"},good[1]], + ): + with self.subTest(bad=bad),self.assertRaises(ValueError): + qual.verify_phase_logs(folder,bad,[{},{}]) + original=folder/"bridge-phase-1.log" + external=Path(td)/"external.log" + external.write_bytes(original.read_bytes()) + original.unlink() + original.symlink_to(external) + with self.assertRaisesRegex(ValueError,"linked"): + qual.verify_phase_logs(folder,good,[{},{}]) + + def test_independent_online4b_imports_only_after_git_source_check(self): + yml=(ROOT/".github/workflows/online-recovery-v2.yml").read_text() + step="Verify independent exact-source worktree before importing recovery code" + self.assertIn(step,yml) + self.assertIn('git worktree add --detach "$VERIFIED" "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn('--profile online-recovery-v2 --source-sha "$ALLFATHER_SOURCE_SHA"',yml) + self.assertIn( + '"$ONLINE4B_VERIFIED_ROOT/scripts/qualify-online-recovery-v2.py"',yml) + self.assertLess(yml.index(step), + yml.index("Independently qualify guarded safety")) + + def test_http_target_policy_is_in_exact_guarded_source_closure(self): + exact_sha="b"*40 + names=json.loads((ROOT/"qualification/online-recovery-v2.json") + .read_text())["attested_paths"] + path="tests/lichess/http_target_contract.py" + self.assertEqual(names.count(path),1) + file_hashes={name:qual.hash_file(ROOT/name) for name in names} + document={"source_commit":exact_sha,"files":file_hashes} + qual.attest(document,exact_sha,names) + forged=copy.deepcopy(document) + forged["files"][path]="0"*64 + with self.assertRaisesRegex(ValueError,"source hash mismatch"): + qual.attest(forged,exact_sha,names) + def test_persisted_journal_verifies_and_mutation_is_detected(self): with tempfile.TemporaryDirectory() as d: folder = Path(d) @@ -212,5 +269,189 @@ def test_valid_diagnostic_claims_do_not_authorize_online_release(self): self.assertFalse(value["deployment_host_qualified"]) + def test_guarded_request_ledger_rejects_duplicate_illegal_and_forbidden(self): + gid = "off3b001" + path = f"/api/bot/game/{gid}/move/e2e4" + game = {"game_id": gid, "color": "white", "moves": ["e2e4"], + "forbidden_attempts": []} + requests = [{"method": "POST", "path": path, + "request_target": path, "target_canonical": True, + "status": 200, "request_id": "request-1"}] + trace = [ + {"seq":1,"event":"move_attempt","game_id":gid,"uci":"e2e4", + "path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid,"origin":"allfather", + "uci":"e2e4","ply_before":0}, + ] + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4","history_json":"[]"}] + self.assertEqual( + qual.verify_guarded_requests(requests,trace,game,intents)["unique_legal_move_attempts"], + 1) + invalid_cases = ( + (requests + [{"method":"POST","path":path,"status":409}], + trace + [{"seq":3, "event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":1}],game,intents), + ([{"method":"POST","path":f"/api/bot/game/{gid}/move/e7e5","status":400}], + [{"seq":1,"event":"move_attempt","game_id":gid,"uci":"e7e5", + "path":f"/api/bot/game/{gid}/move/e7e5","ply_at_receipt":0}], + {**game,"moves":[]},intents), + (requests+[{"method":"POST","path":f"/api/bot/game/{gid}/abort","status":403}], + trace, {**game,"forbidden_attempts":[f"POST /api/bot/game/{gid}/abort"]},intents), + (requests,trace,game,[{**intents[0],"history_json":'["d2d4"]'}]), + ) + for mutated in invalid_cases: + with self.subTest(mutated=mutated),self.assertRaises(ValueError): + qual.verify_guarded_requests(*mutated) + + def test_guarded_failed_nonmove_endpoint_never_qualifies(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + base=[{"method":"POST","path":path,"request_target":path, + "target_canonical":True,"request_id":"request-1","status":200}] + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + for method,target,status in (("GET","/api/account",401), + ("GET","/api/account/playing",503), + ("POST","/api/token/test",500), + ("POST",f"/api/challenge/{gid}/accept",409)): + with self.subTest(target=target,status=status),self.assertRaisesRegex( + ValueError,"unqualified status"): + qual.verify_guarded_requests( + base+[{"method":method,"path":target, + "request_target":target,"target_canonical":True, + "status":status}],trace,game,intents) + + def test_guarded_audit_cannot_swap_valid_move_request_ids(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + for rid in ("request-2", "request-unseen", None): + with self.subTest(request_id=rid),self.assertRaisesRegex( + ValueError,"request ID|request IDs"): + qual.verify_guarded_requests( + [{"method":"POST","path":path,"request_target":path, + "target_canonical":True,"request_id":rid, + "status":200}],trace,game,intents) + + def test_guarded_raw_get_semantics_cannot_be_forged(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + requests=[{"method":"POST","path":path, + "request_target":path,"target_canonical":True, + "request_id":"request-1","status":200}] + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + expected=qual.verify_guarded_requests( + requests+[{"method":"GET","path":"/api/users/status", + "request_target":"/api/users/status?ids=allfatherfixture", + "target_canonical":True,"status":200}], + trace,game,intents) + self.assertEqual(expected["unique_legal_move_attempts"],1) + for raw in ("//api/account", "/api/account?access_token=secret", + "/api/users/status?ids=other"): + with self.subTest(raw=raw),self.assertRaisesRegex( + ValueError,"guarded HTTP request target"): + qual.verify_guarded_requests( + requests+[{"method":"GET","path":raw.split("?",1)[0].lstrip("/"), + "request_target":raw, + "target_canonical":False,"status":200}], + trace,game,intents) + # Even a producer-controlled "canonical" bit cannot override a + # mismatched raw target with the same approved visible pathname. + with self.assertRaisesRegex(ValueError,"guarded HTTP request target"): + qual.verify_guarded_requests( + requests+[{"method":"GET","path":"/api/account", + "request_target":"/api/account?access_token=x", + "target_canonical":True,"status":200}], + trace,game,intents) + + def test_legal_but_different_guarded_move_url_is_not_traced_intent(self): + gid="off3b001" + path=f"/api/bot/game/{gid}/move/e2e4" + requests=[{"method":"POST","path":path,"request_target":path, + "target_canonical":True,"status":200, + "request_id":"request-1"}] + # d2d4 is independently legal, committed and durably prepared; + # the actual server URL submitted e2e4 instead. + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"d2d4","path":path,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"d2d4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["d2d4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"d2d4", + "history_json":"[]"}] + with self.assertRaisesRegex(ValueError, + "guarded move URL differs from traced"): + qual.verify_guarded_requests(requests,trace,game,intents) + + def test_encoded_move_url_aliases_are_rejected(self): + gid="off3b001" + encoded=f"/api/bot/game/{gid}/move/%65%32e4" + trace=[{"seq":1,"event":"move_attempt","game_id":gid, + "uci":"e2e4","path":encoded,"ply_at_receipt":0}, + {"seq":2,"event":"board_commit","game_id":gid, + "uci":"e2e4","origin":"allfather","ply_before":0}] + game={"game_id":gid,"color":"white","moves":["e2e4"], + "forbidden_attempts":[]} + intents=[{"game_id":gid,"ply_before":0,"move":"e2e4", + "history_json":"[]"}] + with self.assertRaisesRegex(ValueError,"guarded HTTP request target"): + qual.verify_guarded_requests( + [{"method":"POST","path":encoded,"request_target":encoded, + "target_canonical":True,"status":200, + "request_id":"request-1"}], + trace,game,intents) + + def test_journal_history_must_equal_authoritative_prefix(self): + from deploy.lichess.reconcile import position_id + gid, epoch = "off3b001", "a"*32 + game = {"game_id":gid,"moves":["e2e4","e7e5"]} + journal = {"owner_epoch":epoch,"events":[{"event_id":1,"event":"observe"}], + "games":[], + "intents":[{"game_id":gid,"ply_before":2,"move":"g1f3", + "history_json":'["e2e4", "e7e5"]', + "position_id":position_id(("e2e4","e7e5")), + "status":"PREPARED"}]} + result={"phases":[{"epoch":epoch}]} + guarded=[{"event":"authoritative_snapshot_observed"}] + qual.verify_journal(journal,[],game,result,guarded) + wrong=copy.deepcopy(journal) + wrong["intents"][0]["history_json"]='["d2d4", "d7d5"]' + wrong["intents"][0]["position_id"]=position_id(("d2d4","d7d5")) + with self.assertRaisesRegex(ValueError,"authoritative game history"): + qual.verify_journal(wrong,[],game,result,guarded) + + def test_overlay_verifier_compares_keys_and_digests_not_counts(self): + expected={"upstream_commit":"a"*40,"patched_upstream":{ + "lib/lichess.py":{"git_blob_sha1":"b"*40,"patched_sha256":"c"*64}}, + "guard_modules":{"guarded_transport.py":"d"*64}, + "mode":"OFFLINE-4B-ONLY","public_bot_release":False} + qual.verify_overlay_identity(expected,copy.deepcopy(expected)) + mutated=copy.deepcopy(expected) + mutated["patched_upstream"]["lib/lichess.py"]["patched_sha256"]="e"*64 + with self.assertRaisesRegex(ValueError,"overlay"): + qual.verify_overlay_identity(mutated,expected) + + if __name__ == "__main__": unittest.main() diff --git a/tests/release/test_assurance_contract.py b/tests/release/test_assurance_contract.py new file mode 100644 index 00000000..2904fff9 --- /dev/null +++ b/tests/release/test_assurance_contract.py @@ -0,0 +1,228 @@ +"""Independent negative oracles for source closure and verdict implications.""" +from __future__ import annotations +import copy +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def load(path,ident): + spec=importlib.util.spec_from_file_location(ident,ROOT/path) + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +closure=load("scripts/verify_source_closure.py","assurance_closure") +claims=load("scripts/verify_assurance_contract.py","assurance_claims") +sanitizer=load("scripts/sanitize_qualification_evidence.py","assurance_safe_output") + + +class AssuranceContractTests(unittest.TestCase): + def test_all_positive_claims_have_explicit_proof_requirements(self): + p=claims.validate_contract() + self.assertFalse(p["public_bot_release"]) + self.assertFalse(p["authority"]["public_distribution"]) + for key,entry in p["claims"].items(): + self.assertTrue(entry["requires"],key) + self.assertTrue(entry["does_not_imply"],key) + + def test_exact_recovery_source_closure_is_transitive(self): + for profile in ("online-recovery-v1","online-recovery-v2"): + with self.subTest(profile=profile): + self.assertTrue(closure.verify_profile(profile)[ + "source_closure_verified"]) + policy=json.loads((ROOT/"qualification/online-recovery-v2.json").read_text()) + names=set(policy["attested_paths"]) + for name in ("tests/lichess/fault_plan.py", + "deploy/lichess/run_recovery.py", + "deploy/lichess/log_capture.py", + "deploy/lichess/Dockerfile.offline", + "scripts/verify_source_closure.py", + "qualification/assurance-contract-v1.json"): + self.assertIn(name,names) + + def test_git_committed_bytes_catch_mutated_executable_worktree(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td) + subprocess.run(["git","-C",str(root),"init","-q"], + check=True,capture_output=True) + subprocess.run(["git","-C",str(root),"config", + "user.email","offline@example.test"],check=True) + subprocess.run(["git","-C",str(root),"config", + "user.name","Offline Test"],check=True) + source=root/"deploy/lichess/fault_plan.py" + source.parent.mkdir(parents=True) + source.write_text("STATE='attested'\n") + subprocess.run(["git","-C",str(root),"add","."],check=True) + subprocess.run(["git","-C",str(root),"commit","-qm","freeze"], + check=True) + sha=subprocess.check_output( + ["git","-C",str(root),"rev-parse","HEAD"],text=True).strip() + path="deploy/lichess/fault_plan.py" + closure.verify_committed_bytes(sha,[path],root) + source.write_text("STATE='tampered'\n") + with self.assertRaisesRegex(ValueError, + "executed source differs from Git"): + closure.verify_committed_bytes(sha,[path],root) + + def test_dynamic_executable_inputs_cannot_be_dropped_from_attestation(self): + dynamic={ + "qualification/http-evidence-contract-v1.json", + "tests/fixtures/lichess/recovery_scenarios.json", + "deploy/lichess/bridge.lock.json", + } + for profile,policy in ( + ("online-recovery-v1","qualification/online-recovery-v1.json"), + ("online-recovery-v2","qualification/online-recovery-v2.json"), + ): + required=closure.expected_sources(profile) + self.assertTrue(dynamic.issubset(required)) + self.assertIn(policy,required) + original=closure.PROFILES[profile]["inputs"] + try: + # A producer-controlled profile cannot shrink independent + # expected imports/dynamic contract inputs. + config=closure.PROFILES[profile] + self.assertTrue(set(original).issubset(required)) + self.assertTrue(closure.verify_profile(profile)[ + "source_closure_verified"]) + finally: + closure.PROFILES[profile]["inputs"]=original + + def test_dependency_parser_catches_unattested_nested_import(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td) + target=root/"deploy/lichess" + target.mkdir(parents=True) + a=target/"root.py" + b=target/"nested.py" + a.write_text("from deploy.lichess import nested\n") + b.write_text("VALUE=1\n") + deps=closure.imported_sources(a,root) + self.assertIn("deploy/lichess/nested.py",deps) + b.unlink() + self.assertNotIn("deploy/lichess/nested.py", + closure.imported_sources(a,root)) + + def test_contradictory_online4b_claims_are_rejected(self): + good={ + "public_bot_release":False, "source_commit":"a"*40, + "network_recovery_qualified":False, + "harness_qualified":True,"required_run_count":1, + "validated_run_count":1,"qualification_failures":[], + "scenario_results":[{"harness_valid":True,"scenario":"R00", + "status":"NORMAL_COMPLETION"}], + "containment_qualified_offline":True, + "recovery_qualified_offline":False, + } + claims.verify_report(good,"online4b") + forged={**good,"recovery_qualified_offline":True} + with self.assertRaisesRegex(ValueError,"missing fresh recovery witness"): + claims.verify_report(forged,"online4b") + corrupt={**good,"scenario_results":[{"harness_valid":True, + "status":"UNSAFE_WITNESS"}]} + with self.assertRaisesRegex(ValueError,"unsafe or unverified"): + claims.verify_report(corrupt,"online4b") + + def test_release_promotion_cannot_be_inferred_from_dependency_locks(self): + report={ + "public_bot_release":False, + "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "release_blocked":True,"release_promotion_authorized":False, + "license_approved":False,"destination_host_qualified":False, + "image_integrity_qualified":False, + "frozen_dependency_lock_qualified":True, + "offline_candidate_qualified":False, + "blockers":["controller_license_review"], + } + claims.verify_report(report,"release_aggregate") + forged={**report,"offline_candidate_qualified":True} + with self.assertRaisesRegex(ValueError,"authorization prerequisites"): + claims.verify_report(forged,"release_aggregate") + + def test_four_games_without_oci_provenance_do_not_grant_image_integrity(self): + complete={key:{"passed":True, + "independent_evidence":{"independent_qualified":True}} + for key in ("r00-white","r00-black","r02-white","r10-white")} + report={ + "public_bot_release":False, + "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "release_blocked":True, + "release_promotion_authorized":False, + "license_approved":False, + "destination_host_qualified":False, + "image_integrity_qualified":True, + "offline_candidate_qualified":False, + "smoke":complete, + "oci_archive_sha256":"a"*64, + "image_manifest_digest":"sha256:"+"b"*64, + "oci_evidence":{}, + } + with self.assertRaisesRegex(ValueError,"OCI archive, SBOM"): + claims.verify_report(report,"release_aggregate") + report["oci_evidence"]={key:True for key in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")} + claims.verify_report(report,"release_aggregate") + + def test_forged_image_integrity_needs_four_independent_games(self): + report={ + "public_bot_release":False, "network_recovery_qualified":False, + "artifact_distribution_authorized":False, + "release_blocked":True,"release_promotion_authorized":False, + "license_approved":False,"destination_host_qualified":False, + "image_integrity_qualified":True,"smoke":{}, + "offline_candidate_qualified":False, + "oci_archive_sha256":"a"*64, + "image_manifest_digest":"sha256:"+"b"*64, + "oci_evidence":{key:True for key in ( + "integrity_verified","oci_layers_verified", + "oci_diff_ids_verified","oci_application_identity_verified", + "oci_attestation_references_verified", + "oci_attestation_subjects_verified", + "oci_sbom_verified","oci_provenance_verified")}, + } + with self.assertRaisesRegex(ValueError,"verified packaged games"): + claims.verify_report(report,"release_aggregate") + + def test_fixed_enum_allowlist_rejects_secret_like_valid_tokens(self): + for key in ("reason","origin","status","behavior","action", + "event","scenario","endpoint"): + with self.subTest(field=key): + cleaned=sanitizer.sanitize_report({key:"PRIVATE_CANARY"}) + self.assertEqual(cleaned[key],"__redacted__") + self.assertEqual(sanitizer.sanitize_report({ + "reason":"noncanonical_request_target"})["reason"], + "noncanonical_request_target") + + def test_safe_output_never_exports_nested_secret_canaries(self): + doc={ + "public_bot_release":False, + "unsafe_witnesses":[{"behavior":"UNSAFE_WITNESS", + "unusable_move_submissions":[ + {"path":"/api/account?access_token=PRIVATE-CANARY", + "uci":"PRIVATE-CANARY","status":401}], + "early_rate_limit_requests":[ + {"path":"/api/account?access_token=PRIVATE-CANARY", + "request_target":"//PRIVATE-CANARY", + "error":"PRIVATE-CANARY"}], + "runner_errors":["PRIVATE-CANARY"]}], + "failure":{"message":"PRIVATE-CANARY"}, + "harness_failures":[{"error":"PRIVATE-CANARY"}], + } + out=sanitizer.serialize_report(doc) + self.assertNotIn("PRIVATE-CANARY",out) + self.assertIn("/api/account",out) + self.assertIn("UNSAFE_WITNESS",out) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_aggregate.py b/tests/release/test_release_aggregate.py index 05d3a4e5..a41b53c7 100644 --- a/tests/release/test_release_aggregate.py +++ b/tests/release/test_release_aggregate.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +from contextlib import nullcontext import json import tempfile import unittest @@ -16,6 +17,15 @@ spec.loader.exec_module(aggregate) +def smoke_http_for_tests(): + """Explicit test-only loading of the unmodified shared HTTP contract.""" + spec=importlib.util.spec_from_file_location( + "release1_http_for_aggregate_test",ROOT/"scripts/release_http_contract.py") + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def write_json(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, sort_keys=True) + "\n", encoding="utf-8") @@ -28,6 +38,8 @@ def setUp(self): self.root = Path(temp.name) self.stage = self.root / "stage" self.oci = self.root / "oci.json" + self.oci_archive = self.root / "image.oci.tar" + self.oci_archive.write_bytes(b"synthetic verified offline archive") self.host = self.root / "host.json" self.smoke_dir = self.root / "smoke" self.os_inventory = self.root / "os.txt" @@ -51,11 +63,20 @@ def setUp(self): }) write_json(self.oci, { "integrity_verified": True, + "oci_layers_verified": True, + "oci_diff_ids_verified": True, + "oci_application_identity_verified": True, + "oci_attestation_references_verified": True, + "oci_attestation_subjects_verified": True, + "oci_sbom_verified": True, + "oci_provenance_verified": True, "public_bot_release": False, "publisher_signature_verified": False, "oci_image_manifest_digest": "sha256:" + "c" * 64, "oci_config_digest": "sha256:" + "d" * 64, - "oci_archive_sha256": "e" * 64, + "oci_archive_sha256": aggregate.digest(self.oci_archive), + "oci_exported_root_digest": "sha256:" + "e"*64, + "buildkit_digest": "sha256:" + "e"*64, }) write_json(self.host, { "test_host_observed": True, @@ -74,7 +95,13 @@ def setUp(self): write_json(self.smoke_dir / key / "result.json", { "offline": True, "ready": True, "runner_errors": [], "public_bot_release": False, - "game": {"complete": key != "r10-white", "accepted": ["e2e4"]}, + "game": {"complete": key != "r10-white", "accepted": ["e2e4"], + "forbidden_attempts": []}, + "http": [{"method": "POST", "path": "/api/token/test", + "request_target": "/api/token/test", + "target_canonical":True, "status": 200}], + "rate_cooldown_verified": key == "r10-white", + "fresh_second_epoch_commits": 1 if key == "r02-white" else None, "observations": [{"phase": "synthetic"}], }) @@ -88,12 +115,23 @@ def git(self, command, **kwargs): raise AssertionError("unexpected git command") def qualify(self, **overrides): - arguments = dict(stage=self.stage, oci=self.oci, host=self.host, + arguments = dict(stage=self.stage, oci=self.oci, + oci_archive=self.oci_archive, host=self.host, smoke_dir=self.smoke_dir, os_inventory=self.os_inventory, wheel_inventory=self.wheel_inventory, source_sha=self.source, root=self.root) arguments.update(overrides) - with mock.patch.object(aggregate.subprocess, "check_output", side_effect=self.git): + with mock.patch.object(aggregate.subprocess, "check_output", side_effect=self.git), \ + mock.patch.object(aggregate,"verified_release_snapshot", + return_value=nullcontext(ROOT)), \ + mock.patch.object(aggregate, "check_smoke_evidence", + side_effect=lambda path,key,*args: {"independent_qualified":True, + "persisted_sqlite_verified":True, + "release_blocked":True, + "run_id":key, + "scenario":key.split("-")[0].upper(), + "color":key.split("-")[1], + "public_bot_release":False}): return aggregate.qualify(**arguments) def test_sha1_git_vs_sha256_artifacts(self): @@ -111,6 +149,74 @@ def test_sha1_git_vs_sha256_artifacts(self): with self.subTest(digest=bad, prefixed=prefixed): self.assertFalse(aggregate.is_sha256_hex(bad, prefixed=prefixed)) + def test_forged_get_does_not_pass_aggregate(self): + case = "r00-white" + target = self.smoke_dir/case/"result.json" + original = json.loads(target.read_text()) + for path, status in (("/api/account/email",200), + ("/api/account",401), + ("/api/stream/event?token=secret",200)): + with self.subTest(path=path): + target.write_text(json.dumps({**original, + "http":original["http"]+[{ + "method":"GET","path":path,"status":status}]})) + with self.assertRaisesRegex(ValueError, + "HTTP contains prohibited request"): + self.qualify() + target.write_text(json.dumps(original)) + + def test_http_admission_is_consistent_with_independent_verifier(self): + token={"method":"POST","path":"/api/token/test", + "request_target":"/api/token/test", + "target_canonical":True,"status":200} + move={"method":"POST", + "path":"/api/bot/game/off3b001/move/e2e4", + "request_target":"/api/bot/game/off3b001/move/e2e4", + "target_canonical":True, + "status":200,"request_id":"request-1"} + self.assertTrue(aggregate.allowed_release_http(token,"r00-white",verified_contract=smoke_http_for_tests())) + self.assertTrue(aggregate.allowed_release_http(move,"r00-white",verified_contract=smoke_http_for_tests())) + for bad in ({**move,"status":400}, + {**move,"status":429}, + {**move,"request_id":None}, + {**move,"path":"/api/bot/game/off3b001/abort"}, + {**token,"status":500}, + {**token,"status":True}): + self.assertFalse(aggregate.allowed_release_http(bad,"r00-white",verified_contract=smoke_http_for_tests())) + self.assertTrue(aggregate.allowed_release_http( + {**move,"status":429},"r10-white",verified_contract=smoke_http_for_tests())) + + def test_aggregate_requires_persisted_sqlite_qualified_and_specific_case(self): + for mutation in ("without-sqlite","wrong-case","wrong-disposition"): + def fake(path,key,*args): + report={"independent_qualified":True, + "persisted_sqlite_verified":True, + "release_blocked":True, + "run_id":key,"scenario":key.split("-")[0].upper(), + "color":key.split("-")[1],"public_bot_release":False} + if key=="r10-white": + if mutation=="without-sqlite": + report["persisted_sqlite_verified"]=False + elif mutation=="wrong-case": + report["scenario"]="R00" + else: + report["release_blocked"]=False + return report + with self.subTest(mutation=mutation), \ + mock.patch.object(aggregate.subprocess,"check_output", + side_effect=self.git), \ + mock.patch.object(aggregate,"verified_release_snapshot", + return_value=nullcontext(ROOT)), \ + mock.patch.object(aggregate,"check_smoke_evidence", + side_effect=fake): + with self.assertRaisesRegex(ValueError,"chess/HTTP/SQLite"): + aggregate.qualify( + stage=self.stage,oci=self.oci, + oci_archive=self.oci_archive,host=self.host, + smoke_dir=self.smoke_dir,os_inventory=self.os_inventory, + wheel_inventory=self.wheel_inventory, + source_sha=self.source,root=self.root) + def test_valid_synthetic_aggregate_cannot_promote_public_release(self): report = self.qualify() self.assertEqual(report["source_commit"], self.source) @@ -191,5 +297,64 @@ def test_rejects_host_and_licensing_authority_escalation(self): self.qualify() + def test_aggregate_rejects_forbidden_requests_and_fake_cooldown(self): + key = "r10-white" + path = self.smoke_dir / key / "result.json" + original = json.loads(path.read_text()) + for bad in ( + {**original, "game": {**original["game"], + "forbidden_attempts": ["POST /api/bot/game/off3b001/abort"]}}, + {**original, "http": original["http"] + + [{"method":"POST","path":"/api/bot/game/off3b001/abort","status":403}]}, + {**original, "rate_cooldown_verified": False}, + ): + with self.subTest(bad=bad): + write_json(path, bad) + with self.assertRaises(ValueError): + self.qualify() + write_json(path, original) + + def test_aggregate_requires_new_epoch_confirmed_move(self): + path = self.smoke_dir / "r02-white/result.json" + doc = json.loads(path.read_text()) + write_json(path, {**doc, "fresh_second_epoch_commits": 0}) + with self.assertRaisesRegex(ValueError, "no fresh confirmed move"): + self.qualify() + + + def test_no_producer_ready_bit_bypasses_independent_checker(self): + path=self.smoke_dir/"r00-white"/"result.json" + original=json.loads(path.read_text()) + # The test deliberately leaves the producer saying ready=true. + self.assertTrue(original["ready"]) + with mock.patch.object(aggregate.subprocess,"check_output",side_effect=self.git), \ + mock.patch.object(aggregate,"verified_release_snapshot", + return_value=nullcontext(ROOT)), \ + mock.patch.object(aggregate,"check_smoke_evidence", + side_effect=ValueError("illegal request in independently replayed trace")): + with self.assertRaisesRegex(ValueError,"illegal request"): + aggregate.qualify( + stage=self.stage,oci=self.oci, + oci_archive=self.oci_archive,host=self.host, + smoke_dir=self.smoke_dir,os_inventory=self.os_inventory, + wheel_inventory=self.wheel_inventory,source_sha=self.source, + root=self.root) + + + def test_atomically_replaced_archive_fails_downstream_verdict(self): + import os + with tempfile.TemporaryDirectory() as d: + replacement=Path(d)/"replacement.oci.tar" + replacement.write_bytes(b"other valid looking but unverified OCI bytes") + os.replace(replacement,self.oci_archive) + with self.assertRaisesRegex(ValueError,"OCI archive identity"): + self.qualify() + + def test_verifier_report_must_match_actual_oci_archive_bytes(self): + self.oci_archive.write_bytes(b"tampered OCI archive") + with self.assertRaisesRegex(ValueError,"OCI archive identity"): + self.qualify() + + if __name__ == "__main__": unittest.main() diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 2ddd2644..9100f331 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -103,6 +103,50 @@ def test_pinned_base_and_apt_snapshot_are_not_placeholders(self): docker.index("COPY deploy/release/ubuntu.sources")) self.assertIn("test -s /etc/ssl/certs/ca-certificates.crt",docker) + def test_pinned_snapshot_retries_are_bounded_without_mirror_fallback(self): + docker=(ROOT/"deploy/release/Dockerfile").read_text() + helper=(ROOT/"deploy/release/install-pinned-apt.sh").read_text() + self.assertIn('while [ "$attempt" -le 4 ]',helper) + self.assertIn('Acquire::Retries=1',helper) + self.assertIn('Acquire::https::Timeout=25',helper) + self.assertIn("apt-get",helper) + self.assertIn('only_transient_acquisition_failures',helper) + self.assertIn('if ! only_transient_acquisition_failures',helper) + self.assertIn("COPY deploy/release/install-pinned-apt.sh",docker) + self.assertIn("/bin/sh /usr/local/bin/install-pinned-apt",docker) + self.assertIn("COPY deploy/release/ubuntu.sources",docker) + instructions = "\n".join( + line for line in docker.splitlines() + if not line.lstrip().startswith("#")) + apt_diagnostic = ( + "E: Unable to fetch some archives, maybe run apt-get update " + "or try with --fix-missing?") + # APT may emit the exact transient-install diagnostic, but this + # must never become a shell command-line flag or fallback option. + self.assertLessEqual(helper.count(apt_diagnostic), 1) + code_without_apt_message = helper.replace(apt_diagnostic, "") + for unsafe in ("--fix-missing", "trusted=yes", "allow-unauthenticated", + "Acquire::https::Verify-Peer=false", + "Acquire::https::Verify-Host=false"): + self.assertNotIn(unsafe,instructions) + self.assertNotIn(unsafe,code_without_apt_message) + + def test_sbom_generator_is_source_locked_and_local_digest_addressed(self): + image=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) + scanner=json.loads((ROOT/image["sbom_generator_policy"]).read_text()) + self.assertIsNone(image["sbom_generator_image"]) + self.assertEqual(scanner["upstream_commit"], + "d88056b4e5b61d0ca037340df91be47d343b4386") + self.assertEqual(scanner["required_file_hash_algorithms"], + ["SHA1","SHA256"]) + self.assertFalse(scanner["public_bot_release"]) + self.assertFalse(scanner["artifact_distribution_authorized"]) + content=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertEqual(content.count("ci-release-scanner.py setup"),2) + self.assertEqual(content.count("ci-release-scanner.py verify-approval"),2) + self.assertIn('SCANNER="$RELEASE_SCANNER_REF"',content) + self.assertIn('--attest "type=sbom,generator=$SCANNER"',content) + def test_readonly_runtime_requires_separate_replay_bind_mount(self): p=json.loads((ROOT/"qualification/release-image-v1.json").read_text()) runtime=json.loads((ROOT/"deploy/release/runtime-contract.json").read_text()) @@ -133,6 +177,7 @@ def test_oci_index_metadata_without_config_uses_exact_loaded_image(self): ("sha256:"+"b"*64, observed), (None, {"Id":"sha256:"+"b"*64,"Architecture":"amd64"}), (None, {"Id":digest,"Architecture":"arm64"}), + (None, {"Id":digest,"Architecture":"amd64","Os":"windows"}), ): with self.subTest(metadata=metadata, inspected=inspection), self.assertRaises(ValueError): oci.verify_image_config_link( @@ -193,5 +238,37 @@ def test_source_tree_staging_requires_clean_exact_checkout(self): Path(temp),Path(temp)) + def test_every_authentication_and_host_release_flag_fails_policy(self): + source = ROOT / "qualification/release-qualification-v1.json" + for flag in ("authenticated_publication_authorized", + "destination_host_qualified", "public_bot_release", + "artifact_distribution_authorized", + "release_promotion_authorized"): + with self.subTest(flag=flag), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + for name in ("release-image-v1.json", + "release-qualification-v1.json", + "license-compliance-v1.json"): + target = root / "qualification" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((ROOT / "qualification" / name).read_bytes()) + target = root / "qualification/release-qualification-v1.json" + policy_doc = json.loads(target.read_text()) + policy_doc[flag] = True + target.write_text(json.dumps(policy_doc)) + with self.assertRaisesRegex(ValueError, flag): + policy.audit(root) + + def test_replay_mount_documentation_matches_ci(self): + ops = (ROOT / "docs/RELEASE_1_OPERATIONS.md").read_text() + workflow = (ROOT / ".github/workflows/release-candidate.yml").read_text() + replay = ":/opt/allfather/build/replays-online-hybrid-v2:rw" + self.assertIn(replay, ops) + self.assertIn(replay, workflow) + for flag in ("--network none", "--read-only", "--cap-drop ALL", + "--security-opt no-new-privileges", "10001:10001"): + self.assertIn(flag, ops) + + if __name__=="__main__": unittest.main() diff --git a/tests/release/test_release_http_contract.py b/tests/release/test_release_http_contract.py new file mode 100644 index 00000000..3d6551a3 --- /dev/null +++ b/tests/release/test_release_http_contract.py @@ -0,0 +1,124 @@ +"""Shared fail-closed HTTP admission across smoke and aggregate.""" +from __future__ import annotations +import importlib.util +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +ROOT=Path(__file__).resolve().parents[2] + +def load(path, ident): + spec=importlib.util.spec_from_file_location(ident,ROOT/path) + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +smoke=load("scripts/qualify-release-smoke.py","release1_smoke_http_test") +aggregate=load("scripts/qualify-release-aggregate.py","release1_aggregate_http_test") + + +class SharedHTTPAdmissionTests(unittest.TestCase): + def test_safe_release_report_module_loads_without_tests_package(self): + import importlib.util as ilu + path=ROOT/"scripts/sanitize_qualification_evidence.py" + spec=ilu.spec_from_file_location("safe_report_test",path) + instance=ilu.module_from_spec(spec) + spec.loader.exec_module(instance) + self.assertEqual(instance.sanitize_report({ + "path":"/api/account?secret=CANARY"})["path"],"/api/account") + self.assertEqual(instance.sanitize_report({ + "path":"/api/bot/game/off3b001/move/garbage"})["path"], + "__invalid_path__") + self.assertEqual(instance.sanitize_report({ + "path":"/api/bot/game/off3b001/move/e2e4"})["path"], + "/api/bot/game/off3b001/move/e2e4") + + def test_safe_output_whitelist_matches_canonical_http_endpoints(self): + from scripts.sanitize_qualification_evidence import ( + SAFE_PUBLIC_PATHS, safe_path) + from tests.lichess.http_target_contract import ( + APPROVED_GETS, APPROVED_POSTS) + self.assertEqual(SAFE_PUBLIC_PATHS, + APPROVED_GETS | APPROVED_POSTS) + prefix="/api/bot/game/off3b001/move/" + self.assertEqual(safe_path(prefix+"e7e8q"),prefix+"e7e8q") + self.assertEqual(safe_path(prefix+"garbage"),"__invalid_path__") + + def test_standalone_release_verifiers_import_policy_without_pythonpath(self): + # Identical invocation mode to the real OCI release workflow. + with tempfile.TemporaryDirectory() as tmp: + environment = {k:v for k,v in os.environ.items() if k != "PYTHONPATH"} + for entry in ("scripts/qualify-release-smoke.py", + "scripts/qualify-release-aggregate.py"): + with self.subTest(cli=entry): + outcome = subprocess.run( + [sys.executable, str(ROOT / entry), "--help"], + cwd=tmp, env=environment, capture_output=True, + text=True, timeout=20) + self.assertEqual(outcome.returncode, 0, outcome.stderr) + self.assertIn("usage:", outcome.stdout.lower()) + + def test_each_allowed_get_and_case_agrees_across_both_verifiers(self): + for case in smoke.CASES: + for path in smoke.http_contract.ALLOWED_GETS: + row={"method":"GET","path":path,"status":200, + "request_target":path,"target_canonical":True} + with self.subTest(case=case,path=path): + self.assertTrue(smoke.http_contract.allowed_release_http( + row,case)) + self.assertEqual(aggregate.allowed_release_http(row,case,smoke.http_contract), + smoke.http_contract.allowed_release_http( + row,case)) + + def test_no_unauthorized_get_or_failed_status_is_admissible(self): + for row in ( + {"method":"GET","path":"/api/account/email","status":200}, + {"method":"GET","path":"/api/account","status":401}, + {"method":"GET","path":"/api/account","status":429}, + {"method":"GET","path":"/api/account","status":200, + "request_id":"move-123"}, + {"method":"GET","path":"/api/account?x=1","status":200}, + {"method":"GET","path":"/api/account","status":True}, + {"method":"POST","path":"/api/token/test","status":500}, + {"method":"POST", + "path":"/api/bot/game/off3b001/move/%65%32e4","status":200, + "request_id":"request-1"}, + {"method":"GET","path":"/api/account","status":200, + "request_target":"/api/account?access_token=leak", + "target_canonical":True}, + {"method":"GET","path":"/api/users/status","status":200, + "request_target":"/api/users/status?ids=unauthorized", + "target_canonical":True}, + ): + with self.subTest(row=row): + self.assertFalse(smoke.http_contract.allowed_release_http( + row,"r00-white")) + self.assertFalse(aggregate.allowed_release_http(row,"r00-white",smoke.http_contract)) + + def test_rejected_raw_query_is_hashed_before_audit(self): + from tests.lichess.http_target_contract import safe_audit_fields + original="/api/account?access_token=PRIVATE-CANARY" + safe=safe_audit_fields("GET",original) + self.assertFalse(safe["target_canonical"]) + self.assertIsNone(safe["request_target"]) + self.assertEqual(safe["path"],"/api/account") + self.assertNotIn("PRIVATE-CANARY",repr(safe)) + self.assertEqual(len(safe["request_target_sha256"]),64) + + def test_legitimate_upstream_query_forms_are_admitted(self): + for method, raw, rid in ( + ("GET", "/api/users/status?ids=allfatherfixture", None), + ("POST", "/api/bot/game/off3b001/move/e2e4?offeringDraw=false", + "request-1"), + ("POST", "/api/bot/game/off3b001/move/e2e4?offeringDraw=true", + "request-1"), + ): + row = {"method":method, "path":raw.split("?")[0], + "request_target":raw, "target_canonical":True, + "status":200,"request_id":rid} + self.assertTrue(aggregate.allowed_release_http(row,"r00-white",smoke.http_contract)) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_oci_integrity.py b/tests/release/test_release_oci_integrity.py new file mode 100644 index 00000000..2af1c7e3 --- /dev/null +++ b/tests/release/test_release_oci_integrity.py @@ -0,0 +1,1228 @@ +"""Destructive OCI proof tests with actual tar/index/blob digests.""" +from __future__ import annotations +import gzip +import hashlib +import importlib.util +import io +import json +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location("release1_oci_integrity", + ROOT/"scripts/verify-release-oci.py") +oci=importlib.util.module_from_spec(spec) +spec.loader.exec_module(oci) + + +def encode(obj): + return json.dumps(obj,sort_keys=True,separators=(",",":")).encode() + + +def sha(body): + return "sha256:"+hashlib.sha256(body).hexdigest() + + +class OCIProofTests(unittest.TestCase): + def fixture(self, target: Path, *, replace_layer=False, bad_subject=False, + bad_reference=False, corrupt_rootfs=False, subject_kind="image", + metadata_kind="root", manifest_subject_kind="none", + invalid_predicate=False, nested=True, wrong_size=None, + slsa_case="valid", spdx_case="valid", + bad_metadata_descriptor=False, + application_platform="linux/amd64", + index_platform="absent", inner_index_platform="absent", + double_nested=False, embedded_media="absent", + config_platform="valid", diagnostic_canary=None, + malformed_diagnostic=None, statement_wrapper="singleton", + omit_slsa=False, duplicate_slsa=False): + base = b"original uncompressed OCI application layer\n" + expanded = (b"substituted uncompressed OCI application layer\n" + if replace_layer else base) + blobs = {} + + def store(data): + oid = sha(data) + blobs["blobs/sha256/" + oid[7:]] = data + return oid + + def descriptor(kind, digest, **extra): + return {"mediaType": kind, "digest": digest, + "size": len(blobs["blobs/sha256/" + digest[7:]]), **extra} + + oci_manifest = "application/vnd.oci.image.manifest.v1+json" + oci_index = "application/vnd.oci.image.index.v1+json" + tar_layer = store(gzip.compress(expanded, mtime=0)) + diff_id = sha(b"mismatched expected diff" if corrupt_rootfs else base) + cfg = { + "architecture": "amd64", "os": "linux", + "rootfs": {"type": "layers", "diff_ids": [diff_id]}, + "config": {"User": "10001:10001", + "Entrypoint": ["/opt/online3b/bin/python", + "/opt/release-tools/deploy/release/entrypoint.py"]} + } + config_changes = { + "valid": {}, + "variant-v3": {"variant": "v3"}, + "valid-baseline": {"variant": "v1", "os.features": [], + "os.version": ""}, + "os-features-text": {"os.features": "avx512"}, + "os-features-extra": {"os.features": ["avx512"]}, + "os-version-extra": {"os.version": "2026"}, + "wrong-os": {"os": "windows"}, + "wrong-arch": {"architecture": "arm64"}, + } + if config_platform not in config_changes: + raise ValueError("unsupported image configuration fixture variant") + cfg.update(config_changes[config_platform]) + config = store(encode(cfg)) + cfg_desc = descriptor( + diagnostic_canary if diagnostic_canary else + "application/vnd.oci.image.config.v1+json", config) + layer_desc = descriptor("application/vnd.oci.image.layer.v1.tar+gzip", + tar_layer) + if wrong_size == "config": + cfg_desc["size"] += 1 + if wrong_size == "layer": + layer_desc["size"] += 1 + application_doc = { + "schemaVersion": 2, "config": cfg_desc, "layers": [layer_desc]} + if malformed_diagnostic == "app-config-list": + application_doc["config"] = [cfg_desc] + elif malformed_diagnostic == "app-layers-null": + application_doc["layers"] = None + elif malformed_diagnostic == "app-layer-scalar": + application_doc["layers"] = [7] + if embedded_media in ("app-valid", "app-wrong"): + application_doc["mediaType"] = ( + oci_manifest if embedded_media == "app-valid" else oci_index) + application = store(encode(application_doc)) + app_desc = descriptor(oci_manifest, application) + if application_platform == "linux/amd64": + app_desc["platform"] = {"os": "linux", "architecture": "amd64"} + elif application_platform == "valid-extras": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "variant": "v1", "os.features": [], + "features": [], "os.version": ""} + elif application_platform == "variant-v3": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "variant": "v3"} + elif application_platform == "os-features-text": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "os.features": "avx512"} + elif application_platform == "os-features-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "os.features": ["avx512"]} + elif application_platform == "features-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "features": ["unqualified"]} + elif application_platform == "os-version-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "os.version": "2026"} + elif application_platform == "unknown-extra": + app_desc["platform"] = {"os": "linux", "architecture": "amd64", + "arbitrary": "not qualified"} + elif application_platform == "linux/arm64": + app_desc["platform"] = {"os": "linux", "architecture": "arm64"} + elif application_platform == "windows/amd64": + app_desc["platform"] = {"os": "windows", "architecture": "amd64"} + elif application_platform == "malformed": + app_desc["platform"] = "linux/amd64" + elif application_platform != "absent": + raise ValueError("unknown synthetic application-platform variant") + att_config = store(encode({"architecture": "unknown", "os": "unknown"})) + attest_layers = [] + for predicate in ("https://spdx.dev/Document", + "https://slsa.dev/provenance/v1"): + if omit_slsa and predicate=="https://slsa.dev/provenance/v1": + continue + subject = {"image": application[7:], "config": config[7:], + "layer": tar_layer[7:], "empty": None}[subject_kind] + if bad_subject: + subject = "f" * 64 + body = ({ + "SPDXID": "SPDXRef-DOCUMENT", + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "name": "AllfatherChess-offline-image", + "documentNamespace": "https://spdx.org/spdxdocs/allfather-release1-fixture", + "creationInfo": { + "created": "2026-10-14T00:00:00Z", + "creators": ["Tool: BuildKit fixture"]}, + "packages": [{ + "SPDXID": "SPDXRef-Package-offline", + "name": "offline-test-package", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "copyrightText": "NOASSERTION", + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION"}], + } if predicate == "https://spdx.dev/Document" else { + "buildDefinition": { + "buildType": "https://mobyproject.org/buildkit@v1", + "externalParameters": {}}, + "runDetails": {"builder": { + "id": "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml"}}}) + if predicate == "https://spdx.dev/Document": + if spdx_case == "missing-license": + del body["dataLicense"] + elif spdx_case == "wrong-document-id": + body["SPDXID"] = "SPDXRef-Other" + elif spdx_case == "missing-name": + del body["name"] + elif spdx_case == "bad-namespace": + body["documentNamespace"] = "invalid" + elif spdx_case == "missing-created": + del body["creationInfo"]["created"] + elif spdx_case == "invalid-created": + body["creationInfo"]["created"] = "2026-99-14T00:00:00Z" + elif spdx_case == "missing-creators": + body["creationInfo"]["creators"] = [] + elif spdx_case == "bad-creator": + body["creationInfo"]["creators"] = ["anonymous"] + elif spdx_case == "empty-inventory": + body["packages"] = [] + elif spdx_case == "bad-package": + body["packages"] = [{"SPDXID":"SPDXRef-Package-X"}] + elif spdx_case == "missing-copyright": + del body["packages"][0]["copyrightText"] + elif spdx_case == "empty-copyright": + body["packages"][0]["copyrightText"] = "" + elif spdx_case == "filesAnalyzed-invalid": + body["packages"][0]["filesAnalyzed"] = "false" + elif spdx_case == "missing-filesAnalyzed-without-code": + del body["packages"][0]["filesAnalyzed"] + elif spdx_case == "filesAnalyzed-without-code": + body["packages"][0]["filesAnalyzed"] = True + elif spdx_case == "unanalysed-with-code": + body["packages"][0]["packageVerificationCode"] = { + "packageVerificationCodeValue": "a"*40} + elif spdx_case == "unanalysed-with-file-licenses": + body["packages"][0]["licenseInfoFromFiles"] = ["MIT"] + elif spdx_case == "invalid-license": + body["packages"][0]["licenseDeclared"] = 3 + elif spdx_case == "invalid-license-expression": + body["packages"][0]["licenseDeclared"] = "not a license expression" + elif spdx_case == "valid-license-expression": + body["packages"][0]["licenseDeclared"] = "(MIT OR Apache-2.0) AND LicenseRef-Local" + body["packages"][0]["licenseConcluded"] = ( + "GPL-2.0-only WITH Classpath-exception-2.0") + elif spdx_case == "valid-license-info-no-code": + body["packages"][0]["filesAnalyzed"] = True + body["packages"][0]["licenseInfoFromFiles"] = ["MIT","Apache-2.0 OR MIT"] + elif spdx_case == "invalid-license-info": + body["packages"][0]["filesAnalyzed"] = True + body["packages"][0]["licenseInfoFromFiles"] = [ + "MIT","PRIVATE_CANARY"] + elif spdx_case.startswith("spdx22-"): + body["spdxVersion"] = "SPDX-2.2" + body["packages"][0]["filesAnalyzed"] = True + body["packages"][0]["packageVerificationCode"] = { + "packageVerificationCodeValue": "a"*40} + if spdx_case == "spdx22-missing-copyright": + del body["packages"][0]["copyrightText"] + elif spdx_case == "spdx22-missing-concluded": + del body["packages"][0]["licenseConcluded"] + elif spdx_case == "spdx22-missing-declared": + del body["packages"][0]["licenseDeclared"] + elif spdx_case == "spdx22-missing-code": + del body["packages"][0]["packageVerificationCode"] + elif spdx_case != "spdx22-valid": + raise ValueError("unknown SPDX 2.2 fixture variant") + elif spdx_case == "missing-download": + del body["packages"][0]["downloadLocation"] + elif spdx_case == "invalid-download": + body["packages"][0]["downloadLocation"] = "garbage" + elif spdx_case == "download-unicode": + body["packages"][0]["downloadLocation"] = "https://example.test/☃" + elif spdx_case == "download-braces": + body["packages"][0]["downloadLocation"] = "https://example.test/{bad}" + elif spdx_case == "download-pipe": + body["packages"][0]["downloadLocation"] = "https://example.test/a|b" + elif spdx_case == "invalid-percent": + body["packages"][0]["downloadLocation"] = "https://example.test/%zz" + elif spdx_case == "invalid-percent-host": + body["packages"][0]["downloadLocation"] = "https://%zz/path" + elif spdx_case == "invalid-port": + body["packages"][0]["downloadLocation"] = "https://example.test:bad/p" + elif spdx_case == "invalid-authority": + body["packages"][0]["downloadLocation"] = "https://bad..host/path" + elif spdx_case == "encoded-control": + body["packages"][0]["downloadLocation"] = "https://example.test/%00" + elif spdx_case == "bad-package-checksum": + body["packages"][0]["checksums"] = [ + {"algorithm":"BOGUS","checksumValue":"0"}] + elif spdx_case == "short-sha256": + body["packages"] = [] + body["files"] = [{"SPDXID":"SPDXRef-File-A", + "fileName":"./opt/example", + "checksums":[{"algorithm":"SHA256", + "checksumValue":"0"}]}] + elif spdx_case == "bogus-algorithm": + body["packages"] = [] + body["files"] = [{"SPDXID":"SPDXRef-File-A", + "fileName":"./opt/example", + "checksums":[{"algorithm":"BOGUS", + "checksumValue":"0"}]}] + elif spdx_case in ("file-only-valid","file-sha256-only", + "file-invalid-sha1","file-duplicate-sha1", + "file-invalid-license"): + body["packages"] = [] + entries=[{"algorithm":"SHA1","checksumValue":"b"*40}, + {"algorithm":"SHA256","checksumValue":"a"*64}] + if spdx_case=="file-sha256-only": + entries=entries[1:] + if spdx_case=="file-invalid-sha1": + entries[0]["checksumValue"]="PRIVATE_CANARY" + if spdx_case=="file-duplicate-sha1": + entries.append({"algorithm":"SHA1","checksumValue":"c"*40}) + file={"SPDXID":"SPDXRef-File-A", + "fileName":"./usr/bin/allfather","checksums":entries} + if spdx_case=="file-invalid-license": + file["licenseConcluded"]="not a license expression" + body["files"]=[file] + elif spdx_case == "missing-file-checksum": + body["packages"] = [] + body["files"] = [{"SPDXID": "SPDXRef-File-A", + "fileName": "./usr/bin/allfather"}] + elif spdx_case == "bad-file-checksum": + body["packages"] = [] + body["files"] = [{"SPDXID": "SPDXRef-File-A", + "fileName": "./usr/bin/allfather", + "checksums": [{"algorithm":"SHA256", + "checksumValue":"not-hex"}]}] + elif spdx_case == "duplicate-id": + body["files"] = [{"SPDXID":"SPDXRef-Package-offline", + "fileName":"./file", + "checksums":[{"algorithm":"SHA256", + "checksumValue":"a"*64}]}] + elif spdx_case == "namespace-bracket-path": + body["documentNamespace"] = "https://example.test/a[b]" + elif spdx_case == "namespace-bracket-query": + body["documentNamespace"] = "https://example.test/?q=[x]" + elif spdx_case == "download-bracket-path": + body["packages"][0]["downloadLocation"] = "https://example.test/a[b]" + elif spdx_case == "namespace-unicode": + body["documentNamespace"] = "https://example.test/snowman-☃" + elif spdx_case == "namespace-braces": + body["documentNamespace"] = "https://example.test/{bad}" + elif spdx_case == "namespace-pipe": + body["documentNamespace"] = "https://example.test/a|b" + elif spdx_case == "namespace-percent": + body["documentNamespace"] = "https://%zz/path" + elif spdx_case == "namespace-port": + body["documentNamespace"] = "https://example.test:bogus/x" + elif spdx_case == "namespace-control": + body["documentNamespace"] = "https://example.test/path\x00" + elif spdx_case == "namespace-userinfo": + body["documentNamespace"] = "https://user:pw@example.test/a" + elif spdx_case == "namespace-encoded-path": + body["documentNamespace"] = "https://example.test/a%zz" + elif spdx_case == "namespace-fragment": + body["documentNamespace"] += "#other" + elif spdx_case == "namespace-trailing-fragment": + body["documentNamespace"] += "#" + elif spdx_case != "valid": + raise ValueError("unknown SPDX fixture variant") + if predicate.endswith("/v1"): + if slsa_case == "empty": + body = {"buildDefinition": {}, "runDetails": {}} + if slsa_case == "builder-empty": + body["runDetails"]["builder"]["id"] = "" + if slsa_case == "other-builder": + body["runDetails"]["builder"]["id"] = "https://github.com/moby/buildkit" + if slsa_case == "bad-build-type": + body["buildDefinition"]["buildType"] = "not-a-uri" + if slsa_case == "no-external": + del body["buildDefinition"]["externalParameters"] + if slsa_case == "resolved-as-string": + body["buildDefinition"]["resolvedDependencies"] = "not-an-array" + if slsa_case == "metadata-as-string": + body["runDetails"]["metadata"] = "not-an-object" + if slsa_case == "byproducts-as-map": + body["runDetails"]["byproducts"] = {} + if slsa_case == "version-as-string": + body["runDetails"]["builder"]["version"] = "not-an-object" + if slsa_case == "resource-item-nonobject": + body["buildDefinition"]["resolvedDependencies"] = [17] + if slsa_case == "resource-empty": + body["buildDefinition"]["resolvedDependencies"] = [{}] + if slsa_case == "resource-name-only": + body["runDetails"]["byproducts"] = [{"name":"not-identity"}] + if slsa_case == "resource-bad-digest": + body["buildDefinition"]["resolvedDependencies"] = [ + {"digest":{"sha256":"not-hex"}}] + if slsa_case == "resource-empty-digest": + body["buildDefinition"]["resolvedDependencies"] = [ + {"digest":{}}] + if slsa_case == "resource-bad-content": + body["buildDefinition"]["resolvedDependencies"] = [ + {"content":"not+base64!"}] + if slsa_case == "metadata-bad-start": + body["runDetails"]["metadata"] = {"startedOn":"not-a-time"} + if slsa_case == "metadata-bad-finish": + body["runDetails"]["metadata"] = {"finishedOn":"garbage"} + if slsa_case == "metadata-reversed": + body["runDetails"]["metadata"] = { + "startedOn":"2026-10-18T00:00:00Z", + "finishedOn":"2026-10-17T00:00:00Z"} + if slsa_case == "valid-optionals": + body["buildDefinition"]["resolvedDependencies"] = [ + {"uri":"https://example.test/source.tar.gz", + "digest":{"sha256":"a"*64}}] + body["buildDefinition"]["internalParameters"] = {} + body["runDetails"]["builder"]["version"] = { + "buildkit":"v0.20"} + body["runDetails"]["metadata"] = { + "invocationId":"offline-test"} + body["runDetails"]["byproducts"] = [] + if slsa_case == "v02" and predicate.endswith("/v1"): + predicate="https://slsa.dev/provenance/v0.2" + body={"builder":{"id": + "https://github.com/HMarcusWH/AllfatherChess/actions/workflows/release-candidate.yml"}, + "buildType":"https://mobyproject.org/buildkit@v1", + "invocation":{}} + if invalid_predicate: + body = {"schema_version": 1} + statement_document = { + "_type": diagnostic_canary or "https://in-toto.io/Statement/v1", + "predicateType": diagnostic_canary or predicate, + "subject": ([] if subject is None else + [{"name": diagnostic_canary or "offline-fixture", + "digest": {"sha256": subject}}]), + "predicate": body} + if predicate == "https://spdx.dev/Document": + if statement_wrapper == "array-bad-subject": + statement_document = {"statements":[ + {**statement_document,"subject":[None]}, + statement_document]} + elif statement_wrapper == "array-empty-predicate": + statement_document = {"statements":[ + {**statement_document,"predicate":{}}, + statement_document]} + elif statement_wrapper == "array-unknown": + statement_document = {"statements":[ + {**statement_document,"predicateType": + "https://unknown.test/forged-predicate"}, + statement_document]} + elif statement_wrapper == "array": + statement_document = {"statements": [statement_document]} + elif statement_wrapper == "array-null": + statement_document = {"statements":[None,statement_document]} + elif statement_wrapper in ("null", "object", "empty", "string"): + statement_document["statements"] = { + "null": None, "object": {}, "empty": [], + "string": "malformed", + }[statement_wrapper] + elif statement_wrapper != "singleton": + raise ValueError("unsupported statement-wrapper fixture") + lid = store(encode(statement_document)) + layer = descriptor("application/vnd.in-toto+json", lid) + if wrong_size == "statement": + layer["size"] += 1 + attest_layers.append(layer) + if duplicate_slsa and predicate.startswith("https://slsa.dev/"): + attest_layers.append(layer) + attest_data = { + "schemaVersion": 2, + "config": descriptor("application/vnd.oci.image.config.v1+json", + att_config), + "layers": attest_layers} + if malformed_diagnostic == "attest-config-list": + attest_data["config"] = [attest_data["config"]] + elif malformed_diagnostic == "attest-layer-null": + attest_data["layers"] = [None] + if manifest_subject_kind != "none": + subject = descriptor(oci_manifest, application) + if manifest_subject_kind == "wrong": + subject["digest"] = "sha256:" + "f"*64 + elif manifest_subject_kind == "missing-size": + del subject["size"] + elif manifest_subject_kind == "boolean-size": + subject["size"] = True + elif manifest_subject_kind == "float-size": + subject["size"] = float(subject["size"]) + elif manifest_subject_kind == "negative-size": + subject["size"] = -1 + elif manifest_subject_kind == "incorrect-size": + subject["size"] += 1 + elif manifest_subject_kind == "wrong-media-type": + subject["mediaType"] = oci_index + elif manifest_subject_kind != "image": + raise ValueError("unknown synthetic subject variant") + attest_data["subject"] = subject + attest_manifest = store(encode(attest_data)) + annotations={"vnd.docker.reference.type": "attestation-manifest", + "vnd.docker.reference.digest": ( + "sha256:" + "f"*64 if bad_reference else application)} + if diagnostic_canary: + annotations["secret.debug"] = diagnostic_canary + att_desc = descriptor(oci_manifest, attest_manifest, + annotations=annotations, + platform={"os": "unknown", "architecture": "unknown"}) + if wrong_size == "application": + app_desc["size"] += 1 + children = [app_desc, att_desc] + + def advertise_index(entry, value): + if value == "absent": + return + if value == "linux/amd64": + entry["platform"] = {"os":"linux", "architecture":"amd64"} + elif value == "linux/arm64": + entry["platform"] = {"os":"linux", "architecture":"arm64"} + elif value == "windows/amd64": + entry["platform"] = {"os":"windows", "architecture":"amd64"} + elif value == "malformed": + entry["platform"] = ["linux", "amd64"] + elif value in ("valid-extras", "variant-v3", "os-features-text", + "os-features-extra", "features-extra", + "os-version-extra", "unknown-extra"): + platform = {"os":"linux", "architecture":"amd64"} + changes = { + "valid-extras": {"variant":"v1", "os.features": [], + "features": [], "os.version": ""}, + "variant-v3": {"variant":"v3"}, + "os-features-text": {"os.features":"bad"}, + "os-features-extra": {"os.features":["bad"]}, + "features-extra": {"features":["bad"]}, + "os-version-extra": {"os.version":"22"}, + "unknown-extra": {"unknown-feature":"bad"}, + } + entry["platform"] = {**platform, **changes[value]} + else: + raise ValueError("unrecognized nested-index platform fixture") + + if nested: + nested_id = store(encode({ + "schemaVersion": 2, + "mediaType": (oci_manifest if embedded_media == "index-wrong" + else oci_index), + "manifests": children})) + if double_nested: + inner_desc = descriptor(oci_index, nested_id) + advertise_index(inner_desc, inner_index_platform) + nested_id = store(encode({ + "schemaVersion": 2, "mediaType": oci_index, + "manifests": [inner_desc]})) + root_desc = descriptor(oci_index, nested_id) + advertise_index(root_desc, index_platform) + outer_children = [root_desc] + else: + root_desc = app_desc + outer_children = children + if wrong_size == "root": + root_desc["size"] += 1 + if wrong_size == "boolean": + root_desc["size"] = True + if wrong_size == "negative": + root_desc["size"] = -1 + index = encode({"schemaVersion": 2, "mediaType": oci_index, + "manifests": outer_children}) + archive = target / "test.oci.tar" + with tarfile.open(archive, "w") as out: + payloads = {"oci-layout": encode({"imageLayoutVersion": "1.0.0"}), + "index.json": index, **blobs} + for name, body in payloads.items(): + info = tarfile.TarInfo(name) + info.size = len(body) + out.addfile(info, io.BytesIO(body)) + meta = target / "metadata.json" + reported = (attest_manifest if metadata_kind == "attestation" + else sha(index) if metadata_kind == "outer" + else application if metadata_kind == "wrong-flat" + else root_desc["digest"]) + reported_desc = dict(root_desc) + if bad_metadata_descriptor: + reported_desc["size"] += 1 + meta.write_text(json.dumps({ + "image.name": diagnostic_canary or "docker.io/allfather/release1:offline", + "containerimage.digest": diagnostic_canary or reported, + "containerimage.descriptor": reported_desc})) + inspected = target / "docker-inspect.json" + inspected.write_text(json.dumps({"Id": config, "Architecture": "amd64"})) + return archive, meta, inspected + + def test_nested_application_platform_matches_config(self): + for kind in ("linux/arm64", "windows/amd64", "malformed", "absent"): + with self.subTest(platform=kind), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), application_platform=kind) + with self.assertRaisesRegex(ValueError, "platform"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_flat_manifest_without_platform_is_supported(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), nested=False, application_platform="absent") + self.assertTrue( + oci.verify_archive(archive, meta, docker_inspect=inspection)[ + "oci_application_identity_verified"]) + + def test_attestation_subject_descriptor_sizes_and_types(self): + for kind in ("missing-size", "boolean-size", "float-size", + "negative-size", "incorrect-size", "wrong-media-type"): + with self.subTest(subject=kind), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), manifest_subject_kind=kind) + with self.assertRaisesRegex(ValueError, "manifest subject"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_valid_optional_attestation_subject_in_nested_image(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), manifest_subject_kind="image") + report = oci.verify_archive(archive, meta, docker_inspect=inspection) + self.assertTrue(report["oci_attestation_references_verified"]) + + def test_nested_document_embedded_media_type_matches_descriptor(self): + for variant in ("app-wrong", "index-wrong"): + with self.subTest(variant=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), embedded_media=variant) + with self.assertRaisesRegex(ValueError, "media type"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), embedded_media="app-valid") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspection)["integrity_verified"]) + + def test_actual_image_config_platform_is_independently_qualified(self): + invalid = ("variant-v3", "os-features-text", "os-features-extra", + "os-version-extra", "wrong-os", "wrong-arch") + for variant in invalid: + with self.subTest(config=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspected = self.fixture( + Path(d), config_platform=variant) + # Fixture rehashes config -> manifest -> nested index -> + # BuildKit metadata, and inspected image ID, by construction. + with self.assertRaisesRegex(ValueError, "OCI runnable platform"): + oci.verify_archive(archive, meta, docker_inspect=inspected) + with tempfile.TemporaryDirectory() as d: + archive, meta, inspected = self.fixture( + Path(d), config_platform="valid-baseline") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspected)["integrity_verified"]) + + def test_runtime_platform_optional_fields_fail_closed(self): + invalid = ("variant-v3", "os-features-text", "os-features-extra", + "features-extra", "os-version-extra", "unknown-extra") + for where in ("root", "inner", "app"): + for variant in invalid: + with self.subTest(where=where, kind=variant): + with tempfile.TemporaryDirectory() as d: + kwargs = ({"index_platform":variant} + if where=="root" else + {"inner_index_platform":variant} + if where=="inner" else + {"application_platform":variant}) + archive, meta, inspection = self.fixture( + Path(d), double_nested=True, **kwargs) + with self.assertRaisesRegex(ValueError, "OCI runnable platform"): + oci.verify_archive(archive, meta, + docker_inspect=inspection) + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), double_nested=True, + index_platform="valid-extras", + inner_index_platform="valid-extras", + application_platform="valid-extras") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspection)["integrity_verified"]) + + def test_nested_index_advertised_platform_must_match_runnable_image(self): + for advertised in ("linux/arm64", "windows/amd64", "malformed"): + for inner in (False, True): + with self.subTest(advertised=advertised, inner=inner): + with tempfile.TemporaryDirectory() as d: + kwargs = ({"inner_index_platform":advertised} + if inner else {"index_platform":advertised}) + archive, meta, inspection = self.fixture( + Path(d), double_nested=inner, **kwargs) + with self.assertRaisesRegex(ValueError, "OCI runnable platform"): + oci.verify_archive(archive, meta, + docker_inspect=inspection) + + def test_valid_advertised_platform_at_every_nested_index_level(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), double_nested=True, + index_platform="linux/amd64", + inner_index_platform="linux/amd64") + self.assertTrue( + oci.verify_archive(archive, meta, + docker_inspect=inspection)["integrity_verified"]) + + def test_supported_spdx_download_locations(self): + accepted = ("NONE", "NOASSERTION", + "https://example.test/archive.tar.gz", + "https://example.test/archive%20name.tar.gz", + "ftp://example.test/private.tar.gz", + "git+https://example.test/repo.git@5d2fa", + "git+ssh://git@example.test/repo.git@5d2fa") + refused = ("", "garbage", "noassertion", "none", "example.test/repo", + "https:///missing-host", "https://example.test/a b", + "https://user:password@example.test/x", + "http://user@example.test/x", "javascript:evil()", + "https://[bad-ipv6/x", "https://example.test:invalid/path", + "https://%zz/path", "https://example.test/a%2", + "https://example.test/%00", "https://bad..host/p", + "https://user@@example.test/p", "NOASSERTION\n") + for location in accepted: + with self.subTest(location=location): + self.assertTrue(oci.valid_spdx_download_location(location)) + for location in refused: + with self.subTest(location=location): + self.assertFalse(oci.valid_spdx_download_location(location)) + + def test_spdx_algorithm_lengths_and_digest_canonicality(self): + for algorithm, width in oci.SPDX_CHECKSUM_HEX_LENGTH.items(): + with self.subTest(algorithm=algorithm): + self.assertTrue(oci.valid_spdx_checksums([ + {"algorithm": algorithm, "checksumValue": "a" * width}])) + for bad in ("a"*(width-1), "A"*width, "g"*width, "a"*(width+1)): + self.assertFalse(oci.valid_spdx_checksums([ + {"algorithm": algorithm, "checksumValue": bad}])) + self.assertFalse(oci.valid_spdx_checksums( + [{"algorithm":"BOGUS","checksumValue":"0"}])) + self.assertFalse(oci.valid_spdx_checksums( + [{"algorithm":"MD6","checksumValue":"a"*64}])) + self.assertFalse(oci.valid_spdx_checksums( + [{"algorithm":"SHA256","checksumValue":"a"*64}]*2)) + self.assertFalse(oci.valid_spdx_checksums([])) + + def test_uri_grammar_is_shared_without_collapsing_identity_policies(self): + for uri in ("https://example.test/build@v1", "urn:allfather:build:v1"): + self.assertTrue(oci.valid_identity_uri(uri)) + self.assertTrue(oci.valid_spdx_namespace( + "https://example.test/spdx%20namespace")) + self.assertTrue(oci.valid_spdx_namespace( + "https://example.test/%E2%98%83")) + self.assertTrue(oci.valid_identity_uri( + "https://example.test/build?key=value&v=2")) + self.assertTrue(oci.valid_identity_uri( + "https://[2001:db8::1]/x?q=%5By%5D")) + for uri in ("https://%zz/path", "https://example.test:bogus/x", + "https://user:secret@example.test/x", + "https://example.test/a\x00", "https://example.test/a%2", + "https://example.test/☃", "https://example.test/{bad}", + "https://example.test/a|b", "https://example.test/^x", + "https://example.test/a[b]", + "https://example.test/?q=[x]", "urn:example:a[b]", + "https://example.test/foo#"): + with self.subTest(uri=uri): + self.assertFalse(oci.valid_spdx_namespace(uri)) + self.assertTrue(oci.valid_spdx_download_location("NOASSERTION")) + self.assertFalse(oci.valid_spdx_namespace("NOASSERTION")) + + def test_spdx_package_optional_defaults_and_conditional_verification(self): + pkg={"SPDXID":"SPDXRef-Package-complete", + "name":"sample", "downloadLocation":"NOASSERTION", + "copyrightText":"NOASSERTION", "filesAnalyzed":False} + self.assertTrue(oci.valid_spdx_download_location(pkg["downloadLocation"])) + # Omitted filesAnalyzed defaults to True; SPDX 2.3 permits + # missing copyright and absent verification code even when + # file-derived license information is provided. + for variant in ("missing-copyright", + "missing-filesAnalyzed-without-code", + "filesAnalyzed-without-code", + "valid-license-info-no-code", + "valid-license-expression"): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),spdx_case=variant) + self.assertTrue(oci.verify_archive( + archive,meta,docker_inspect=inspection)["oci_sbom_verified"]) + + def test_spdx22_and_23_mandatory_field_version_split(self): + with tempfile.TemporaryDirectory() as td: + a,meta,inspection=self.fixture(Path(td),spdx_case="spdx22-valid") + self.assertTrue(oci.verify_archive( + a,meta,docker_inspect=inspection)["oci_sbom_verified"]) + for variant in ("spdx22-missing-copyright", + "spdx22-missing-concluded", + "spdx22-missing-declared", + "spdx22-missing-code"): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as td: + a,meta,inspection=self.fixture(Path(td),spdx_case=variant) + with self.assertRaisesRegex(ValueError,"SPDX"): + oci.verify_archive(a,meta,docker_inspect=inspection) + + def test_spdx_file_only_inventory_uses_fileName_not_package_name(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspect = self.fixture(Path(d), spdx_case="file-only-valid") + self.assertTrue(oci.verify_archive( + archive, meta, docker_inspect=inspect)["oci_sbom_verified"]) + + def test_sanitized_diagnostics_report_spdx_shape_without_file_names(self): + mod_spec = importlib.util.spec_from_file_location( + "release1_inventory_diagnostic", + ROOT/"scripts/inspect-release-oci.py") + mod = importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive, meta, _ = self.fixture(Path(d), spdx_case="file-only-valid") + summary = mod.inspect(archive, meta) + attest = summary["index_manifests"][0]["children"][1] + spdx = next(s for l in attest["layers"] for s in l["statements"] + if s["predicate_type"] == "https://spdx.dev/Document") + self.assertEqual(spdx["spdx_package_count"], 0) + self.assertEqual(spdx["spdx_file_count"], 1) + self.assertIn("fileName", spdx["spdx_file_field_keys"]) + self.assertNotIn("./usr/bin/allfather", json.dumps(summary)) + self.assertFalse(summary["integrity_verified"]) + + def test_incomplete_spdx_sbom_never_qualifies(self): + cases = ("missing-license", "wrong-document-id", "missing-name", + "bad-namespace", "missing-created", "invalid-created", + "missing-creators", "bad-creator", "empty-inventory", + "bad-package", "missing-download", "invalid-download", + "empty-copyright", "filesAnalyzed-invalid", + "invalid-license-expression", "invalid-license-info", + "file-sha256-only","file-invalid-sha1", + "file-duplicate-sha1","file-invalid-license", + "unanalysed-with-code", + "unanalysed-with-file-licenses","invalid-license", + "download-unicode", "download-braces", "download-pipe", + "invalid-percent", "invalid-percent-host", "invalid-port", + "invalid-authority", "encoded-control", "bad-package-checksum", "short-sha256", "bogus-algorithm", + "missing-file-checksum", "bad-file-checksum", "duplicate-id", "namespace-fragment", + "namespace-trailing-fragment", "namespace-percent", + "namespace-port", "namespace-control", "namespace-userinfo", + "namespace-encoded-path", "namespace-unicode", "namespace-braces", + "namespace-pipe", "namespace-bracket-path", + "namespace-bracket-query", "download-bracket-path") + for variant in cases: + with self.subTest(spdx=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), spdx_case=variant) + with self.assertRaisesRegex(ValueError, "SPDX"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_in_toto_wrapper_requires_absence_or_nonempty_array(self): + diagnostic_spec=importlib.util.spec_from_file_location( + "release1_statement_wrapper_diagnostic", + ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(diagnostic_spec) + diagnostic_spec.loader.exec_module(mod) + for kind in ("singleton", "array"): + with self.subTest(valid=kind),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture( + Path(d),statement_wrapper=kind) + self.assertTrue(oci.verify_archive( + archive,meta,docker_inspect=inspection)["integrity_verified"]) + summary=mod.inspect(archive,meta) + self.assertFalse(summary["integrity_verified"]) + self.assertEqual( + len(summary["index_manifests"][0]["children"][1] + ["layers"][0]["statements"]),1) + for kind in ("null", "object", "empty", "string"): + with self.subTest(invalid=kind),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture( + Path(d),statement_wrapper=kind) + with self.assertRaisesRegex(ValueError, + "in-toto statements wrapper"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + with self.assertRaisesRegex(ValueError, + "invalid OCI diagnostic statement list"): + mod.inspect(archive,meta) + + def test_real_small_oci_graph_binds_every_layer_and_attestation(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d)) + report=oci.verify_archive(archive,meta,docker_inspect=inspection) + self.assertTrue(report["oci_diff_ids_verified"]) + self.assertTrue(report["oci_attestation_subjects_verified"]) + self.assertEqual(report["oci_diff_id_count"],1) + self.assertTrue(report["integrity_verified"]) + + def test_replacing_oci_path_during_verification_cannot_swap_digest(self): + import os + from unittest import mock + with tempfile.TemporaryDirectory() as d: + folder=Path(d) + first=folder/"first" + second=folder/"second" + first.mkdir() + second.mkdir() + archive,meta,inspection=self.fixture(first) + replacement,_,_=self.fixture(second,spdx_case="file-only-valid") + orig=oci.verify_attestation_subjects + def swap(*args): + os.replace(replacement,archive) + return orig(*args) + with mock.patch.object(oci,"verify_attestation_subjects", + side_effect=swap): + with self.assertRaisesRegex(ValueError, + "archive file was mutated or replaced"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_oci_mutated_in_place_during_verification_is_refused(self): + from unittest import mock + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d)) + orig=oci.verify_attestation_subjects + def mutate(*args): + with archive.open("ab") as writable: + writable.write(b"tampered trailing bytes") + return orig(*args) + with mock.patch.object(oci,"verify_attestation_subjects", + side_effect=mutate): + with self.assertRaisesRegex(ValueError, + "archive file was mutated or replaced"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_rehashed_replacement_layer_still_rejected_by_rootfs(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),replace_layer=True) + with self.assertRaisesRegex(ValueError,"rootfs.diff_ids"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_attestation_subject_cannot_name_an_unrelated_image(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),bad_subject=True) + with self.assertRaisesRegex(ValueError,"application manifest"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_attestation_descriptor_cannot_point_to_other_image(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),bad_reference=True) + with self.assertRaisesRegex(ValueError,"different application image"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_rootfs_diff_identity_is_mandatory(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),corrupt_rootfs=True) + with self.assertRaisesRegex(ValueError,"rootfs.diff_ids"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + + def test_config_and_layer_subjects_cannot_authorize_image(self): + for kind in ("config","layer","empty"): + with self.subTest(kind=kind),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),subject_kind=kind) + with self.assertRaisesRegex(ValueError, + "subjects|application manifest"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_index_digest_is_a_valid_metadata_reference(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),metadata_kind="index", + manifest_subject_kind="image") + report=oci.verify_archive(archive,meta,docker_inspect=inspection) + self.assertEqual(report["buildkit_digest_kind"],"oci-index") + self.assertTrue(report["oci_attestation_references_verified"]) + + def test_attestation_descriptor_digest_is_not_image_metadata(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d), + metadata_kind="attestation") + with self.assertRaisesRegex(ValueError,"BuildKit descriptor digest"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_attestation_manifest_subject_must_match_image(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d), + manifest_subject_kind="wrong") + with self.assertRaisesRegex(ValueError,"manifest subject"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_full_image_provenance_matches_exact_release_builder_and_version(self): + # Synthetic archives rehash *all* affected OCI layers/descriptors, so + # the negative is about provenance identity, not a checksum mismatch. + for variant, expected in ( + ("other-builder", "builder differs from pinned"), + ("v02", "pinned v1 predicate"), + ): + with self.subTest(variant=variant), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), slsa_case=variant) + with self.assertRaisesRegex(ValueError, expected): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_duplicate_image_provenance_cannot_claim_integrity(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), duplicate_slsa=True) + with self.assertRaisesRegex(ValueError, "exactly one pinned SLSA"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_garbled_predicate_cannot_claim_sbom_or_provenance(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture(Path(d),invalid_predicate=True) + with self.assertRaisesRegex(ValueError,"SPDX|SLSA|predicate"): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + + + def test_nested_exported_root_is_independently_bound(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d)) + report = oci.verify_archive(archive, meta, docker_inspect=inspection) + self.assertEqual(report["buildkit_digest_kind"], "oci-index") + self.assertEqual(report["oci_exported_root_digest"], + report["buildkit_digest"]) + + def test_explicit_flat_image_export_still_supported(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), nested=False) + report = oci.verify_archive(archive, meta, docker_inspect=inspection) + self.assertEqual(report["buildkit_digest_kind"], + "application-manifest") + + def test_outer_entrypoint_and_unrelated_image_are_not_buildkit_roots(self): + for kind in ("outer", "wrong-flat", "attestation"): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), + metadata_kind=kind) + with self.assertRaisesRegex(ValueError, "BuildKit descriptor"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_metadata_descriptor_size_must_be_integer_even_if_numerically_equal(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d)) + report = json.loads(meta.read_text()) + report["containerimage.descriptor"]["size"] = float( + report["containerimage.descriptor"]["size"]) + meta.write_text(json.dumps(report)) + with self.assertRaisesRegex(ValueError, "descriptor identity"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_wrong_buildkit_descriptor_size_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture( + Path(d), bad_metadata_descriptor=True) + with self.assertRaisesRegex(ValueError, "descriptor identity"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_all_descriptors_require_exact_nonboolean_sizes(self): + for mode in ("root", "boolean", "negative", "application", + "config", "layer", "statement"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), + wrong_size=mode) + with self.assertRaisesRegex(ValueError, r"descriptor.*size"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_resource_descriptors_require_meaningful_identity(self): + good=( + {"uri":"https://example.test/file"}, + {"uri":"pkg:docker/ubuntu@sha256%3Aabc123"}, + {"digest":{"sha256":"a"*64}}, + {"content":"e30="}, + {"uri":"git+https://example.test/code.git@abc123", + "digest":{"sha1":"b"*40}}, + ) + bad=( + {},{"name":"name-is-not-an-identity"},{"uri":""}, + {"digest":{}},{"digest":{"sha256":"garbage"}}, + {"content":"not+base64!"},{"uri":"https://example.test/x", + "digest":{}}, + {"uri":"https://example.test/x","content":"*"}, + ) + for row in good: + with self.subTest(valid=row): + self.assertTrue(oci._valid_resource_descriptor(row)) + for row in bad: + with self.subTest(invalid=row): + self.assertFalse(oci._valid_resource_descriptor(row)) + + def test_metadata_timestamps_require_rfc3339_and_monotonic_order(self): + good={"startedOn":"2026-10-17T08:00:00Z", + "finishedOn":"2026-10-17T08:00:01+00:00"} + self.assertTrue(oci._valid_slsa_metadata(good,version="v1")) + self.assertTrue(oci._valid_slsa_metadata( + {"startedOn":None,"finishedOn":""},version="v1")) + for bad in ( + {"startedOn":"not-a-time"}, + {"finishedOn":"2026-15-17T18:00:00Z"}, + {"startedOn":"2026-10-17T19:00:00Z", + "finishedOn":"2026-10-17T18:00:00Z"}, + {"startedOn":"2026-10-17T08:00:00"}, + ): + with self.subTest(bad=bad): + self.assertFalse(oci._valid_slsa_metadata(bad,version="v1")) + + def test_ipvfuture_authority_is_valid_but_brackets_in_paths_are_not(self): + self.assertTrue(oci.valid_identity_uri( + "https://[v1.a:b]/resource")) + self.assertTrue(oci.valid_identity_uri( + "https://[2001:db8::1]/resource")) + self.assertFalse(oci.valid_identity_uri( + "https://example.test/a[b]")) + self.assertFalse(oci.valid_identity_uri( + "https://[v1.bad!]/a[b]")) + + def test_slsa_optional_fields_have_independent_schema_contract(self): + with tempfile.TemporaryDirectory() as d: + archive,meta,inspected=self.fixture( + Path(d),slsa_case="valid-optionals") + self.assertTrue(oci.verify_archive( + archive,meta,docker_inspect=inspected)["oci_provenance_verified"]) + v02={"builder":{"id":"https://builder.example.test/v02"}, + "buildType":"https://example.test/buildtype", + "invocation":{}} + oci.validate_slsa("https://slsa.dev/provenance/v0.2",v02) + for bad in ( + {**v02,"materials":"not-an-array"}, + {**v02,"metadata":"not-an-object"}, + {**v02,"builder":{**v02["builder"],"version":"bad"}}, + {**v02,"invocation":{"environment":"not-an-object"}}, + {**v02,"buildConfig":[]}, + ): + with self.subTest(bad=bad),self.assertRaisesRegex(ValueError, + "SLSA"): + oci.validate_slsa("https://slsa.dev/provenance/v0.2",bad) + + def test_slsa_builder_diagnostics_are_field_specific_and_safe(self): + # Never echo producer-controlled URI or version data into logs. + kind="https://slsa.dev/provenance/v1" + good={"buildDefinition":{ + "buildType":"https://mobyproject.org/buildkit@v1", + "externalParameters":{}}, + "runDetails":{"builder":{"id":"https://github.com/moby/buildkit"}}} + oci.validate_slsa(kind,good) + variants=( + ({"runDetails":{"builder":{}}}, "builder ID"), + ({"runDetails":{"builder":{ + "id":"https://github.com/moby/buildkit", + "version":"PRIVATE_CANARY"}}}, "builder version map"), + ({"runDetails":{"builder":{ + "id":"https://github.com/moby/buildkit", + "builderDependencies":"PRIVATE_CANARY"}}}, "builderDependencies"), + ) + for replacement,expected in variants: + with self.subTest(failure=expected): + corrupted={**good,**replacement} + with self.assertRaisesRegex(ValueError,expected) as caught: + oci.validate_slsa(kind,corrupted) + self.assertNotIn("PRIVATE_CANARY",str(caught.exception)) + + def test_slsa_predicates_cannot_be_schema_only(self): + for mode in ("empty", "builder-empty", "bad-build-type", "no-external", + "resolved-as-string", "metadata-as-string", + "byproducts-as-map", "version-as-string", + "resource-item-nonobject", "resource-empty", + "resource-name-only","resource-bad-digest", + "resource-empty-digest","resource-bad-content", + "metadata-bad-start","metadata-bad-finish", + "metadata-reversed"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: + archive, meta, inspection = self.fixture(Path(d), + slsa_case=mode) + with self.assertRaisesRegex(ValueError, "SLSA"): + oci.verify_archive(archive, meta, docker_inspect=inspection) + + def test_all_diagnostic_statement_values_are_allowlisted(self): + mod_spec=importlib.util.spec_from_file_location( + "release1_diag_canary",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + marker="CANARY-private-access-token-DO-NOT-RETAIN" + with tempfile.TemporaryDirectory() as d: + archive,meta,_=self.fixture(Path(d),diagnostic_canary=marker) + report=mod.inspect(archive,meta) + serialized=json.dumps(report) + self.assertNotIn(marker,serialized) + self.assertFalse(report["integrity_verified"]) + self.assertIsNone(report["buildkit_digest"]) + attest=report["index_manifests"][0]["children"][1] + for layer in attest["layers"]: + for statement in layer["statements"]: + self.assertEqual(statement["_type"],"unrecognized") + self.assertEqual(statement["predicate_type"],"unrecognized") + self.assertEqual( + report["index_manifests"][0]["children"][0]["config_type"], + "unrecognized") + + def test_malformed_oci_containers_yield_sanitized_error_and_strict_refusal(self): + mod_spec=importlib.util.spec_from_file_location( + "release1_bad_oci_diagnostic",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + import sys + for mode in ("app-config-list", "app-layers-null", + "app-layer-scalar", "attest-config-list", + "attest-layer-null"): + with self.subTest(mode=mode),tempfile.TemporaryDirectory() as d: + archive,meta,inspection=self.fixture( + Path(d),malformed_diagnostic=mode) + output=Path(d)/"diagnostic.json" + before=sys.argv + try: + sys.argv=["diag","--archive",str(archive), + "--metadata",str(meta),"--output",str(output)] + self.assertEqual(mod.main(),0) + finally: + sys.argv=before + report=json.loads(output.read_text()) + self.assertFalse(report["integrity_verified"]) + self.assertTrue(report["inspection_failed"]) + self.assertEqual(report["inspection_error"],"ValueError") + with self.assertRaises((ValueError, TypeError)): + oci.verify_archive(archive,meta,docker_inspect=inspection) + + def test_retained_oci_diagnostics_do_not_echo_untrusted_subject_names(self): + mod_spec=importlib.util.spec_from_file_location( + "release1_safe_diag",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive, meta, _ = self.fixture(Path(d)) + report=mod.inspect(archive,meta) + payload=json.dumps(report) + self.assertNotIn("offline-fixture",payload) + self.assertNotIn("SPDXRef-Package-offline",payload) + self.assertNotIn("offline-test-package",payload) + self.assertFalse(report["integrity_verified"]) + + def test_diagnostic_accepts_valid_single_statement_envelope(self): + spec=importlib.util.spec_from_file_location( + "release1_single_statement_diag",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive,meta,_=self.fixture(Path(d)) + report=mod.inspect(archive,meta) + attest=report["index_manifests"][0]["children"][1] + self.assertEqual(len(attest["layers"]),2) + self.assertEqual( + sum(len(layer["statements"]) for layer in attest["layers"]),2) + self.assertFalse(report["integrity_verified"]) + + def test_diagnostic_reports_optional_subject_declared_and_actual_sizes(self): + inspect_spec = importlib.util.spec_from_file_location( + "release1_oci_diag_subject", ROOT/"scripts/inspect-release-oci.py") + mod = importlib.util.module_from_spec(inspect_spec) + inspect_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive, metadata, _ = self.fixture( + Path(d), manifest_subject_kind="image") + report = mod.inspect(archive, metadata) + self.assertFalse(report["integrity_verified"]) + att = report["index_manifests"][0]["children"][1] + self.assertEqual(att["subject_declared_size"], + att["subject_actual_size"]) + self.assertGreater(att["subject_actual_size"],0) + + def test_diagnostic_structure_is_not_an_integrity_verdict(self): + inspect_spec=importlib.util.spec_from_file_location( + "release1_oci_diagnostic",ROOT/"scripts/inspect-release-oci.py") + mod=importlib.util.module_from_spec(inspect_spec) + inspect_spec.loader.exec_module(mod) + with tempfile.TemporaryDirectory() as d: + archive,meta,_=self.fixture(Path(d)) + report=mod.inspect(archive,meta) + self.assertFalse(report["integrity_verified"]) + self.assertEqual(len(report["index_manifests"]),1) + self.assertEqual(report["exported_root_digest"],report["buildkit_digest"]) + self.assertEqual(report["application_manifest_digest"],report["index_manifests"][0]["children"][0]["digest"]) + attest=report["index_manifests"][0]["children"][1] + self.assertIsNone(attest["manifest_subject"]) + self.assertEqual(len(attest["layers"]),2) + self.assertEqual(attest["layers"][0]["statements"][0]["subject_count"],1) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_scanner_content.py b/tests/release/test_release_scanner_content.py new file mode 100644 index 00000000..3bddbf08 --- /dev/null +++ b/tests/release/test_release_scanner_content.py @@ -0,0 +1,49 @@ +"""Known real-file SHA1/SHA256 must match bytes, not checksum shape.""" +from __future__ import annotations +import hashlib +import importlib.util +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location( + "release1_file_hash_witness", + ROOT/"scripts/check-release-scanner-content.py") +module=importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class ScannerRealFileHashTests(unittest.TestCase): + FILE=b"fixture constant regular file contents\x00\n" + + def record(self, *, sha1=None, sha256=None, path="./usr/bin/dpkg"): + one=sha1 if sha1 is not None else hashlib.sha1(self.FILE).hexdigest() + two=sha256 if sha256 is not None else hashlib.sha256(self.FILE).hexdigest() + return {"files":[{"fileName":path,"checksums":[ + {"algorithm":"SHA1","checksumValue":one}, + {"algorithm":"SHA256","checksumValue":two}]}]} + + def test_independently_hashed_fixture_matches(self): + result=module.file_checksum_evidence(self.record(),self.FILE) + self.assertTrue(result["sha1_verified"]) + self.assertTrue(result["sha256_verified"]) + self.assertFalse(result["public_bot_release"]) + + def test_hash_shapes_dont_replace_real_content_identity(self): + for corrupted in ( + self.record(sha1="f"*40), + self.record(sha1="0"*40), + self.record(sha256="e"*64), + {"files":[{"fileName":"./usr/bin/dpkg", + "checksums":[{"algorithm":"SHA256", + "checksumValue":hashlib.sha256(self.FILE).hexdigest()}]}]}, + self.record(path="../../usr/bin/dpkg"), + {"files":[self.record()["files"][0]]*2}, + ): + with self.subTest(corruption=corrupted): + with self.assertRaises(ValueError): + module.file_checksum_evidence(corrupted,self.FILE) + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_scanner_contract.py b/tests/release/test_release_scanner_contract.py new file mode 100644 index 00000000..d61324e7 --- /dev/null +++ b/tests/release/test_release_scanner_contract.py @@ -0,0 +1,116 @@ +"""Early real-scanner attestation contract uses strict SPDX verifier.""" +from __future__ import annotations +import importlib.util +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] + +def module(path,name): + spec=importlib.util.spec_from_file_location(name,ROOT/path) + result=importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + +probe=module("scripts/check-release-scanner.py","release1_scanner_probe") +oci_fixture=module("tests/release/test_release_oci_integrity.py", + "release1_fixture_scanner") + +class ScannerProbeContractTests(unittest.TestCase): + def test_real_scanner_declares_same_builder_identity_as_release(self): + workflow=(ROOT/".github/workflows/release-candidate.yml").read_text() + probe=workflow.split(" scanner-compatibility:",1)[1].split( + " release-oci-integration:",1)[0] + self.assertIn( + "--attest type=provenance,mode=min,builder-id=" + "https://github.com/HMarcusWH/AllfatherChess/actions/" + "workflows/release-candidate.yml",probe) + self.assertIn('--attest "type=sbom,generator=$SCANNER"',probe) + self.assertIn("ci-release-scanner.py setup",probe) + self.assertIn("ci-release-scanner.py verify-approval",probe) + self.assertIn('SCANNER="$RELEASE_SCANNER_REF"',probe) + scanner_policy=(ROOT/"qualification/release-scanner-v1.json").read_text() + self.assertIn('"required_file_hash_algorithms": [',scanner_policy) + self.assertIn('"SHA1"',scanner_policy) + self.assertIn('"SHA256"',scanner_policy) + + def test_valid_spdx_probe_never_claims_release_authority(self): + with tempfile.TemporaryDirectory() as tmp: + tar, _, _ = oci_fixture.OCIProofTests().fixture(Path(tmp)) + report=probe.inspect_scanner_output(tar) + self.assertEqual(report["spdx_document_count"],1) + self.assertGreater(sum((report["spdx_package_count"], + report["spdx_file_count"])),0) + self.assertTrue(report["scanner_slsa_v1_contract_verified"]) + self.assertTrue(report["builder_identity_matched"]) + self.assertEqual(report["slsa_statement_count"],1) + self.assertFalse(report["integrity_verified"]) + self.assertFalse(report["release_qualified"]) + + def test_scanner_requires_one_exact_version_and_builder_provenance(self): + variants=( + ({"omit_slsa":True},"exactly one expected SLSA"), + ({"slsa_case":"other-builder"},"builder identity"), + ({"slsa_case":"v02"},"provenance version"), + ({"duplicate_slsa":True},"exactly one expected SLSA"), + ) + for kwargs,reason in variants: + with self.subTest(kind=kwargs),tempfile.TemporaryDirectory() as td: + archive,_,_=oci_fixture.OCIProofTests().fixture(Path(td),**kwargs) + with self.assertRaisesRegex(ValueError,reason): + probe.inspect_scanner_output(archive) + + def test_null_statement_wrapper_cannot_qualify_a_scanner(self): + for kind in ("null","empty","object","string"): + with self.subTest(wrapper=kind),tempfile.TemporaryDirectory() as tmp: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(tmp),statement_wrapper=kind) + with self.assertRaisesRegex(ValueError,"scanner statement envelope"): + probe.inspect_scanner_output(tar) + + def test_mixed_nonobject_statement_element_must_reject(self): + # Pinned real scanner must not claim compatible if an otherwise + # valid SPDX statement array includes an extra null/scalar item. + with tempfile.TemporaryDirectory() as tmp: + path=Path(tmp) + tar,_,_=oci_fixture.OCIProofTests().fixture( + path,statement_wrapper="array") + # A structurally valid positive array is accepted. + self.assertEqual(probe.inspect_scanner_output(tar)[ + "spdx_document_count"],1) + with tempfile.TemporaryDirectory() as tmp: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(tmp),statement_wrapper="array-null") + with self.assertRaisesRegex(ValueError, + "scanner in-toto statement element"): + probe.inspect_scanner_output(tar) + + def test_invalid_statement_subject_or_predicate_not_skipped(self): + for variant,why in (("array-bad-subject","subject identity"), + ("array-empty-predicate","predicate missing")): + with self.subTest(variant=variant),tempfile.TemporaryDirectory() as td: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(td),statement_wrapper=variant) + with self.assertRaisesRegex(ValueError,why): + probe.inspect_scanner_output(tar) + + def test_unknown_in_toto_row_cannot_be_skipped_next_to_spdx(self): + with tempfile.TemporaryDirectory() as td: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(td),statement_wrapper="array-unknown") + with self.assertRaisesRegex(ValueError, + "scanner unsupported in-toto predicate"): + probe.inspect_scanner_output(tar) + + def test_malformed_spdx_refused_at_scanner_boundary(self): + for mode in ("missing-download","bogus-algorithm", + "namespace-fragment","empty-inventory"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp: + tar,_,_=oci_fixture.OCIProofTests().fixture( + Path(tmp),spdx_case=mode) + with self.assertRaisesRegex(ValueError,"SPDX"): + probe.inspect_scanner_output(tar) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_scanner_source.py b/tests/release/test_release_scanner_source.py new file mode 100644 index 00000000..3d995c09 --- /dev/null +++ b/tests/release/test_release_scanner_source.py @@ -0,0 +1,121 @@ +"""Bounded registry candidate identity and source-pin negative controls.""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT=Path(__file__).resolve().parents[2] + + +def module(name,path): + spec=importlib.util.spec_from_file_location(name,ROOT/path) + result=importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +source=module("release1_scanner_source","scripts/prepare-release-scanner.py") +ci=module("release1_scanner_ci","scripts/ci-release-scanner.py") + + +class ReleaseScannerSourceContractTests(unittest.TestCase): + def test_frozen_patch_matches_reviewed_digest(self): + policy=json.loads((ROOT/"qualification/release-scanner-v1.json").read_text()) + source.check_lock(policy) + self.assertEqual( + source.sha256(ROOT/policy["patch_path"]), + "87fbe492c4b504374b41a6303ebc75833559bacb83addf9e5520b9768cc37d09") + self.assertEqual(policy["upstream_commit"], + "d88056b4e5b61d0ca037340df91be47d343b4386") + self.assertEqual(policy["toolchain_version"],"go1.25.8") + self.assertEqual(policy["vendored_syft_version"],"v1.42.3") + + def test_approved_binary_and_manifest_are_exact_reviewed_hashes(self): + policy=json.loads(source.LOCK.read_text()) + self.assertEqual(policy["approved_binary_sha256"], + "a1043ee5fa8285740548b600fd24d106b4e68211327238b4edbbe70765bd67ca") + self.assertEqual(policy["approved_manifest_digest"], + "sha256:fe9efbc9d42e6ddbbe44f0f35e2daa45299123891076c1700414a74978a51d2a") + self.assertFalse(policy["public_bot_release"]) + + def test_mutated_patch_cannot_keep_approved_digest(self): + original=source.ROOT + with tempfile.TemporaryDirectory() as td: + target=Path(td)/"deploy/release/scanner" + target.mkdir(parents=True) + (target/"target.go.patch").write_text("PRIVATE_CANARY\n") + policy=json.loads(source.LOCK.read_text()) + with mock.patch.object(source,"ROOT",Path(td)): + with self.assertRaisesRegex(ValueError,"patch bytes"): + source.check_lock(policy) + + def test_unreviewed_source_and_authority_changes_fail(self): + source_policy=json.loads(source.LOCK.read_text()) + for field,value in ( + ("upstream_commit","a"*40), + ("toolchain_version","go1.26.3"), + ("vendored_syft_version","v1.51.0"), + ("public_bot_release",True), + ("required_file_hash_algorithms",["SHA256"]), + ): + with self.subTest(field=field): + changed=dict(source_policy,**{field:value}) + with self.assertRaisesRegex(ValueError,"source/build policy"): + source.check_lock(changed) + + def test_candidate_approval_requires_frozen_independent_digests(self): + with tempfile.TemporaryDirectory() as td: + folder=Path(td) + candidate={ + "schema_version":1, + "upstream_commit":"d88056b4e5b61d0ca037340df91be47d343b4386", + "binary_sha256":"b"*64, + "manifest_digest":"sha256:"+"a"*64, + "image_reproducible":True, + "binary_reproducible":True} + (folder/"scanner-candidate.json").write_text(json.dumps(candidate)) + with mock.patch.object(ci,"OUT",folder): + with self.assertRaisesRegex(ValueError,"not yet frozen"): + ci.verify_approval() + # These approvals remain separate from build-generated claims. + frozen=dict(json.loads(ci.LOCK.read_text()), + approved_binary_sha256="b"*64, + approved_manifest_digest="sha256:"+"a"*64) + with mock.patch.object(ci,"LOCK",folder/"reviewed.json"): + (folder/"reviewed.json").write_text(json.dumps(frozen)) + ci.verify_approval() + candidate["manifest_digest"]="sha256:"+"c"*64 + (folder/"scanner-candidate.json").write_text(json.dumps(candidate)) + with self.assertRaisesRegex(ValueError,"not yet frozen"): + ci.verify_approval() + + def test_registry_local_digest_is_full_sha256_manifest_identity(self): + manifest=json.dumps({"schemaVersion":2,"mediaType": + "application/vnd.docker.distribution.manifest.v2+json", + "config":{},"layers":[]}).encode() + expected="sha256:"+hashlib.sha256(manifest).hexdigest() + class Response: + status=200 + headers={"Content-Type": + "application/vnd.docker.distribution.manifest.v2+json", + "Docker-Content-Digest":expected} + def read(self,amount): + return manifest + def __enter__(self): return self + def __exit__(self,*args): pass + with mock.patch.object(ci,"urlopen",return_value=Response()): + self.assertEqual(ci.response_digest("candidate-1"),expected) + with mock.patch.object(ci,"urlopen",return_value=Response()): + Response.headers=dict(Response.headers, + **{"Docker-Content-Digest":"sha256:"+"0"*64}) + with self.assertRaisesRegex(ValueError,"hash"): + ci.response_digest("candidate-2") + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_smoke_evidence.py b/tests/release/test_release_smoke_evidence.py new file mode 100644 index 00000000..dc956da4 --- /dev/null +++ b/tests/release/test_release_smoke_evidence.py @@ -0,0 +1,485 @@ +"""Adversarial RELEASE-1 evidence fixtures with legal game and HTTP traces. + +A valid evidence graph is constructed first; each negative case mutates a +single contract property and recomputes the non-authenticating trace digest. +""" +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +import unittest +from pathlib import Path + +import chess + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release_smoke_evidence_qualifier", ROOT / "scripts/qualify-release-smoke.py") +qual = importlib.util.module_from_spec(spec) +spec.loader.exec_module(qual) + + +def fixture(key): + scenario, color = qual.CASES[key] + first, second = "a"*32, "b"*32 + board = chess.Board() + rows, requests, guarded, intents, accepted, commits = [], [], [], [], [], [] + activations = [] + def event(kind, **kw): + row = {"seq":len(rows)+1, "event":kind, + "monotonic_ns":(len(rows)+1)*1_000_000, **kw} + rows.append(row) + return row + requests.extend([ + {"method":"POST","path":"/api/token/test","status":200,"monotonic_ms":10}, + {"method":"POST","path":f"/api/challenge/{qual.GAME_ID}/accept", + "status":200,"monotonic_ms":20}, + ]) + if scenario == "R10": + uci, ply = "e2e4", 0 + path = qual.MOVE_PREFIX + uci + request = event("move_attempt",game_id=qual.GAME_ID, + ply_at_receipt=ply,uci=uci,path=path) + rid = f"request-{request['seq']}" + fault = event("fault_activated",action="rate_limit_429", + endpoint="move_post",request_id=rid) + completed = event("response_write_completed",status=429,path=path, + request_id=rid) + row = event("rate_limit_hold_completed",action="rate_limit_429", + request_id=rid) + row["monotonic_ns"] = completed["monotonic_ns"] + 61_500_000_000 + activations.append({"action":"rate_limit_429","request_id":rid}) + requests.append({"method":"POST","path":path,"status":429, + "monotonic_ms":1000,"request_id":rid}) + guarded.append({"event":"http_move_submission_attempt", + "owner_epoch":first,"game_id":qual.GAME_ID, + "ply":ply,"uci":uci}) + guarded.append({"event":"global_rate_limit_block", + "owner_epoch":first,"endpoint":"move","status":429, + "timestamp":1800000000.25}) + intents.append({"game_id":qual.GAME_ID,"epoch":first, + "ply_before":ply,"move":uci,"history_json":"[]", + "status":"UNKNOWN_ACK"}) + else: + bot_index = 0 + for uci in ("f2f3","e7e5","g2g4","d8h4"): + ply = len(board.move_stack) + before = board.fen() + is_bot = board.turn == (color=="white") + rid = None + epoch = second if scenario=="R02" and bot_index else first + if is_bot: + path = qual.MOVE_PREFIX + uci + a = event("move_attempt",game_id=qual.GAME_ID,uci=uci, + path=path,ply_at_receipt=ply) + rid = f"request-{a['seq']}" + if scenario=="R02" and bot_index==0: + event("fault_activated",action="commit_then_close", + endpoint="move_post",request_id=rid) + activations.append({"action":"commit_then_close", + "request_id":rid}) + requests.append({"method":"POST","path":path,"status":200, + "monotonic_ms":1000+ply*1000, + "request_id":rid}) + intents.append({"game_id":qual.GAME_ID,"epoch":epoch, + "ply_before":ply,"move":uci, + "history_json":json.dumps( + [m.uci() for m in board.move_stack]), + "status":"CONFIRMED"}) + guarded.append({"event":"http_move_submission_attempt", + "owner_epoch":epoch,"game_id":qual.GAME_ID, + "ply":ply,"uci":uci}) + bot_index += 1 + board.push_uci(uci) + c = event("board_commit",game_id=qual.GAME_ID, + origin="allfather" if is_bot else "scripted_opponent", + request_id=rid,uci=uci,ply_before=ply, + ply_after=len(board.move_stack),fen_before=before, + fen_after=board.fen()) + commits.append(c) + if is_bot: + accepted.append({"ply":len(board.move_stack),"uci":uci}) + if scenario=="R02" and is_bot and bot_index==1: + event("connection_close_requested",path=qual.MOVE_PREFIX+uci, + request_id=rid) + guarded.append({"event":"http_unknown_ack","owner_epoch":first, + "game_id":qual.GAME_ID,"endpoint":"move"}) + elif is_bot: + event("response_write_completed",path=qual.MOVE_PREFIX+uci, + request_id=rid,status=200) + guarded.append({"event":"http_move_ack_observed", + "owner_epoch":epoch,"game_id":qual.GAME_ID, + "move":uci}) + moves = [m.uci() for m in board.move_stack] + complete = scenario!="R10" + observations = ( + [{"phase":1,"epoch":first,"observation":"restart_after_lost_ack"}, + {"phase":2,"epoch":second,"observation":"complete_confirmed"}] + if scenario=="R02" else + [{"phase":1,"epoch":first, + "observation":"rate_limited_suspension" if scenario=="R10" + else "complete_confirmed"}]) + result = { + "schema_version":2,"run_id":key,"scenario":scenario,"color":color, + "offline":True,"public_bot_release":False,"ready":True, + "runner_errors":[],"http":requests,"transport_trace":rows, + "game":{"game_id":qual.GAME_ID,"color":color,"moves":moves, + "accepted":accepted,"complete":complete, + "outcome":({"termination":"CHECKMATE"} if complete else None), + "forbidden_attempts":[]}, + "commits":commits,"fault":{"activations":activations}, + "guard_events":guarded,"observations":observations, + "journal":{"owner_epoch":observations[-1]["epoch"], + "intents":intents, + "games":[{"game_id":qual.GAME_ID,"history_json":json.dumps(moves), + "terminal":int(complete)}], + "cooldown":[{"singleton":1,"until_wall":1800000060.0, + "cause":"http429:move"}] if scenario=="R10" else [], + "events":[{ + "event_id":1, "ts":1800000000.0, "epoch":first, + "event":"cooldown", "game_id":None, + "details":json.dumps({ + "until_wall":1800000060.0, + "cause":"http429:move"}, + sort_keys=True, separators=(",",":")) + }] if scenario=="R10" else []}, + } + from deploy.lichess.reconcile import position_id + for intent in result["journal"]["intents"]: + intent["position_id"] = position_id(json.loads(intent["history_json"])) + for request in result["http"]: + request.setdefault("request_target", request["path"]) + request.setdefault("target_canonical", True) + seal(result) + return result + + +def seal(value): + value["transport_trace_sha256"] = hashlib.sha256( + qual.trace_bytes(value["transport_trace"])).hexdigest() + + +class PackagedMoveEvidenceTests(unittest.TestCase): + def test_all_four_realistic_cases_have_valid_independent_witnesses(self): + for key in qual.CASES: + with self.subTest(key=key): + result = qual.verify_smoke(fixture(key), key) + self.assertTrue(result["independent_qualified"]) + self.assertFalse(result["public_bot_release"]) + + def test_completed_game_cannot_forge_scripted_opponent_as_bot(self): + for case in ("r00-white", "r00-black", "r02-white"): + with self.subTest(case=case): + doc=fixture(case) + for item in doc["transport_trace"]: + if item.get("event") == "board_commit": + item["origin"]="scripted_opponent" + item["request_id"]=None + doc["game"]["accepted"]=[] + seal(doc) + with self.assertRaisesRegex( + ValueError, "committed move actor differs"): + qual.verify_smoke(doc, case) + + def test_get_allowlist_matches_fake_api_without_wildcard(self): + doc = fixture("r00-white") + for endpoint in sorted(qual.http_contract.ALLOWED_GETS): + doc["http"].append({"method":"GET", "path":endpoint, + "request_target":endpoint, + "target_canonical":True, + "status":200, "monotonic_ms":30}) + self.assertTrue(qual.verify_smoke(doc, "r00-white")[ + "independent_qualified"]) + + def test_forged_get_never_qualifies_producer_success(self): + for path, status, rid in ( + ("/api/account/email",200,None), + ("/api/account",401,None), + ("/api/account",429,None), + ("/api/stream/event",403,None), + ("/api/stream/event",200,"request-1"), + ("/api/account?private=true",200,None), + ("/api/bot/game/stream/off3b001/../other",200,None), + ): + with self.subTest(path=path,status=status,rid=rid): + doc=fixture("r00-white") + doc["http"].append({"method":"GET","path":path, + "status":status,"request_id":rid, + "monotonic_ms":30}) + with self.assertRaisesRegex(ValueError, + "unapproved GET or POST|unexpected or malformed HTTP"): + qual.verify_smoke(doc,"r00-white") + + def test_position_illegal_uci_fails_even_when_the_producer_says_ready(self): + doc=fixture("r10-white") + uci="e7e5" # syntactically valid, illegal on White's first turn + doc["transport_trace"][0]["uci"]=uci + doc["transport_trace"][0]["path"]=qual.MOVE_PREFIX+uci + doc["http"][-1]["path"]=qual.MOVE_PREFIX+uci + doc["journal"]["intents"][0]["move"]=uci + doc["guard_events"][0]["uci"]=uci + seal(doc) + with self.assertRaisesRegex(ValueError,"position-illegal"): + qual.verify_smoke(doc,"r10-white") + + def test_duplicate_submission_fails_independently_of_journal_status(self): + doc=fixture("r10-white") + final=doc["transport_trace"][-1] + doc["transport_trace"].append({ + "seq":final["seq"]+1,"monotonic_ns":final["monotonic_ns"]+1_000_000, + "event":"move_attempt","game_id":qual.GAME_ID, + "ply_at_receipt":0,"uci":"e2e4","path":qual.MOVE_PREFIX+"e2e4"}) + seal(doc) + with self.assertRaisesRegex(ValueError,"repeated intent"): + qual.verify_smoke(doc,"r10-white") + + def test_unattributed_http_move_and_illegal_rejection_fail(self): + for mutation in ("unknown-request","unexpected-status"): + doc=fixture("r00-white") + if mutation=="unknown-request": + doc["http"].append({"method":"POST", + "path":qual.MOVE_PREFIX+"e7e5", + "status":400,"monotonic_ms":6000, + "request_id":"request-999"}) + else: + doc["http"][2]["status"]=400 + with self.subTest(mutation=mutation),self.assertRaises(ValueError): + qual.verify_smoke(doc,"r00-white") + + def test_forged_trace_digest_or_commit_result_fails(self): + doc=fixture("r00-black") + doc["transport_trace"][0]["uci"]="a7a5" + with self.assertRaisesRegex(ValueError,"trace digest"): + qual.verify_smoke(doc,"r00-black") + doc=fixture("r00-black") + doc["commits"].pop() + with self.assertRaisesRegex(ValueError,"commit extract"): + qual.verify_smoke(doc,"r00-black") + + def test_r02_recovery_requires_a_distinct_confirmed_epoch(self): + doc=fixture("r02-white") + doc["observations"][1]["epoch"]=doc["observations"][0]["epoch"] + doc["journal"]["owner_epoch"]=doc["observations"][0]["epoch"] + with self.assertRaisesRegex(ValueError,"new-epoch"): + qual.verify_smoke(doc,"r02-white") + + def test_r10_requires_correlated_completed_server_429(self): + for mode in ("missing", "wrong-request", "duplicate", "wrong-status", + "before-fault"): + doc = fixture("r10-white") + rows = doc["transport_trace"] + response = next(r for r in rows if + r["event"] == "response_write_completed") + if mode == "missing": + rows.remove(response) + elif mode == "wrong-request": + response["request_id"] = "request-999" + elif mode == "duplicate": + hold_index = next(i for i, row in enumerate(rows) + if row["event"] == "rate_limit_hold_completed") + rows.insert(hold_index, dict(response)) + elif mode == "wrong-status": + response["status"] = 200 + else: + response = rows.pop(rows.index(response)) + rows.insert(1, response) # before the fault activation + if mode in ("missing", "duplicate", "before-fault"): + for i, row in enumerate(rows, start=1): + row["seq"] = i + if row["event"] != "rate_limit_hold_completed": + row["monotonic_ns"] = i * 1_000_000 + seal(doc) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "429|request correlation|status"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_requires_client_observed_rate_limit(self): + for mode in ("missing", "bad-endpoint", "wrong-status", + "wrong-epoch", "duplicate"): + doc = fixture("r10-white") + evidence = doc["guard_events"] + block = next(g for g in evidence + if g["event"] == "global_rate_limit_block") + if mode == "missing": + evidence.remove(block) + elif mode == "bad-endpoint": + block["endpoint"] = "challenge" + elif mode == "wrong-status": + block["status"] = 200 + elif mode == "wrong-epoch": + block["owner_epoch"] = "z"*32 + else: + evidence.append(dict(block)) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "client guard"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_malformed_cooldown_row_fails_as_a_qualification_error(self): + doc = fixture("r10-white") + doc["journal"]["cooldown"] = [None] + with self.assertRaisesRegex(ValueError, "durable 429"): + qual.verify_smoke(doc, "r10-white") + + def test_r10_cooldown_event_and_deadline_falsifiers(self): + for mode in ("text", "boolean", "infinite", "negative", + "short", "long", "missing-event", "wrong-epoch", + "wrong-cause", "wrong-event-deadline", + "wrong-guard-time"): + doc = fixture("r10-white") + cooldown = doc["journal"]["cooldown"][0] + event = doc["journal"]["events"][0] + if mode == "text": + cooldown["until_wall"] = "garbage" + elif mode == "boolean": + cooldown["until_wall"] = True + elif mode == "infinite": + cooldown["until_wall"] = float("inf") + elif mode == "negative": + cooldown["until_wall"] = -2.0 + elif mode == "short": + cooldown["until_wall"] = event["ts"] + 30 + elif mode == "long": + cooldown["until_wall"] = event["ts"] + 86401 + elif mode == "missing-event": + doc["journal"]["events"] = [] + elif mode == "wrong-epoch": + event["epoch"] = "f"*32 + elif mode == "wrong-cause": + event["details"] = json.dumps({ + "cause":"http429:challenge", + "until_wall":cooldown["until_wall"]}) + elif mode == "wrong-event-deadline": + event["details"] = json.dumps({ + "cause":"http429:move","until_wall":1234.0}) + else: + block = next(g for g in doc["guard_events"] + if g["event"] == "global_rate_limit_block") + block["timestamp"] = 1700000000.0 + if mode in ("short", "long"): + # Keep the JSON and persisted event in agreement, so the + # duration-bound check itself must reject this mutation. + event["details"] = json.dumps({ + "until_wall":cooldown["until_wall"], + "cause":"http429:move"}) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "cooldown|guard observation"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_rejects_durable_intent_without_unknown_ack(self): + doc = fixture("r10-white") + doc["journal"]["intents"][0]["status"] = "ACK_OBSERVED" + with self.assertRaisesRegex(ValueError, "durable 429"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_window_cannot_be_counted_before_completed_response(self): + doc = fixture("r10-white") + response = next(r for r in doc["transport_trace"] + if r["event"] == "response_write_completed") + hold = doc["transport_trace"][-1] + response["monotonic_ns"] = hold["monotonic_ns"] - 500_000_000 + seal(doc) + with self.assertRaisesRegex(ValueError, "post-429 observation"): + qual.verify_smoke(doc, "r10-white") + + def test_r10_hold_event_must_bind_targeted_request(self): + doc = fixture("r10-white") + doc["transport_trace"][-1]["request_id"] = "request-unrelated" + seal(doc) + with self.assertRaisesRegex(ValueError, "post-429 observation"): + qual.verify_smoke(doc, "r10-white") + + def test_r10_requires_complete_monotonic_observation(self): + doc=fixture("r10-white") + final=doc["transport_trace"][-1] + fault=doc["transport_trace"][1] + final["monotonic_ns"]=fault["monotonic_ns"]+59_000_000_000 + seal(doc) + with self.assertRaisesRegex(ValueError,"61 seconds"): + qual.verify_smoke(doc,"r10-white") + + def test_r10_rejects_all_api_requests_during_cooldown(self): + doc=fixture("r10-white") + doc["http"].append({"method":"GET","path":"/api/account/playing", + "request_target":"/api/account/playing", + "target_canonical":True, + "status":200,"monotonic_ms":1200}) + with self.assertRaisesRegex(ValueError,"cooldown"): + qual.verify_smoke(doc,"r10-white") + + def test_r02_completed_success_response_fails(self): + doc = fixture("r02-white") + close = next(x for x in doc["transport_trace"] + if x["event"] == "connection_close_requested") + rid = close["request_id"] + close["event"] = "response_write_completed" + close["status"] = 200 + final = doc["transport_trace"][-1] + doc["transport_trace"].append({ + "seq":final["seq"]+1, + "monotonic_ns":final["monotonic_ns"]+1_000_000, + "event":"connection_close_requested","path":close["path"], + "request_id":rid}) + seal(doc) + with self.assertRaisesRegex(ValueError,"lost-ack"): + qual.verify_smoke(doc,"r02-white") + + def test_r02_unknown_ack_must_identify_move_endpoint(self): + for mode in ("missing", "challenge", "token", "duplicate"): + doc = fixture("r02-white") + event = next(g for g in doc["guard_events"] + if g["event"] == "http_unknown_ack") + if mode == "missing": + del event["endpoint"] + elif mode in ("challenge", "token"): + event["endpoint"] = mode + else: + doc["guard_events"].append({ + **event, "endpoint": "challenge"}) + with self.subTest(mode=mode), self.assertRaisesRegex( + ValueError, "lost-ack"): + qual.verify_smoke(doc, "r02-white") + + def test_r02_unrelated_completed_responses_are_allowed(self): + doc = fixture("r02-white") + target = next(g["request_id"] for g in doc["transport_trace"] + if g["event"] == "fault_activated") + completed = [r for r in doc["transport_trace"] + if r["event"] == "response_write_completed" + and r.get("request_id") != target] + self.assertTrue(completed) + self.assertTrue(qual.verify_smoke(doc,"r02-white")["independent_qualified"]) + + def test_r02_rejects_missing_unknown_ack_or_successful_ack(self): + for mode in ("missing-unknown", "acknowledged"): + doc = fixture("r02-white") + if mode == "missing-unknown": + doc["guard_events"] = [g for g in doc["guard_events"] + if g["event"] != "http_unknown_ack"] + else: + doc["guard_events"].append({ + "event":"http_move_ack_observed","game_id":qual.GAME_ID, + "owner_epoch":doc["observations"][0]["epoch"],"move":"f2f3"}) + with self.subTest(mode=mode),self.assertRaisesRegex(ValueError,"lost-ack"): + qual.verify_smoke(doc,"r02-white") + + + def test_r02_rejects_a_successful_response_without_request_identity(self): + doc = fixture("r02-white") + close = next(t for t in doc["transport_trace"] + if t["event"] == "connection_close_requested") + final = doc["transport_trace"][-1] + doc["transport_trace"].append({ + "seq":final["seq"]+1, + "monotonic_ns":final["monotonic_ns"]+1_000_000, + "event":"response_write_completed", + "path":close["path"],"status":200}) + seal(doc) + with self.assertRaisesRegex(ValueError, "request correlation"): + qual.verify_smoke(doc,"r02-white") + + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_smoke_failures.py b/tests/release/test_release_smoke_failures.py new file mode 100644 index 00000000..935faf37 --- /dev/null +++ b/tests/release/test_release_smoke_failures.py @@ -0,0 +1,153 @@ +"""The actual image supervisor cannot turn exceptions into green evidence.""" +from __future__ import annotations +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location( + "offline_smoke_failure_tests",ROOT/"deploy/release/offline_smoke.py") +smoke=importlib.util.module_from_spec(spec) +spec.loader.exec_module(smoke) + + +class SmokeFailureTests(unittest.TestCase): + def test_producer_r10_waits_full_time_from_correlated_429_completion(self): + event = {"event":"response_write_completed", + "request_id":"request-1", "monotonic_ns":1_000_000_000, + "status":429} + start = event["monotonic_ns"] + wait = smoke.R10_POST_RESPONSE_HOLD_NS + self.assertFalse(smoke.r10_hold_ready([event], "request-1", start+wait-1)) + self.assertTrue(smoke.r10_hold_ready([event], "request-1", start+wait)) + self.assertFalse(smoke.r10_hold_ready([event], "request-2", start+wait)) + self.assertFalse(smoke.r10_hold_ready([event, dict(event)], + "request-1", start+wait)) + + def test_illegal_move_http_post_cannot_qualify_completed_game(self): + valid = [ + {"method":"POST","path":"/api/token/test","status":200}, + {"method":"POST","path":"/api/challenge/off3b001/accept","status":200}, + {"method":"POST","path":"/api/bot/game/off3b001/move/f2f3","status":200}, + ] + self.assertEqual(smoke.rejected_http_posts(valid,"R00"),[]) + illegal_status = {"method":"POST", + "path":"/api/bot/game/off3b001/move/e7e5", + "status":400} + for scenario in ("R00", "R02", "R10"): + with self.subTest(scenario=scenario): + rejected = smoke.rejected_http_posts(valid+[illegal_status], + scenario) + self.assertEqual(rejected,[illegal_status]) + self.assertFalse(not rejected) # producer ready requires no bad_http + invalid_path = {"method":"POST","path":["bad"],"status":200} + self.assertEqual(smoke.rejected_http_posts(valid+[invalid_path],"R00"), + [invalid_path]) + + def test_unexpected_exception_cannot_be_swallowed_by_finally(self): + class BrokenServer: + def __init__(self,*args): + self.fault=type("F",(),{"evidence":lambda self:{"activations":[]}})() + def start(self): + raise AssertionError("synthetic supervisor exception") + def evidence(self): + return {"complete":False,"moves":[],"accepted":[], + "forbidden_attempts":[],"requests":[], + "fault":{"activations":[]},"transport_trace":[]} + + with tempfile.TemporaryDirectory() as d: + with mock.patch.object(smoke.os,"getpid",return_value=1),\ + mock.patch.dict(smoke.os.environ,{"ONLINE_RELEASEx_ISOLATED":"1"}),\ + mock.patch.object(smoke,"PERSIST",Path(d)),\ + mock.patch.object(smoke,"TOOLS",ROOT),\ + mock.patch.object(smoke,"FakeLichess",BrokenServer): + with self.assertRaisesRegex(AssertionError,"synthetic"): + smoke.run(color="white",scenario_id="R00",run_id="broken") + result=json.loads((Path(d)/"broken/result.json").read_text()) + self.assertFalse(result["ready"]) + self.assertTrue(any("AssertionError" in x + for x in result["runner_errors"])) + + + def test_late_exception_after_completed_game_is_never_suppressed(self): + """Reverts to a false-green exit if the original finally-return returns.""" + moves=["f2f3","e7e5","g2g4","d8h4"] + report={"game_id":"off3b001","color":"white","moves":moves, + "accepted":[],"complete":True, + "outcome":{"termination":"CHECKMATE"}, + "forbidden_attempts":[],"requests":[], + "transport_trace":[],"fault":{"activations":[]}} + class Done: + def is_set(self): + return True + class Ready: + def wait(self,*args): + return True + class Fault: + def evidence(self): + return {"activations":[]} + class Sessions: + def snapshot(self): + return [] + class FinishedServer: + url="http://127.0.0.1:21212/" + finished=Done() + event_stream_open=Ready() + game_stream_open=Ready() + fault=Fault() + sessions=Sessions() + def __init__(self,*args): + pass + def start(self): + pass + def issue_challenge(self): + pass + def close(self): + pass + def evidence(self): + return report + class Journal: + def snapshot(self): + return {"games":[{"game_id":"off3b001","terminal":1, + "history_json":json.dumps(moves)}], + "intents":[],"cooldown":[],"events":[]} + class Owner: + epoch="a"*32 + journal=Journal() + def __init__(self,*args): + self.directory=Path(temp)/"state" + def __enter__(self): + return self + def __exit__(self,*exc): + return False + class Proc: + def poll(self): + return None + with tempfile.TemporaryDirectory() as temp: + with mock.patch.object(smoke.os,"getpid",return_value=1), \ + mock.patch.dict(smoke.os.environ,{"ONLINE_RELEASEx_ISOLATED":"1"}), \ + mock.patch.object(smoke,"PERSIST",Path(temp)), \ + mock.patch.object(smoke,"TOOLS",ROOT), \ + mock.patch.object(smoke,"FakeLichess",FinishedServer), \ + mock.patch.object(smoke,"SessionOwner",Owner), \ + mock.patch.object(smoke.bridge_policy,"generate",return_value={}), \ + mock.patch.object(smoke,"_start",return_value=(Proc(),None)), \ + mock.patch.object(smoke,"shutdown_bridge"), \ + mock.patch.object(smoke,"stop_all_isolated_processes", + side_effect=AssertionError("late after completed game")): + with self.assertRaisesRegex(AssertionError,"late after completed game"): + smoke.run(color="white",scenario_id="R00",run_id="late") + result=json.loads((Path(temp)/"late/result.json").read_text()) + self.assertEqual(result["observations"][0]["observation"], + "complete_confirmed") + self.assertFalse(result["ready"]) + self.assertTrue(any("AssertionError" in item + for item in result["runner_errors"])) + + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_snapshot_preflight.py b/tests/release/test_release_snapshot_preflight.py new file mode 100644 index 00000000..c19c6e18 --- /dev/null +++ b/tests/release/test_release_snapshot_preflight.py @@ -0,0 +1,178 @@ +"""Snapshot preflight: complete signed envelopes, bounded I/O and fail-closed report.""" +from __future__ import annotations +import importlib.util +import io +import json +import tempfile +import unittest +from pathlib import Path +from urllib.error import HTTPError + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release1_snapshot_check", ROOT / "scripts/check-release-snapshot.py") +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +def envelope(suite: str) -> bytes: + metadata = ("Origin: Ubuntu\nLabel: Ubuntu\nSuite: " + suite + + "\nArchitectures: amd64\nComponents: main universe restricted multiverse\n" + + "Date: Thu, 01 Oct 2026 00:00:00 UTC\n" + "SHA256:\n" + + ("a" * 72 + " 170 Contents-amd64.gz\n") * 20) + return ("-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n" + + metadata + "-----BEGIN PGP SIGNATURE-----\n" + + "iQIz" + "a" * 120 + "\n=ABCD\n-----END PGP SIGNATURE-----\n").encode() + + +class Response: + def __init__(self, url, data, *, final_url=None, declared=None, + error=None): + self.url = final_url or url + self.stream = io.BytesIO(data) + self.headers = {} if declared is None else {"Content-Length":str(declared)} + self.error = error + def __enter__(self): return self + def __exit__(self, *args): return None + def geturl(self): return self.url + def read(self, n): + if self.error: + raise self.error + return self.stream.read(n) + + +class SnapshotAvailabilityTests(unittest.TestCase): + def test_complete_signed_reachable_after_transient_503(self): + tries = {} + def opener(url, timeout): + suite = url.split("/dists/")[1].split("/")[0] + tries[url] = tries.get(url, 0) + 1 + if tries[url] == 1: + raise HTTPError(url, 503, "temporary outage", {}, None) + content = envelope(suite) + return Response(url,content,declared=len(content)) + result = checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(set(result["suites"]),set(checker.SUITES)) + self.assertEqual(set(tries.values()), {2}) + self.assertFalse(result["signature_verified"]) + + def test_nontransient_status_is_immediately_refused(self): + requests=[] + def opener(url,timeout): + requests.append(url) + raise HTTPError(url,404,"not found",{},None) + with self.assertRaisesRegex(ValueError,"HTTP 404"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(requests),1) + + def test_persistent_503_is_bounded(self): + attempts=[] + def opener(url,timeout): + attempts.append(url) + raise HTTPError(url,503,"temporary",{},None) + with self.assertRaisesRegex(ValueError,"HTTP 503"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),3) + + def test_network_timeout_retries_then_classifies_failure(self): + attempts=[] + def opener(url,timeout): + attempts.append(url) + raise TimeoutError("read operation timed out") + with self.assertRaisesRegex(ValueError,"network/read unavailable"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),3) + + def test_read_timeout_retries_and_does_not_skip_suite(self): + attempts=[] + def opener(url,timeout): + attempts.append(url) + suite=url.split("/dists/")[1].split("/")[0] + return Response(url,envelope(suite),error=TimeoutError("read timeout")) + with self.assertRaisesRegex(ValueError,"network/read unavailable"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),3) + + def test_empty_truncated_invalid_suite_and_redirect_refused(self): + for kind in ("empty","truncated","missing-signature","wrong-suite", + "redirect","wrong-content-length","oversized"): + attempts=[] + def opener(url,timeout): + attempts.append(url) + suite=url.split("/dists/")[1].split("/")[0] + body=envelope(suite) + kwargs={} + if kind=="empty": body=b"" + elif kind=="truncated": body=body[:400] + elif kind=="missing-signature": body=body.split( + b"-----BEGIN PGP SIGNATURE-----")[0] + elif kind=="wrong-suite": body=envelope("wrong-suite") + elif kind=="redirect": kwargs["final_url"]="https://evil.test/other" + elif kind=="wrong-content-length": kwargs["declared"]=len(body)+3 + elif kind=="oversized": body=b"x"*(checker.MAX_INRELEASE_BYTES+1) + return Response(url,body,**kwargs) + with self.subTest(kind=kind), self.assertRaisesRegex( + ValueError,"InRelease|redirected|suite"): + checker.probe("20261001T000000Z",opener=opener, + sleeper=lambda _:None) + self.assertEqual(len(attempts),1) + + def test_sources_require_exact_two_signed_stanzas(self): + raw=(ROOT/"deploy/release/ubuntu.sources").read_text() + checker.validate_sources(raw,"20261001T000000Z") + for poisoned in ( + raw+"\nTypes: deb\nURIs: http://untrusted.test\n", + raw.replace("Signed-By:", "Signed-Of:"), + raw.replace("Architectures: amd64","Architectures: arm64"), + raw.replace("https://security.ubuntu.com", + "http://security.ubuntu.com"), + ): + with self.subTest(change=poisoned[-60:]), self.assertRaises(ValueError): + checker.validate_sources(poisoned,"20261001T000000Z") + + def test_streaming_read_cannot_extend_global_deadline_indefinitely(self): + moment = [0.0] + class DelayedResponse(Response): + def read(self,n): + moment[0] = 150.0 + return b"a" + def opener(url,timeout): + return DelayedResponse(url,envelope("noble")) + with self.assertRaisesRegex(ValueError,"budget expired"): + checker.probe("20261001T000000Z", + opener=opener, sleeper=lambda _:None, + clock=lambda:moment[0]) + + def test_bad_snapshot_identity_rejected_before_network(self): + with self.assertRaisesRegex(ValueError,"invalid pinned"): + checker.probe("20261001T000000Z/../latest", + opener=lambda *a,**kw:self.fail("network called")) + + def test_main_failure_report_survives_invalid_sources(self): + with tempfile.TemporaryDirectory() as td: + root=Path(td) + policy=root/"policy.json" + sources=root/"sources" + output=root/"evidence.json" + policy.write_text(json.dumps({"apt_snapshot":"20261001T000000Z"})) + sources.write_text("Types: deb\nURIs: http://unsafe.test\n") + original_argv=checker.__dict__.get("_test_argv") + import sys + old=sys.argv + try: + sys.argv=["snapshot", "--policy",str(policy), + "--sources",str(sources),"--output",str(output)] + self.assertEqual(checker.main(),2) + finally: + sys.argv=old + report=json.loads(output.read_text()) + self.assertFalse(report["signature_verified"]) + self.assertTrue(report["unavailable_or_invalid"]) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_snapshot_retry.py b/tests/release/test_release_snapshot_retry.py new file mode 100644 index 00000000..eee429e1 --- /dev/null +++ b/tests/release/test_release_snapshot_retry.py @@ -0,0 +1,165 @@ +"""Simulated APT 503 and signature failures without touching host APT.""" +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +SCRIPT=ROOT/"deploy/release/install-pinned-apt.sh" + +FAKE_APT='''#!/bin/sh +case " $* " in + *" update "*) phase=update ;; + *" install "*) phase=install ;; + *) echo "unexpected fake apt arguments" >&2; exit 99 ;; +esac +file="$APT_TEST_COUNTER/$phase" +count=0 +[ ! -f "$file" ] || count=$(cat "$file") +count=$((count+1)) +printf "%s" "$count" > "$file" +case "$APT_TEST_MODE" in + recover) + if [ "$phase" = update ] && [ "$count" -le 2 ]; then + echo "E: Failed to fetch InRelease 503 Service Unavailable" >&2 + exit 100 + fi ;; + always503) echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2; exit 100 ;; + badsignature) + echo "E: BADSIG GPG signature failure" >&2 + echo "E: InRelease 503 Service Unavailable" >&2 + exit 100 ;; + badhash) echo "E: Hash Sum mismatch" >&2; exit 100 ;; + missing) echo "E: 404 Not Found" >&2; exit 100 ;; + mixed-dns) + echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 503 Service Unavailable" >&2 + echo "Err:2 https://snapshot.ubuntu.com/ubuntu noble-security InRelease" >&2 + echo " Temporary failure resolving snapshot.ubuntu.com" >&2 + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble-security/InRelease Temporary failure resolving" >&2 + exit 100 ;; + mixed-401) + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble-security/InRelease 401 Unauthorized" >&2 + exit 100 ;; + mixed-signature) + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: BADSIG: invalid signature" >&2 + exit 100 ;; + mixed-unclassified) + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Some unknown acquisition condition" >&2 + exit 100 ;; + real503port) + echo "Err:4 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/noble/InRelease 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Some index files failed to download. They have been ignored, or old ones used instead." >&2 + exit 100 ;; + real504port) + echo "Err:4 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 504 Gateway Time-out [IP: 185.125.189.69 443]" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/noble/InRelease 504 Gateway Time-out [IP: 185.125.189.69 443]" >&2 + exit 100 ;; + install503) + if [ "$phase" = install ]; then + echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble packages" >&2 + echo " 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/packages.deb 503 Service Unavailable [IP: 185.125.189.69 443]" >&2 + echo "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?" >&2 + exit 100 + fi ;; + install503mixed) + if [ "$phase" = install ]; then + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/packages.deb 503 Service Unavailable" >&2 + echo "E: Failed to fetch https://snapshot.ubuntu.com/ubuntu/other.deb 401 Unauthorized" >&2 + echo "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?" >&2 + exit 100 + fi ;; + two503) + echo "Err:1 https://snapshot.ubuntu.com/ubuntu noble InRelease" >&2 + echo " 503 Service Unavailable" >&2 + echo "Err:2 https://snapshot.ubuntu.com/ubuntu noble-security InRelease" >&2 + echo " 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble/InRelease 503 Service Unavailable" >&2 + echo "E: Failed to fetch noble-security/InRelease 503 Service Unavailable" >&2 + exit 100 ;; +esac +exit 0 +''' + +class PinnedSnapshotRetryTests(unittest.TestCase): + def execute(self, mode): + with tempfile.TemporaryDirectory() as td: + d=Path(td) + (d/"bin").mkdir() + apt=d/"bin/apt-get" + apt.write_text(FAKE_APT) + apt.chmod(0o755) + (d/"lists").mkdir() + stale=d/"lists/old-index" + stale.write_text("not admissible") + env={**os.environ,"PATH":str(d/"bin")+":"+os.environ["PATH"], + "APT_TEST_COUNTER":str(d),"APT_TEST_MODE":mode, + "APT_LISTS_DIR":str(d/"lists"),"APT_RETRY_BACKOFF_SECONDS":"0", + "APT_LOG":str(d/"trace.log")} + outcome=subprocess.run(["/bin/sh",str(SCRIPT)],env=env, + capture_output=True,text=True,timeout=15) + counts={k:int((d/k).read_text()) if (d/k).exists() else 0 + for k in ("update","install")} + self.assertFalse(stale.exists()) + self.assertFalse((d/"trace.log").exists()) + return outcome,counts + + def test_transient_outage_then_signed_success(self): + result,counts=self.execute("recover") + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(counts,{"update":3,"install":1}) + + def test_persistent_503_is_bounded_and_blocks_install(self): + result,counts=self.execute("always503") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":4,"install":0}) + + def test_mixed_error_class_never_retries(self): + for mode in ("mixed-dns","mixed-401","mixed-signature", + "mixed-unclassified"): + with self.subTest(mode=mode): + result,counts=self.execute(mode) + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":0}) + + def test_realistic_503_and_504_logs_with_https_port_are_retryable(self): + for mode in ("real503port","real504port"): + with self.subTest(mode=mode): + result,counts=self.execute(mode) + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":4,"install":0}) + self.assertIn("exhausted four bounded attempts",result.stderr) + + def test_install_summary_with_only_transient_errors_retries_bounded(self): + result,counts=self.execute("install503") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":4}) + + def test_install_summary_does_not_override_unknown_or_401(self): + result,counts=self.execute("install503mixed") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":1}) + + def test_multiple_503s_are_individually_classified_and_bounded(self): + result,counts=self.execute("two503") + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":4,"install":0}) + + def test_signature_hash_and_missing_source_fail_without_retries(self): + for mode in ("badsignature","badhash","missing"): + with self.subTest(mode=mode): + result,counts=self.execute(mode) + self.assertNotEqual(result.returncode,0) + self.assertEqual(counts,{"update":1,"install":0}) + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_source_attestation.py b/tests/release/test_release_source_attestation.py new file mode 100644 index 00000000..3a0f569f --- /dev/null +++ b/tests/release/test_release_source_attestation.py @@ -0,0 +1,148 @@ +"""Real Git-byte and staged-manifest authorization tests for RELEASE-1.""" +from __future__ import annotations + +import hashlib +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location( + "release1_attested_aggregate",ROOT/"scripts/qualify-release-aggregate.py") +aggregate=importlib.util.module_from_spec(spec) +spec.loader.exec_module(aggregate) + + +class ReleaseVerifierSourceAttestationTests(unittest.TestCase): + def setUp(self): + temp=tempfile.TemporaryDirectory(prefix="release-source-contract-") + self.addCleanup(temp.cleanup) + self.root=Path(temp.name) + self.git("init","-q") + self.git("config","user.email","local-only@example.test") + self.git("config","user.name","Offline Source Test") + self.source_paths=aggregate.VERDICT_SOURCE_PATHS + for relative in self.source_paths: + target=self.root/relative + target.parent.mkdir(parents=True,exist_ok=True) + target.write_bytes((ROOT/relative).read_bytes()) + self.git("add",".") + self.git("commit","-qm","bind exact-source") + self.source=self.git("rev-parse","HEAD",capture=True) + self.attestation={"source_attestation":{ + relative:hashlib.sha256((self.root/relative).read_bytes()).hexdigest() + for relative in self.source_paths}} + + def git(self,*args,capture=False): + command=["git","-C",str(self.root),*args] + result=subprocess.run(command,check=True,capture_output=True, + text=True,timeout=20) + return result.stdout.strip() if capture else None + + def test_clean_source_loads_from_sealed_snapshot(self): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation) as sealed: + http=aggregate.load_verified_module( + sealed,"scripts/release_http_contract.py","sealed_http_positive") + row={"method":"GET","path":"/api/account","status":200, + "request_target":"/api/account","target_canonical":True} + self.assertTrue(http.allowed_release_http(row,"r00-white")) + smoke=aggregate.load_verified_module( + sealed,"scripts/qualify-release-smoke.py","sealed_smoke_positive") + self.assertTrue(callable(smoke.verify_file)) + self.assertNotEqual(sealed.resolve(),self.root.resolve()) + + def test_isolated_git_worktree_preserves_original_committed_verifier_bytes(self): + # A build step can mutate the producer checkout while preserving HEAD. + # A new detached worktree must execute Git's original bytes instead. + with tempfile.TemporaryDirectory(prefix="release1-isolated-") as td: + verified=Path(td)/"verified" + self.git("worktree","add","--detach",str(verified),self.source) + victim="scripts/qualify-release-smoke.py" + original=(verified/victim).read_bytes() + (self.root/victim).write_bytes(b"raise SystemExit('FORGED')\n") + self.assertEqual((verified/victim).read_bytes(),original) + with self.assertRaisesRegex(ValueError,"staged attestation"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("mutable producer source was accepted") + with aggregate.verified_release_snapshot( + verified,self.source,self.attestation) as sealed: + self.assertEqual((sealed/victim).read_bytes(),original) + + def test_worktree_mutation_cannot_reuse_old_manifest_and_head(self): + path=self.root/"scripts/qualify-release-smoke.py" + path.write_bytes(path.read_bytes()+b"\n# PRIVATE_CANARY\n") + with self.assertRaisesRegex(ValueError,"staged attestation"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("mutated source accepted") + + def test_rehashed_mutation_cannot_forge_git_source_identity(self): + relative="scripts/qualify-release-smoke.py" + path=self.root/relative + path.write_bytes(path.read_bytes()+b"\n# altered verifier\n") + self.attestation["source_attestation"][relative]=hashlib.sha256( + path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,"Git committed bytes"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("rehashed forged source accepted") + + def test_transitive_http_policy_is_also_exactly_attested(self): + relative="tests/lichess/http_target_contract.py" + path=self.root/relative + path.write_bytes(path.read_bytes()+b"\n# altered HTTP policy\n") + self.attestation["source_attestation"][relative]=hashlib.sha256( + path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,"Git committed bytes"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("changed indirect helper accepted") + + def test_missing_and_malformed_manifest_attestation_fails(self): + self.attestation["source_attestation"].pop( + "scripts/qualify-release-aggregate.py") + with self.assertRaisesRegex(ValueError,"staged attestation"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("missing self attestation accepted") + + def test_symlink_replacement_fails_closed(self): + target=self.root/"scripts/qualify-release-smoke.py" + backup=self.root/"untrusted-source.py" + backup.write_bytes(target.read_bytes()) + target.unlink() + target.symlink_to(backup) + with self.assertRaisesRegex(ValueError,"linked or outside"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("linked source accepted") + + def test_original_mutation_after_snapshot_does_not_change_executed_bytes(self): + relative="scripts/qualify-release-smoke.py" + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation) as sealed: + original=(sealed/relative).read_bytes() + (self.root/relative).write_bytes(b"raise SystemExit('CANARY')\n") + self.assertEqual((sealed/relative).read_bytes(),original) + smoke=aggregate.load_verified_module( + sealed,relative,"sealed_smoke_after_mutation") + self.assertTrue(callable(smoke.verify_file)) + + def test_aggregate_verifier_itself_must_match_git_commit(self): + relative="scripts/qualify-release-aggregate.py" + path=self.root/relative + path.write_bytes(path.read_bytes()+b"\n# self mutation\n") + self.attestation["source_attestation"][relative]=hashlib.sha256( + path.read_bytes()).hexdigest() + with self.assertRaisesRegex(ValueError,"Git committed bytes"): + with aggregate.verified_release_snapshot( + self.root,self.source,self.attestation): + self.fail("changed aggregate entrypoint accepted") + + +if __name__=="__main__": + unittest.main() diff --git a/tests/release/test_release_sqlite_persistence.py b/tests/release/test_release_sqlite_persistence.py new file mode 100644 index 00000000..d0335479 --- /dev/null +++ b/tests/release/test_release_sqlite_persistence.py @@ -0,0 +1,220 @@ +"""Real SQLite/WAL durability evidence tests for RELEASE-1 host qualification. + +Synthetic documents are paired with real MoveJournal-schema SQLite databases. +A JSON-only fixture can exercise verify_smoke but must NEVER satisfy verify_file. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from deploy.lichess.move_journal import SCHEMA + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release_smoke_test_fixture", ROOT / "tests/release/test_release_smoke_evidence.py") +original = importlib.util.module_from_spec(spec) +spec.loader.exec_module(original) +fixture = original.fixture +qual = original.qual + + +def real_fixture(folder: Path, key: str, *, keep_wal=False, doc=None): + """Return an optionally live WAL writer and its complete JSON document.""" + doc = fixture(key) if doc is None else doc + folder.mkdir(parents=True) + state = folder / "state" + state.mkdir() + db = state / "move-journal.sqlite3" + conn = sqlite3.connect(db) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=FULL") + for statement in SCHEMA: + conn.execute(statement) + conn.commit() + # Materialize only the schema before inserting application data into WAL. + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + journal = doc["journal"] + conn.execute("INSERT INTO owner(singleton,epoch) VALUES(1,?)", + (journal["owner_epoch"],)) + for table in ("games", "intents", "cooldown", "events"): + for record in journal[table]: + columns = list(record) + query = ("INSERT INTO " + table + " (" + ",".join(columns) + + ") VALUES (" + ",".join("?" for _ in columns) + ")") + conn.execute(query, tuple(record[key] for key in columns)) + conn.commit() + (folder / "result.json").write_text(json.dumps(doc, sort_keys=True)) + if not keep_wal: + conn.close() + conn = None + return doc, conn + + +class RealSQLiteQualificationTests(unittest.TestCase): + def test_every_valid_case_qualifies_with_real_checkpointed_database(self): + for key in qual.CASES: + with self.subTest(key=key), tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / key + real_fixture(folder, key) + report = qual.verify_file(folder / "result.json", key) + self.assertTrue(report["persisted_sqlite_verified"]) + self.assertTrue(report["independent_qualified"]) + + def test_uncheckpointed_wal_rows_are_verified_read_only(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "r02-white" + _, conn = real_fixture(folder, "r02-white", keep_wal=True) + try: + self.assertTrue((folder / "state/move-journal.sqlite3-wal").is_file()) + report = qual.verify_file(folder / "result.json", "r02-white") + self.assertTrue(report["persisted_sqlite_verified"]) + finally: + conn.close() + + def test_qualifier_never_writes_original_read_only_producer_volume(self): + """Reproduce GitHub runner reading the UID-10001-owned mount.""" + for keep_wal in (False, True): + with self.subTest(live_wal=keep_wal), tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r02-white" + _, conn = real_fixture(folder, "r02-white", keep_wal=keep_wal) + state = folder / "state" + database = state / "move-journal.sqlite3" + wal = state / "move-journal.sqlite3-wal" + originals = [database] + ([wal] if keep_wal else []) + before = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in originals} + previous_modes = {p: p.stat().st_mode & 0o777 for p in originals} + try: + for path in originals: + os.chmod(path, 0o444) + os.chmod(state, 0o555) + result = qual.verify_file(folder / "result.json", "r02-white") + self.assertTrue(result["persisted_sqlite_verified"]) + self.assertEqual(before, { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in originals}) + self.assertEqual( + (state / "move-journal.sqlite3-shm").exists(), keep_wal) + finally: + os.chmod(state, 0o700) + for path, mode in previous_modes.items(): + if path.exists(): + os.chmod(path, mode) + if conn is not None: + conn.close() + + def test_qualifier_rejects_mutated_source_without_json_change(self): + with tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r10-white" + real_fixture(folder, "r10-white") + with sqlite3.connect(folder / "state/move-journal.sqlite3") as c: + c.execute("UPDATE cooldown SET cause='http429:other'") + with self.assertRaisesRegex(ValueError, "SQLite persisted cooldown"): + qual.verify_file(folder / "result.json", "r10-white") + + def test_missing_required_wal_refuses_unpersisted_snapshot(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "r02-white" + _, conn = real_fixture(folder, "r02-white", keep_wal=True) + try: + (folder / "state/move-journal.sqlite3-wal").unlink() + with self.assertRaises((ValueError, sqlite3.DatabaseError)): + qual.verify_file(folder / "result.json", "r02-white") + finally: + conn.close() + + def test_missing_corrupt_and_linked_database_are_rejected(self): + for mutation in ("missing", "corrupt", "db-link", "directory-link", + "wal-link"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r00-white" + real_fixture(folder, "r00-white") + state = folder / "state" + db = state / "move-journal.sqlite3" + if mutation == "missing": + db.unlink() + elif mutation == "corrupt": + db.write_bytes(b"not a sqlite database") + elif mutation == "db-link": + original_db = folder / "original.sqlite3" + db.rename(original_db) + db.symlink_to(original_db) + elif mutation == "directory-link": + moved = folder / "actual-state" + state.rename(moved) + state.symlink_to(moved, target_is_directory=True) + else: + (state / "move-journal.sqlite3-wal").symlink_to(db) + with self.assertRaises((ValueError, sqlite3.DatabaseError)): + qual.verify_file(folder / "result.json", "r00-white") + + def test_forged_snapshot_rows_and_owner_are_rejected(self): + for mutation in ("owner", "game", "intent", "cooldown"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as d: + key = "r10-white" if mutation == "cooldown" else "r02-white" + folder = Path(d) / key + doc, _ = real_fixture(folder, key) + if mutation == "owner": + doc["journal"]["owner_epoch"] = "c"*32 + elif mutation == "game": + doc["journal"]["games"][0]["history_json"] = "[]" + elif mutation == "intent": + doc["journal"]["intents"][0]["status"] = "PREPARED" + else: + doc["journal"]["cooldown"].clear() + (folder / "result.json").write_text(json.dumps(doc)) + with self.assertRaisesRegex(ValueError, "SQLite"): + qual.verify_file(folder / "result.json", key) + + def test_coherently_forged_sqlite_and_json_cooldown_still_fail(self): + """Matching disk + JSON is insufficient when the deadline is invalid.""" + for mode in ("text", "too-long", "bad-event-cause"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as d: + folder = Path(d) / "r10-white" + doc = fixture("r10-white") + if mode == "text": + doc["journal"]["cooldown"][0]["until_wall"] = "garbage" + doc["journal"]["events"][0]["details"] = json.dumps({ + "until_wall":"garbage","cause":"http429:move"}) + elif mode == "too-long": + doc["journal"]["cooldown"][0]["until_wall"] = 1800086461.0 + doc["journal"]["events"][0]["details"] = json.dumps({ + "until_wall":1800086461.0,"cause":"http429:move"}) + else: + doc["journal"]["events"][0]["details"] = json.dumps({ + "until_wall":1800000060.0,"cause":"not-move"}) + real_fixture(folder, "r10-white", doc=doc) + with self.assertRaisesRegex(ValueError, "cooldown"): + qual.verify_file(folder / "result.json", "r10-white") + + def test_disk_mutation_is_detected_without_json_mutation(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "r00-white" + real_fixture(folder, "r00-white") + with sqlite3.connect(folder / "state/move-journal.sqlite3") as conn: + conn.execute("UPDATE owner SET epoch=?", ("d"*32,)) + with self.assertRaisesRegex(ValueError, "SQLite owner"): + qual.verify_file(folder / "result.json", "r00-white") + + def test_qualification_fails_closed_for_missing_or_corrupt_case(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for key in qual.CASES: + real_fixture(root / key, key) + self.assertTrue(qual.qualify_directory(root)["offline_smoke_qualified"]) + (root / "r10-white/state/move-journal.sqlite3").unlink() + report = qual.qualify_directory(root) + self.assertFalse(report["offline_smoke_qualified"]) + self.assertEqual(len(report["failures"]), 1) + self.assertEqual(report["failures"][0]["scenario"], "r10-white") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_spdx_license_contract.py b/tests/release/test_spdx_license_contract.py new file mode 100644 index 00000000..4bda72af --- /dev/null +++ b/tests/release/test_spdx_license_contract.py @@ -0,0 +1,59 @@ +"""Offline SPDX v2 license grammar, frozen identifier and negative controls.""" +from __future__ import annotations +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "release_spdx_licenses", ROOT / "scripts/spdx_license_contract.py") +contract = importlib.util.module_from_spec(spec) +spec.loader.exec_module(contract) + + +class SPDXLicenseExpressionTests(unittest.TestCase): + def test_supported_expressions(self): + values = ( + "MIT", "Apache-2.0 OR MIT", + "(MIT OR Apache-2.0) AND LicenseRef-Local", + "GPL-2.0-only WITH Classpath-exception-2.0", + "DocumentRef-external:LicenseRef-Local", + "GPL-2.0-or-later AND MIT", + ) + # NONE and NOASSERTION are whole-field sentinels only. + for value in values + ("NONE", "NOASSERTION"): + with self.subTest(value=value): + self.assertTrue(contract.valid_license_expression(value)) + + def test_malformed_and_unknown_expressions(self): + for value in ( + "not a license expression", "MIT AND", "MIT OR OR Apache-2.0", + "MIT WITH", "NONE OR MIT", "NOASSERTION AND MIT", + "GPL-2.0-only WITH not-an-exception", "MIT WITH MIT", + "(MIT OR Apache-2.0", "(MIT) WITH Classpath-exception-2.0", + "LicenseRef-", "DocumentRef-:LicenseRef-X", + "DefinitelyNotAnSPDXLicense-999.0", + "MIT\nOR Apache-2.0", "MIT\tAND Apache-2.0", + "MIT OR PRIVATE_CANARY", "MIT OR (MIT OR )", + ): + with self.subTest(value=value): + self.assertFalse(contract.valid_license_expression(value)) + + def test_frozen_spdx_catalogue_contains_canonical_identifiers(self): + licenses, exceptions = contract.approved_identifiers() + self.assertIn("MIT", licenses) + self.assertIn("Apache-2.0", licenses) + self.assertIn("Classpath-exception-2.0", exceptions) + self.assertGreaterEqual(len(licenses), 700) + self.assertGreaterEqual(len(exceptions), 80) + self.assertFalse(contract.valid_license_expression( + "PRIVATE_CANARY")) + + def test_input_bounds_and_invalid_types(self): + for value in (None, True, 0, "", "MIT ", " MIT", + "MIT OR " + "MIT OR " * 300 + "Apache-2.0"): + self.assertFalse(contract.valid_license_expression(value)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_workflow_coverage.py b/tests/release/test_workflow_coverage.py new file mode 100644 index 00000000..514ac21a --- /dev/null +++ b/tests/release/test_workflow_coverage.py @@ -0,0 +1,144 @@ +"""Source-bound release workflows and runnable operator examples.""" +from __future__ import annotations +import subprocess +import unittest +from pathlib import Path + +import yaml + +ROOT=Path(__file__).resolve().parents[2] + + +class WorkflowCoverageTests(unittest.TestCase): + def test_online_bridge_contracts_trigger_for_assurance_dependencies(self): + cfg=yaml.load((ROOT/".github/workflows/online-bridge.yml").read_text(), + Loader=yaml.BaseLoader) + paths=set(cfg["on"]["pull_request"]["paths"]) + for critical in ( + "scripts/sanitize_qualification_evidence.py", + "scripts/http_evidence_verdict.py", + "scripts/qualify-online-recovery.py", + "qualification/http-evidence-contract-v1.json", + "qualification/online-recovery-v1.json"): + with self.subTest(source=critical): + self.assertIn(critical,paths) + + def test_release_candidate_runs_on_all_prs_and_merged_main(self): + cfg=yaml.load((ROOT/".github/workflows/release-candidate.yml").read_text(), + Loader=yaml.BaseLoader) + events=cfg["on"] + self.assertIn("pull_request",events) + self.assertNotIn("paths",events["pull_request"] or {}) + self.assertEqual(events["push"]["branches"],["main"]) + self.assertIn("SOURCE_SHA",cfg["env"]) + self.assertIn("github.event.pull_request.head.sha",cfg["env"]["SOURCE_SHA"]) + + def test_operations_docker_command_is_valid_shell(self): + content=(ROOT/"docs/RELEASE_1_OPERATIONS.md").read_text() + fence=chr(96)*3 + start=content.index(fence+"sh\n",content.index("## Runtime contract"))+len(fence+"sh\n") + end=content.index("\n"+fence,start) + command=content[start:end] + self.assertEqual(subprocess.run(["bash","-n"],input=command,text=True, + capture_output=True).returncode,0) + for fragment in ("--network none","--read-only","--cap-drop ALL", + "--security-opt no-new-privileges", + ":/opt/allfather/build/replays-online-hybrid-v2:rw", + "--user 10001:10001"): + self.assertIn(fragment,command) + + + def test_pinned_scanner_is_exercised_without_chess_build(self): + source=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn("scanner-compatibility:",source) + self.assertIn("needs: [release-contracts, scanner-compatibility]",source) + self.assertIn("scripts/check-release-scanner.py",source) + self.assertIn("--attest \"type=sbom,generator=$SCANNER\"",source) + self.assertIn("scanner-contract.json",source) + scanner=source.split(" scanner-compatibility:",1)[1].split( + " release-oci-integration:",1)[0] + self.assertNotIn("make build-online-engine-opt-v2",scanner) + self.assertNotIn("release1-scanner/scanner.oci.tar\n if",scanner) + + def test_release_verdict_sources_are_git_attested_before_import(self): + source=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn( + "Verify scanner and SPDX source against exact committed Git bytes", + source) + self.assertIn( + "Verify every offline verdict module before aggregate execution", + source) + self.assertIn("VERDICT_SOURCE_BYTES_DIFFER_FROM_GIT",source) + builder=(ROOT/"scripts/build-release-candidate.py").read_text() + for relative in ( + "scripts/qualify-release-aggregate.py", + "scripts/qualify-release-smoke.py", + "scripts/release_http_contract.py", + "scripts/verify-release-oci.py", + "scripts/spdx_license_contract.py", + "qualification/spdx-license-identifiers-v3.29.0.json", + "qualification/release-scanner-v1.json", + "deploy/release/scanner/target.go.patch", + "deploy/release/scanner/Dockerfile", + "scripts/prepare-release-scanner.py", + "scripts/ci-release-scanner.py", + "scripts/check-release-scanner-content.py", + ): + with self.subTest(relative=relative): + self.assertIn('"' + relative + '"',builder) + source_code=(ROOT/"scripts/qualify-release-aggregate.py").read_text() + self.assertIn("with verified_release_snapshot",source_code) + self.assertIn("Git committed bytes",source_code) + + def test_every_independent_release_verifier_uses_isolated_committed_source(self): + source=(ROOT/".github/workflows/release-candidate.yml").read_text() + self.assertIn("Checkout isolated exact-commit source",source) + self.assertIn('git worktree add --detach "$VERIFIED" "$SOURCE_SHA"',source) + for name in ( + "qualify-release-smoke.py", "inspect-release-oci.py", + "verify-release-oci.py", "qualify-release-host.py", + "qualify-release-aggregate.py", + ): + with self.subTest(executable=name): + self.assertIn( + '"$RELEASE1_VERIFIED_ROOT/scripts/'+name+'"',source) + self.assertNotIn("python3 scripts/"+name+" \\",source) + self.assertLess(source.index("Checkout isolated exact-commit source"), + source.index("Independently reconstruct all packaged")) + self.assertLess(source.index("Checkout isolated exact-commit source"), + source.index("Export standalone OCI archive")) + + def test_release_artifacts_exclude_raw_provenance_and_image_bytes(self): + content=(ROOT/".github/workflows/release-candidate.yml").read_text() + upload=content.split("- name: Do not publish combined image",1)[1] + self.assertIn("oci-attestation-structure.json", upload) + self.assertIn("smoke-verdict.json",upload) + self.assertIn("aggregate.json",upload) + for forbidden in ("/*.json", "/*.txt", "/*.log", + "build/test-results/release1/oci-build-metadata.json", + "build/test-results/release1/docker-build-metadata.json", + "build/test-results/release1/docker-inspect.json", + "build/release1-offline.oci.tar"): + self.assertNotIn(forbidden,upload) + + def test_offline_oci_export_is_named_and_diagnostics_precede_admission(self): + content=(ROOT/".github/workflows/release-candidate.yml").read_text() + export=content.split("- name: Export standalone OCI archive",1)[1] + self.assertIn("--tag allfather/release1:offline",export) + self.assertIn("scripts/inspect-release-oci.py",export) + self.assertLess(export.index("scripts/inspect-release-oci.py"), + export.index("scripts/verify-release-oci.py")) + self.assertIn("scripts/qualify-release-smoke.py",content) + self.assertLess(content.index("scripts/qualify-release-smoke.py"), + content.index("- name: Export standalone OCI archive")) + self.assertIn('--oci-archive "$GITHUB_WORKSPACE/build/release1-offline.oci.tar"',content) + self.assertIn("snapshot-acquisition-failure.json",content) + self.assertIn("snapshot-availability.json", content) + self.assertLess(content.index("scripts/check-release-snapshot.py"), + content.index("- name: Install exact engine")) + self.assertIn('exit 1',content.split("snapshot-acquisition-failure.json",1)[1]) + self.assertIn("candidate_qualified",content) + self.assertIn("build/test-results/release1/smoke-verdict.json",content) + +if __name__=="__main__": + unittest.main()