From 23e4fb0b4edd319f69bdcc12fbabce35e92e58b8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 9 Aug 2026 02:39:09 +0200 Subject: [PATCH] fix(ci): scan extracted UI release files only Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 133 ++---------------- .github/workflows/_smoke.yml | 19 +-- .github/workflows/dry-run.yml | 8 +- .github/workflows/release.yml | 17 +-- README.md | 4 +- SECURITY.md | 4 +- docs/index.html | 8 +- scripts/ci/README.md | 2 +- scripts/ci/append-vt-notes.sh | 60 ++++---- scripts/ci/check-virustotal.sh | 31 ++-- scripts/ci/extract-release-archives.sh | 30 ++-- scripts/ci/publish-vt-evidence.sh | 4 +- ...test_release_archive_extractor_contract.sh | 49 ++++--- tests/test_smoke_fixture_contract.sh | 8 +- tests/test_vt_gate_zero_tolerance_contract.sh | 38 +++-- tests/test_vt_release_notes_contract.sh | 54 ++++--- tests/test_windows_bundle_contract.sh | 23 ++- 17 files changed, 204 insertions(+), 288 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 3f55dc26..0f4dd1a5 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -12,11 +12,6 @@ on: description: 'Generate build provenance attestations for release artifacts' type: boolean default: false - ui_only: - description: 'Build only the UI runtime archives' - type: boolean - default: false - permissions: contents: read id-token: write @@ -67,34 +62,7 @@ jobs: with: node-version: "22" - - name: Build standard binary - if: ${{ !inputs.ui_only }} - env: - VERSION: ${{ inputs.version }} - CC: ${{ matrix.cc }} - CXX: ${{ matrix.cxx }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" "CC=$CC" "CXX=$CXX" - else - scripts/build.sh "CC=$CC" "CXX=$CXX" - fi - - - name: Ad-hoc sign macOS standard binary - if: ${{ !inputs.ui_only && startsWith(matrix.os, 'macos') }} - run: codesign --sign - --force build/c/codebase-memory-mcp - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - - - name: Build UI binary + - name: Build release binary with UI env: VERSION: ${{ inputs.version }} CC: ${{ matrix.cc }} @@ -106,7 +74,7 @@ jobs: scripts/build.sh --with-ui "CC=$CC" "CXX=$CXX" fi - - name: Ad-hoc sign macOS UI binary + - name: Ad-hoc sign macOS release binary if: startsWith(matrix.os, 'macos') run: codesign --sign - --force build/c/codebase-memory-mcp @@ -116,10 +84,10 @@ jobs: # Archive layout/name live in the ONE canonical script the local # artifact-flow smoke lane also runs (venue-parity contract). - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} --variant ui - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -150,30 +118,7 @@ jobs: with: node-version: "22" - - name: Build standard binary - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - env: - VERSION: ${{ inputs.version }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" CC=clang CXX=clang++ - else - scripts/build.sh CC=clang CXX=clang++ - fi - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - run: scripts/package-release.sh windows amd64 CC=clang CXX=clang++ - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-windows-amd64.zip - - - name: Build UI binary + - name: Build release binary with UI shell: msys2 {0} env: VERSION: ${{ inputs.version }} @@ -186,11 +131,11 @@ jobs: # Archive layout/name live in the ONE canonical script the local # artifact-flow smoke lane also runs (venue-parity contract). - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} run: scripts/package-release.sh windows amd64 --variant ui CC=clang CXX=clang++ - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -225,30 +170,7 @@ jobs: with: node-version: "22" - - name: Build standard binary - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - env: - VERSION: ${{ inputs.version }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" CC=clang CXX=clang++ - else - scripts/build.sh CC=clang CXX=clang++ - fi - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - shell: msys2 {0} - run: scripts/package-release.sh windows arm64 CC=clang CXX=clang++ - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-windows-arm64.zip - - - name: Build UI binary + - name: Build release binary with UI shell: msys2 {0} env: VERSION: ${{ inputs.version }} @@ -259,11 +181,11 @@ jobs: scripts/build.sh --with-ui CC=clang CXX=clang++ fi - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} run: scripts/package-release.sh windows arm64 --variant ui CC=clang CXX=clang++ - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -297,34 +219,7 @@ jobs: with: node-version: "22" - - name: Build standard binary (static) - if: ${{ !inputs.ui_only }} - env: - VERSION: ${{ inputs.version }} - run: | - if [ -n "$VERSION" ]; then - scripts/build.sh --version "$VERSION" CC=gcc CXX=g++ STATIC=1 - else - scripts/build.sh CC=gcc CXX=g++ STATIC=1 - fi - - - name: Verify standard static linking - if: ${{ !inputs.ui_only }} - run: | - file build/c/codebase-memory-mcp - ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked" - - - name: Archive standard binary (canonical package-release.sh) - if: ${{ !inputs.ui_only }} - run: scripts/package-release.sh linux ${{ matrix.arch }}-portable - - - name: Attest standard binary provenance - if: ${{ inputs.attest && !inputs.ui_only }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz - - - name: Build UI binary (static) + - name: Build release binary with UI (static) env: VERSION: ${{ inputs.version }} run: | @@ -334,15 +229,15 @@ jobs: scripts/build.sh --with-ui CC=gcc CXX=g++ STATIC=1 fi - - name: Verify UI static linking + - name: Verify release static linking run: | file build/c/codebase-memory-mcp ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked" - - name: Archive UI binary (canonical package-release.sh) + - name: Archive release binary (canonical package-release.sh) run: scripts/package-release.sh linux ${{ matrix.arch }}-portable --variant ui - - name: Attest UI binary provenance + - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index fca2f040..21c06b85 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -8,11 +8,6 @@ on: description: 'Smoke the shipped binaries on the broad platform matrix (extra OS versions) instead of the core set' type: boolean default: false - ui_only: - description: 'Smoke only the UI runtime archives' - type: boolean - default: false - permissions: contents: read @@ -34,7 +29,6 @@ jobs: id: set env: BROAD: ${{ inputs.broad_platforms }} - UI_ONLY: ${{ inputs.ui_only }} run: | CORE_UNIX='[ {"os":"ubuntu-latest","goos":"linux","goarch":"amd64"}, @@ -77,14 +71,9 @@ jobs: WIN=$(jq -cn --argjson a "$CORE_WIN" '$a') PORTABLE=$(jq -cn --argjson a "$CORE_PORTABLE" '$a') fi - # Keep the variant dimension explicit because the smoke wrappers use it - # to require and validate the UI pack. VT-focused dry runs select only - # UI; release compatibility retains both variants until migration. - if [ "$UI_ONLY" = "true" ]; then - VARIANTS='["ui"]' - else - VARIANTS='["standard","ui"]' - fi + # Keep the UI marker explicit because the smoke wrappers use it to + # require and validate the shipped content-addressed frontend pack. + VARIANTS='["ui"]' UNIX_M=$(jq -cn --argjson a "$UNIX" --argjson v "$VARIANTS" '{include:[$a[] as $o | $v[] as $t | ($o + {variant:$t})]}') WIN_M=$(jq -cn --argjson a "$WIN" --argjson v "$VARIANTS" '{include:[$a[] as $o | $v[] as $t | ($o + {variant:$t})]}') PORTABLE_M=$(jq -cn --argjson a "$PORTABLE" --argjson v "$VARIANTS" '{include:[$a[] as $o | $v[] as $t | ($o + {variant:$t})]}') @@ -140,7 +129,7 @@ jobs: scripts/security-network.sh "$BIN" - name: MCP robustness test (selected linux-amd64 runtime) - if: matrix.goos == 'linux' && matrix.goarch == 'amd64' && (inputs.ui_only || matrix.variant == 'standard') + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' run: | BIN="$RUNNER_TEMP/cbm-artifact/codebase-memory-mcp" scripts/security-fuzz.sh "$BIN" diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index e747c67d..14f20df8 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -91,7 +91,6 @@ jobs: uses: ./.github/workflows/_build.yml with: attest: false - ui_only: true # ── Smoke test every binary ──────────────────────────────────── # Run unless builds were skipped or a build leg FAILED. Every build leg @@ -103,7 +102,6 @@ jobs: uses: ./.github/workflows/_smoke.yml with: broad_platforms: true - ui_only: true # ── Soak tests (optional, parallel with smoke) ──────────────── soak: @@ -148,8 +146,8 @@ jobs: merge-multiple: true path: ${{ runner.temp }}/release-archives - # Same extraction the release verify job performs, so the bytes handed to - # VirusTotal here are the bytes a user would download and run. + # Same extraction the release verify job performs. VirusTotal receives + # only extracted files and unpacked UI assets, never .tar.gz/.zip containers. - name: Extract archive artifacts run: | scripts/ci/extract-release-archives.sh "$RUNNER_TEMP/release-archives" binaries \ @@ -167,7 +165,7 @@ jobs: files: binaries/objects/* request_rate: 4 - # Same ZERO-tolerance gate as the release. Every artifact must arrive + # Same ZERO-tolerance gate as the release. Every extracted object must arrive # naturally clean; any malicious or suspicious verdict fails this run. - name: Wait for VirusTotal results env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81c30e3b..a2d806b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,15 +216,16 @@ jobs: mkdir -p "$ARCHIVE_DIR" binaries gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries \ - --expect-archives=16 \ - --expect-binaries=16 \ + --archive-scope=ui \ + --expect-archives=8 \ + --expect-binaries=8 \ --expect-packs=8 \ - --expect-runtime-files=64 + --expect-runtime-files=32 - - name: Security audits on all unique release objects + - name: Security audits on all unique extracted release objects run: | - # Audit every distinct archive/member/UI-asset byte sequence. The - # associations manifest preserves every shipped archive location. + # Audit every distinct extracted member/UI-asset byte sequence. The + # associations manifest retains each source archive's SHA-256. # security-strings.sh detects file type and applies binary-only # rules (URL allowlist, dangerous-command detection) only to real # binaries; for shell scripts it still runs credential and base64 @@ -243,7 +244,7 @@ jobs: files: binaries/objects/* request_rate: 4 - # ZERO tolerance: every artifact must arrive naturally clean. Any + # ZERO tolerance: every extracted object must arrive naturally clean. Any # malicious or suspicious verdict blocks the release with no exception. - name: Wait for VirusTotal results env: @@ -275,7 +276,7 @@ jobs: VT_RESULTS_PATH: binaries/vt-results.tsv run: bash scripts/ci/publish-vt-evidence.sh - # Renders the exact scan scope plus archive SHA-256/engine evidence. It + # Renders extracted-file scan results plus archive SHA-256 provenance. It # runs only after the exact-set zero-tolerance gate passed. - name: Append VirusTotal scan links to release notes env: diff --git a/README.md b/README.md index 203d6f1c..520b95b3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si > **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration. -> **Security & Trust** — This tool reads your codebase and writes to your agent configuration files. That is what it is designed to do. If you prefer to audit before running, the [full source is here](https://github.com/DeusData/codebase-memory-mcp). Release archives are signed and checksummed; every distinct shipped archive, member, and UI-asset byte object is submitted to VirusTotal. The release notes record the measured engine count and exact 0/N result; publication requires at least 50 decisive engine results with zero malicious and zero suspicious verdicts. All processing happens 100% locally; your code never leaves your machine. Found a security issue? We want to know — see [SECURITY.md](SECURITY.md). Security is Priority #1 for us. +> **Security & Trust** — This tool reads your codebase and writes to your agent configuration files. That is what it is designed to do. If you prefer to audit before running, the [full source is here](https://github.com/DeusData/codebase-memory-mcp). Release archives are signed and checksummed; every distinct extracted member and unpacked UI asset is submitted to VirusTotal, while the downloadable `.tar.gz`/`.zip` containers themselves are not. The release notes record the measured engine count and exact 0/N result; publication requires at least 50 decisive engine results with zero malicious and zero suspicious verdicts. All processing happens 100% locally; your code never leaves your machine. Found a security issue? We want to know — see [SECURITY.md](SECURITY.md). Security is Priority #1 for us.

Graph visualization UI showing the codebase-memory-mcp knowledge graph @@ -791,7 +791,7 @@ internal/cbm/ Vendored tree-sitter grammars (158 languages) + AST extrac Every release is verified through a multi-layer pipeline before publication: -- **VirusTotal** — every release object is scanned (minimum 50 decisive engines; zero malicious and zero suspicious required). Release notes summarize the archive rows and link durable public association, exact-scan-set, and per-object result files containing every measured engine count and VirusTotal report URL. +- **VirusTotal** — every distinct extracted release file and unpacked UI asset is scanned; downloadable `.tar.gz`/`.zip` containers are not submitted (minimum 50 decisive engines; zero malicious and zero suspicious required). Release notes retain archive SHA-256 provenance and link durable public association, exact-scan-set, and per-object result files containing every measured engine count and VirusTotal report URL. - **SLSA Level 3** — cryptographic build provenance generated by the trusted GitHub Actions build workflow; verify with `gh attestation verify --repo DeusData/codebase-memory-mcp --signer-workflow DeusData/codebase-memory-mcp/.github/workflows/_build.yml` - **Sigstore cosign** — keyless signatures on all artifacts; bundles included in every release - **SHA-256 checksums** — `checksums.txt` published with every release; verified by both install scripts before extraction diff --git a/SECURITY.md b/SECURITY.md index 37a61dcb..bb974e7f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,7 +95,7 @@ disclosure. Research conducted under this policy is considered authorised. ## Security Measures -This project implements multiple layers of security verification. Every release archive and its extracted runtime objects must pass all checks before users can download it (draft → verify → publish flow). +This project implements multiple layers of security verification. Every release archive is signed and checksummed, and every extracted runtime object must pass its applicable checks before users can download it (draft → verify → publish flow). ### Build-Time (CI — every commit) @@ -126,7 +126,7 @@ Releases are created as **drafts** (invisible to users) and only published after 2. **Sigstore cosign signing** — keyless digital signatures verifiable by anyone 3. **SBOM** — Software Bill of Materials (SPDX) listing all vendored dependencies 4. **SHA-256 checksums** — published with every release -5. **VirusTotal scanning** — every distinct shipped archive, member, and UI-asset byte object is scanned. Each completed analysis must contain at least 50 decisive engine results, zero malicious verdicts, and zero suspicious verdicts. Release notes summarize archive results and link durable public association, exact-scan-set, per-object-result, and evidence-checksum assets. +5. **VirusTotal scanning** — every distinct extracted member and unpacked UI asset is scanned; downloadable `.tar.gz`/`.zip` containers are not submitted. Each completed analysis must contain at least 50 decisive engine results, zero malicious verdicts, and zero suspicious verdicts. Release notes retain archive SHA-256 provenance and link durable public association, exact-scan-set, per-object-result, and evidence-checksum assets. 6. **OpenSSF Scorecard** — repository security health score Scope of the SLSA claim: this is a build provenance claim for release diff --git a/docs/index.html b/docs/index.html index a8d7d35c..0f2b3687 100644 --- a/docs/index.html +++ b/docs/index.html @@ -165,7 +165,7 @@ "name": "Does codebase-memory-mcp send my code anywhere?", "acceptedAnswer": { "@type": "Answer", - "text": "No. All indexing and querying happen 100% locally on your machine. There is no embedded LLM and no API key — your MCP client acts as the intelligence layer. Release archives are signed and checksummed, and every distinct shipped archive, member, and UI-asset byte object is submitted to VirusTotal; release notes record its measured 0/N result, with at least 50 decisive engines and zero malicious or suspicious verdicts required." + "text": "No. All indexing and querying happen 100% locally on your machine. There is no embedded LLM and no API key — your MCP client acts as the intelligence layer. Release archives are signed and checksummed; every distinct extracted member and unpacked UI asset is submitted to VirusTotal, while downloadable .tar.gz/.zip containers are not. Release notes record measured 0/N results, with at least 50 decisive engines and zero malicious or suspicious verdicts required." } }, { @@ -791,9 +791,9 @@

Does codebase-memory-mcp send my code anywhere?

No. All indexing and querying happen 100% locally. There is no embedded LLM and no API key. Release - archives are signed and checksummed, and every distinct shipped archive, member, and UI-asset byte object - is submitted to VirusTotal. Release notes - record its measured 0/N result; at least 50 decisive engines and zero malicious or suspicious verdicts are required.

+ archives are signed and checksummed; every distinct extracted member and unpacked UI asset is submitted + to VirusTotal, while downloadable .tar.gz/.zip containers are not. Release notes record the scanned files' measured 0/N + results; at least 50 decisive engines and zero malicious or suspicious verdicts are required.

Does it support semantic or natural-language code search?

Yes. Alongside structural and full-text search, search_graph's semantic_query diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 2d482ebb..c00f81fe 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -15,4 +15,4 @@ CI and the local infrastructure — both of which the venue-parity contract | `generate-sbom.py` | The release SPDX SBOM (vendored versions reviewable here, diffable by vendoring PRs — was inline YAML). | `release.yml` | | `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML). | `pr.yml ci-ok` | | `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). | `_test.yml` shard-completeness | -| `check-virustotal.sh` | Release-asset VirusTotal lookups. | `release.yml` | +| `check-virustotal.sh` | Exact extracted-file VirusTotal lookups. | `release.yml` | diff --git a/scripts/ci/append-vt-notes.sh b/scripts/ci/append-vt-notes.sh index 58804617..18974851 100755 --- a/scripts/ci/append-vt-notes.sh +++ b/scripts/ci/append-vt-notes.sh @@ -106,7 +106,7 @@ asset_base = ( ) association_meta, associations = read_tsv( association_path, - "cbm-release-scan-associations-v2", + "cbm-release-scan-associations-v3", ASSOCIATION_FIELDS, ) result_meta, results = read_tsv( @@ -171,19 +171,22 @@ for association in associations: ): fail(f"result hash/size differs from association: {association['scan_path']}") -archive_rows = [row for row in associations if row["association_type"] == "archive"] member_rows = [row for row in associations if row["association_type"] == "member"] asset_rows = [row for row in associations if row["association_type"] == "pack_asset"] -if len(archive_rows) != association_meta.get("archives"): - fail("downloadable archive association count is inconsistent") -archive_names = [row["archive"] for row in archive_rows] -if len(set(archive_names)) != len(archive_names): - fail("downloadable archive association is duplicated") -archive_hashes = {row["archive"]: row["archive_sha256"] for row in archive_rows} -if any(row["archive_sha256"] != row["object_sha256"] for row in archive_rows): - fail("downloadable archive SHA is not bound to its scan object") -if any(archive_hashes.get(row["archive"]) != row["archive_sha256"] for row in associations): - fail("member/asset association is not bound to its downloadable archive") +if len(member_rows) + len(asset_rows) != len(associations): + fail("association manifest contains a non-extracted scan target") +archive_hashes: Dict[str, str] = {} +for association in associations: + archive = association["archive"] + archive_sha256 = association["archive_sha256"] + if not archive or re.fullmatch(r"[0-9a-f]{64}", archive_sha256) is None: + fail("member/asset association lacks valid archive SHA provenance") + previous_sha256 = archive_hashes.get(archive) + if previous_sha256 is not None and previous_sha256 != archive_sha256: + fail(f"conflicting archive SHA provenance: {archive}") + archive_hashes[archive] = archive_sha256 +if len(archive_hashes) != association_meta.get("archives"): + fail("distinct archive provenance count is inconsistent") engine_counts = [int(row["completed_engines"]) for row in results] minimum = min(engine_counts) @@ -198,14 +201,20 @@ section = [ "## Security Verification", "", ( - f"VirusTotal completed **{len(results)} distinct byte objects** covering " - f"**{len(associations)} exact release associations**: " - f"{len(archive_rows)} downloadable archives, {len(member_rows)} archive members, " - f"and {len(asset_rows)} independently extracted UI-pack assets." + f"VirusTotal completed **{len(results)} distinct extracted byte objects** covering " + f"**{len(associations)} exact extracted-file associations**: " + f"{len(member_rows)} archive members and " + f"{len(asset_rows)} independently extracted UI-pack assets." + ), + ( + f"The extraction manifest binds those associations to " + f"**{len(archive_hashes)} downloadable " + f"{'archive' if len(archive_hashes) == 1 else 'archives'}** by SHA-256 provenance. " + "Downloadable .tar.gz/.zip release containers were not submitted to VirusTotal." ), "", ( - f"Every object returned **0 malicious and 0 suspicious** verdicts with " + f"Every scanned object returned **0 malicious and 0 suspicious** verdicts with " f"{engine_range} decisive engine results (required minimum: {policy})." ), ( @@ -217,20 +226,17 @@ section = [ "Durable public evidence: " f"[associations]({asset_base}/virustotal-associations.tsv), " f"[exact scan set]({asset_base}/virustotal-scan-set.tsv), " - f"[per-object results and report links]({asset_base}/virustotal-results.tsv), " + f"[per-extracted-object results and report links]({asset_base}/virustotal-results.tsv), " f"[evidence checksums]({asset_base}/virustotal-evidence-checksums.txt)." ), "", - "| Downloadable archive | SHA-256 | Engines | VirusTotal |", - "|---|---|---:|---|", + "Archive SHA-256 provenance (from the extraction manifest):", + "", + "| Downloadable archive | SHA-256 provenance |", + "|---|---|", ] -for association in sorted(archive_rows, key=lambda row: row["archive"]): - result = results_by_path[association["scan_path"]] - sha256 = result["sha256"] - section.append( - f"| `{association['archive']}` | `{sha256}` | " - f"{result['completed_engines']} | [0 detections]({result['virustotal_url']}) |" - ) +for archive, archive_sha256 in sorted(archive_hashes.items()): + section.append(f"| `{archive}` | `{archive_sha256}` |") section.append(END) replacement = "\n".join(section) diff --git a/scripts/ci/check-virustotal.sh b/scripts/ci/check-virustotal.sh index 427d398d..d186df13 100755 --- a/scripts/ci/check-virustotal.sh +++ b/scripts/ci/check-virustotal.sh @@ -186,7 +186,7 @@ def parse_versioned_tsv( def load_expected(path: pathlib.Path) -> Tuple[List[ExpectedObject], int]: metadata, rows = parse_versioned_tsv( path, - marker="cbm-release-scan-set-v1", + marker="cbm-release-scan-set-v2", fields=SCAN_FIELDS, ) expected_count = metadata.get("scan_objects") @@ -217,7 +217,7 @@ def load_expected(path: pathlib.Path) -> Tuple[List[ExpectedObject], int]: size = int(row["size"]) association_count = int(row["association_count"]) kinds = row["association_kinds"].split(",") - allowed_kinds = {"archive", "binary", "pack", "runtime", "ui_asset"} + allowed_kinds = {"binary", "pack", "runtime", "ui_asset"} if ( association_count < 1 or any(not kind or kind not in allowed_kinds for kind in kinds) @@ -258,7 +258,7 @@ def validate_associations( ) -> None: metadata, rows = parse_versioned_tsv( path, - marker="cbm-release-scan-associations-v2", + marker="cbm-release-scan-associations-v3", fields=ASSOCIATION_FIELDS, ) if ( @@ -273,29 +273,26 @@ def validate_associations( seen: set[Tuple[str, str, str, str]] = set() archive_hashes: Dict[str, str] = {} for row in rows: - if row["association_type"] != "archive": - continue archive = row["archive"] - if not archive or archive in archive_hashes or row["archive_sha256"] != row["object_sha256"]: - raise GateError(f"malformed or duplicate archive association: {archive}") - archive_hashes[archive] = row["archive_sha256"] + archive_sha256 = row["archive_sha256"] + if not archive or SHA256_RE.fullmatch(archive_sha256) is None: + raise GateError(f"malformed archive provenance: {archive}") + previous = archive_hashes.setdefault(archive, archive_sha256) + if previous != archive_sha256: + raise GateError(f"conflicting archive provenance: {archive}") + if metadata.get("archives") != len(archive_hashes): + raise GateError("archive provenance count does not match the association manifest") for row in rows: association_type = row["association_type"] key = (association_type, row["archive"], row["member"], row["asset_path"]) if key in seen: raise GateError(f"duplicate release association: {key}") seen.add(key) - if association_type not in {"archive", "member", "pack_asset"} or not row["archive"]: + if association_type not in {"member", "pack_asset"} or not row["archive"]: raise GateError(f"malformed release association: {key}") if archive_hashes.get(row["archive"]) != row["archive_sha256"]: - raise GateError(f"association is not bound to its archive object: {key}") - if association_type == "archive": - semantic_valid = ( - not row["member"] - and not row["asset_path"] - and row["kind"] == "archive" - ) - elif association_type == "member": + raise GateError(f"association is not bound to its archive provenance: {key}") + if association_type == "member": semantic_valid = bool(row["member"]) and not row["asset_path"] else: semantic_valid = bool(row["member"] and row["asset_path"] and row["mime"]) and row["kind"] == "ui_asset" diff --git a/scripts/ci/extract-release-archives.sh b/scripts/ci/extract-release-archives.sh index 48785727..48e7aaf7 100755 --- a/scripts/ci/extract-release-archives.sh +++ b/scripts/ci/extract-release-archives.sh @@ -9,12 +9,14 @@ # # The output directory is published as one atomic bundle: # objects/ one file per distinct byte sequence -# associations.tsv every archive, member and CBMUIPK asset -> scan object +# associations.tsv every extracted member and CBMUIPK asset -> scan object # scan-set.tsv the exact path/hash/size set the VT action must return # -# The release archives themselves, their exact members, and the uncompressed -# HTML/JavaScript/CSS/etc payloads inside every CBMUIPK v1 pack are all covered. -# Identical bytes are uploaded once, but no shipped association is discarded. +# Every archive is validated and hashed for provenance, but downloadable +# .tar.gz/.zip release containers are not scanned. Their exact members and the +# uncompressed HTML/JavaScript/CSS/etc payloads inside every CBMUIPK v1 pack are +# covered. Identical bytes are uploaded once, but no extracted member/asset +# association is discarded. set -euo pipefail if [ "$#" -lt 2 ]; then @@ -733,24 +735,16 @@ def main(argv: Sequence[str]) -> None: staged_output = pathlib.Path(temporary) / "bundle" staged_output.mkdir(mode=0o700) store = ObjectStore(staged_output / "objects") + archive_store = ObjectStore(pathlib.Path(temporary) / "archives") for archive_path in archive_paths: archive_name = archive_path.name variant = "ui" if archive_name.startswith("codebase-memory-mcp-ui-") else "standard" - archive_object = store.ingest_path( + archive_object = archive_store.ingest_path( archive_path, ceiling=MAX_ARCHIVE_BYTES, label=archive_name, ) archive_sha256 = archive_object.sha256 - add_association( - rows, - archive_object, - association_type="archive", - archive=archive_name, - archive_sha256=archive_sha256, - variant=variant, - kind="archive", - ) _, kinds, member_total = ( process_tar( archive_object.path, @@ -792,7 +786,7 @@ def main(argv: Sequence[str]) -> None: rows.sort( key=lambda row: ( str(row["archive"]), - {"archive": 0, "member": 1, "pack_asset": 2}[str(row["association_type"])], + {"member": 0, "pack_asset": 1}[str(row["association_type"])], str(row["member"]), str(row["asset_path"]), ) @@ -808,7 +802,7 @@ def main(argv: Sequence[str]) -> None: ) write_tsv( staged_output / "associations.tsv", - marker="cbm-release-scan-associations-v2", + marker="cbm-release-scan-associations-v3", metadata=((key, counts[key]) for key in metadata_order), fields=ASSOCIATION_FIELDS, rows=rows, @@ -825,7 +819,7 @@ def main(argv: Sequence[str]) -> None: ] write_tsv( staged_output / "scan-set.tsv", - marker="cbm-release-scan-set-v1", + marker="cbm-release-scan-set-v2", metadata=((key, counts[key]) for key in ("scan_objects", "associations")), fields=SCAN_SET_FIELDS, rows=scan_rows, @@ -848,7 +842,7 @@ def main(argv: Sequence[str]) -> None: ) print( f"scan bundle: {counts['scan_objects']} distinct byte objects cover " - f"{counts['associations']} archive/member/asset associations" + f"{counts['associations']} extracted member/asset associations" ) print(f"associations: {output_dir / 'associations.tsv'}") print(f"expected scan set: {output_dir / 'scan-set.tsv'}") diff --git a/scripts/ci/publish-vt-evidence.sh b/scripts/ci/publish-vt-evidence.sh index 3369855a..c079012b 100644 --- a/scripts/ci/publish-vt-evidence.sh +++ b/scripts/ci/publish-vt-evidence.sh @@ -38,8 +38,8 @@ publish_copy() { chmod 0644 "$WORK/$destination" } -publish_copy "$VT_ASSOCIATIONS" cbm-release-scan-associations-v2 virustotal-associations.tsv -publish_copy "$VT_EXPECTED_SCAN_SET" cbm-release-scan-set-v1 virustotal-scan-set.tsv +publish_copy "$VT_ASSOCIATIONS" cbm-release-scan-associations-v3 virustotal-associations.tsv +publish_copy "$VT_EXPECTED_SCAN_SET" cbm-release-scan-set-v2 virustotal-scan-set.tsv publish_copy "$VT_RESULTS_PATH" cbm-virustotal-results-v1 virustotal-results.tsv for name in virustotal-associations.tsv virustotal-results.tsv virustotal-scan-set.tsv; do diff --git a/tests/test_release_archive_extractor_contract.sh b/tests/test_release_archive_extractor_contract.sh index 9797c8d3..b07eb37f 100644 --- a/tests/test_release_archive_extractor_contract.sh +++ b/tests/test_release_archive_extractor_contract.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Contract for the release scan boundary: exact archives, exact members, -# independently parsed UI-pack assets, byte-exact deduplication and atomic -# publication of a complete association manifest plus scan set. +# Contract for the release scan boundary: archives are validated and hashed; +# only exact extracted members and independently parsed UI-pack assets enter +# the byte-deduplicated, atomically published scan set. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -298,7 +298,6 @@ def build_matrix( path = archive_dir / name write_archive(path, entries) if mutation is None: - expected[("archive", name, "", "")] = path.read_bytes() for entry in entries: if entry["kind"] != "regular": continue @@ -380,10 +379,10 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora fail("scan bundle must publish exactly objects/ plus its two manifests") association_metadata, rows = parse_manifest( - output / "associations.tsv", "cbm-release-scan-associations-v2" + output / "associations.tsv", "cbm-release-scan-associations-v3" ) expected_pack_assets = 8 * len(asset_payloads) - expected_associations = 16 + 88 + expected_pack_assets + expected_associations = 88 + expected_pack_assets expected_metadata = { **expected_base_counts, "pack_assets": expected_pack_assets, @@ -393,13 +392,19 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora if association_metadata != expected_metadata: fail(f"association metadata mismatch: {association_metadata!r}") if len(rows) != expected_associations: - fail("association manifest does not contain every archive/member/asset association") + fail("association manifest does not contain every extracted member/asset association") actual_associations = { (row["association_type"], row["archive"], row["member"], row["asset_path"]): row for row in rows } + if any(row["association_type"] == "archive" or row["kind"] == "archive" for row in rows): + fail("archive containers must not appear in the VirusTotal association set") if set(actual_associations) != set(expected): fail("association manifest keys differ from exact shipped associations") + archive_hashes = { + name: hashlib.sha256((valid / "archives" / name).read_bytes()).hexdigest() + for name in archive_names + } for association, data in expected.items(): row = actual_associations[association] object_path = output / row["scan_path"] @@ -407,6 +412,8 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora fail(f"scan object changed bytes for association {association!r}") if row["object_sha256"] != hashlib.sha256(data).hexdigest() or int(row["size"]) != len(data): fail(f"association digest/size is wrong for {association!r}") + if row["archive_sha256"] != archive_hashes[row["archive"]]: + fail(f"association archive provenance is wrong for {association!r}") object_paths = sorted((output / "objects").iterdir()) if len(object_paths) != len(set(expected.values())): @@ -424,7 +431,7 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora if len(script_rows) != 8 or len({row["scan_path"] for row in script_rows}) != 1: fail("each pack's JavaScript association must reach one exact deduplicated script object") - scan_metadata, scan_rows = parse_manifest(output / "scan-set.tsv", "cbm-release-scan-set-v1") + scan_metadata, scan_rows = parse_manifest(output / "scan-set.tsv", "cbm-release-scan-set-v2") if scan_metadata != { "scan_objects": expected_metadata["scan_objects"], "associations": expected_associations, @@ -450,9 +457,9 @@ with tempfile.TemporaryDirectory(prefix="cbm-release-scan-contract-") as tempora if ui_result.returncode != 0: fail(f"canonical UI-only matrix was rejected: {ui_result.stdout.strip()}") ui_metadata, ui_rows = parse_manifest( - ui_output / "associations.tsv", "cbm-release-scan-associations-v2" + ui_output / "associations.tsv", "cbm-release-scan-associations-v3" ) - ui_associations = 8 + 48 + expected_pack_assets + ui_associations = 48 + expected_pack_assets if ui_metadata != { **ui_expected_base_counts, "pack_assets": expected_pack_assets, @@ -509,7 +516,7 @@ source = extractor.read_text(encoding="utf-8") if "files_equal(candidate, existing.path)" not in source: fail("deduplication no longer exact-compares bytes after SHA-256+size grouping") if source.count("archive_object.path,") < 2: - fail("members are not parsed from the same retained bytes staged as the archive object") + fail("members are not parsed from the same retained archive bytes used for provenance") for ceiling in ("MAX_ARCHIVE_BYTES", "MAX_MEMBER_BYTES", "MAX_TOTAL_MEMBER_BYTES"): if ceiling not in source: fail(f"extractor lost required size ceiling {ceiling}") @@ -518,16 +525,16 @@ release = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") dry_run = (root / ".github/workflows/dry-run.yml").read_text(encoding="utf-8") build = (root / ".github/workflows/_build.yml").read_text(encoding="utf-8") smoke = (root / ".github/workflows/_smoke.yml").read_text(encoding="utf-8") -if "ui_only:" not in build or build.count("if: ${{ !inputs.ui_only }}") < 4: - fail("canonical build workflow must gate every standard build family behind ui_only") +if "ui_only" in build or "Build standard binary" in build: + fail("canonical build workflow must not retain a non-UI release path") if build.count("--variant ui") != 4: - fail("canonical build workflow must package exactly its four UI build families") -if "UI_ONLY: ${{ inputs.ui_only }}" not in smoke or "VARIANTS='[\"ui\"]'" not in smoke: - fail("release smoke matrix must support an explicit UI-only selection") -if dry_run.count("ui_only: true") != 2 or "--archive-scope=ui" not in dry_run: - fail("dry-run must build, smoke and extract only the UI runtime set") -if "--archive-scope=ui" in release: - fail("release workflow must retain its dual-variant compatibility scope") + fail("canonical build workflow must package exactly its four UI-enabled build families") +if "ui_only" in smoke or "VARIANTS='[\"ui\"]'" not in smoke or "standard" in smoke.split("VARIANTS=", 1)[1].split("\n", 1)[0]: + fail("release smoke matrix must expose only the UI-enabled runtime") +if "ui_only" in dry_run or "--archive-scope=ui" not in dry_run: + fail("dry-run must use the canonical UI-only build/smoke/extraction path") +if "--archive-scope=ui" not in release: + fail("release verification must accept only the shipped UI-enabled archives") if ( "path: ${{ runner.temp }}/release-archives" not in dry_run or 'extract-release-archives.sh "$RUNNER_TEMP/release-archives" binaries' not in dry_run @@ -544,7 +551,7 @@ if ( ): fail("release verification must isolate downloaded archives from tracked checkout assets") for workflow_name, workflow, arguments in ( - ("release", release, count_args), + ("release", release, ui_count_args), ("dry-run", dry_run, ui_count_args), ): for argument in arguments: diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index eb33ddda..17f35df0 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -424,15 +424,15 @@ require( "release verification must feed the canonical extractor from an isolated archive directory", ) require( - "cbm-release-scan-associations-v2" in release_extractor - and "cbm-release-scan-set-v1" in release_extractor + "cbm-release-scan-associations-v3" in release_extractor + and "cbm-release-scan-set-v2" in release_extractor and "files_equal(candidate, existing.path)" in release_extractor and "parse_pack_assets" in release_extractor and "PACK_NAME.fullmatch" in release_extractor and "non-regular archive member" in release_extractor and "duplicate archive member" in release_extractor, - "release extraction must validate every exact archive/member/UI-asset association " - "and retain one exact-compared scan object per distinct byte sequence", + "release extraction must validate and hash every archive, then retain only exact " + "member/UI-asset associations and one scan object per distinct extracted byte sequence", ) require( "MSYS2_ARG_CONV_EXCL='*'" in smoke_test diff --git a/tests/test_vt_gate_zero_tolerance_contract.sh b/tests/test_vt_gate_zero_tolerance_contract.sh index 4e7f9d6f..33f3f11f 100644 --- a/tests/test_vt_gate_zero_tolerance_contract.sh +++ b/tests/test_vt_gate_zero_tolerance_contract.sh @@ -33,32 +33,34 @@ printf 'release-bytes\n' > "$FIX/work/binaries/objects/probe" printf 'second-object\n' > "$FIX/work/binaries/objects/probe2" PROBE_SHA="$(hash_file "$FIX/work/binaries/objects/probe")" PROBE2_SHA="$(hash_file "$FIX/work/binaries/objects/probe2")" +ARCHIVE_SHA='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +ARCHIVE2_SHA='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' PROBE_SIZE="$(wc -c < "$FIX/work/binaries/objects/probe" | tr -d ' ')" PROBE2_SIZE="$(wc -c < "$FIX/work/binaries/objects/probe2" | tr -d ' ')" write_manifest() { # one|two - local count=1 associations=3 - if [ "$1" = "two" ]; then count=2; associations=4; fi + local count=1 associations=2 archives=1 + if [ "$1" = "two" ]; then count=2; associations=3; archives=2; fi { - echo '# cbm-release-scan-set-v1' + echo '# cbm-release-scan-set-v2' echo "# scan_objects=$count" echo "# associations=$associations" printf 'scan_path\tsha256\tsize\tassociation_count\tassociation_kinds\n' - printf 'objects/probe\t%s\t%s\t3\tarchive,binary,runtime\n' "$PROBE_SHA" "$PROBE_SIZE" + printf 'objects/probe\t%s\t%s\t2\tbinary,runtime\n' "$PROBE_SHA" "$PROBE_SIZE" if [ "$1" = "two" ]; then - printf 'objects/probe2\t%s\t%s\t1\tarchive\n' "$PROBE2_SHA" "$PROBE2_SIZE" + printf 'objects/probe2\t%s\t%s\t1\truntime\n' "$PROBE2_SHA" "$PROBE2_SIZE" fi } > "$FIX/work/binaries/scan-set.tsv" { - echo '# cbm-release-scan-associations-v2' + echo '# cbm-release-scan-associations-v3' + echo "# archives=$archives" echo "# associations=$associations" echo "# scan_objects=$count" printf 'association_type\tarchive\tarchive_sha256\tvariant\tkind\tmember\tasset_path\tmime\tscan_path\tobject_sha256\tsize\n' - printf 'archive\tprobe.tar.gz\t%s\tstandard\tarchive\t\t\t\tobjects/probe\t%s\t%s\n' "$PROBE_SHA" "$PROBE_SHA" "$PROBE_SIZE" - printf 'member\tprobe.tar.gz\t%s\tstandard\tbinary\tcodebase-memory-mcp\t\t\tobjects/probe\t%s\t%s\n' "$PROBE_SHA" "$PROBE_SHA" "$PROBE_SIZE" - printf 'member\tprobe.tar.gz\t%s\tstandard\truntime\tLICENSE\t\t\tobjects/probe\t%s\t%s\n' "$PROBE_SHA" "$PROBE_SHA" "$PROBE_SIZE" + printf 'member\tprobe.tar.gz\t%s\tstandard\tbinary\tcodebase-memory-mcp\t\t\tobjects/probe\t%s\t%s\n' "$ARCHIVE_SHA" "$PROBE_SHA" "$PROBE_SIZE" + printf 'member\tprobe.tar.gz\t%s\tstandard\truntime\tLICENSE\t\t\tobjects/probe\t%s\t%s\n' "$ARCHIVE_SHA" "$PROBE_SHA" "$PROBE_SIZE" if [ "$1" = "two" ]; then - printf 'archive\tprobe2.zip\t%s\tstandard\tarchive\t\t\t\tobjects/probe2\t%s\t%s\n' "$PROBE2_SHA" "$PROBE2_SHA" "$PROBE2_SIZE" + printf 'member\tprobe2.zip\t%s\tstandard\truntime\tREADME.md\t\t\tobjects/probe2\t%s\t%s\n' "$ARCHIVE2_SHA" "$PROBE2_SHA" "$PROBE2_SIZE" fi } > "$FIX/work/binaries/associations.tsv" } @@ -247,7 +249,21 @@ done # The independently validated association rows, not a mutable summary cell, # decide whether an object is executable and therefore requires Microsoft. write_manifest one -sed -i.bak 's/archive,binary,runtime/archive,runtime/' "$FIX/work/binaries/scan-set.tsv" +sed -i.bak 's/binary,runtime/archive,binary,runtime/' "$FIX/work/binaries/scan-set.tsv" +rm -f "$FIX/work/binaries/scan-set.tsv.bak" +[ "$(run_gate "$clean_output")" != "0" ] || \ + fail "archive container kinds must be rejected from the VirusTotal scan set" +grep -q 'unassociated object in expected set' "$FIX/last.log" || \ + fail "archive-container scan-set rejection is not diagnosable" +write_manifest one +sed -i.bak $'s/^member\t/archive\t/' "$FIX/work/binaries/associations.tsv" +rm -f "$FIX/work/binaries/associations.tsv.bak" +[ "$(run_gate "$clean_output")" != "0" ] || \ + fail "archive containers must be rejected from the VirusTotal association set" +grep -q 'malformed release association' "$FIX/last.log" || \ + fail "archive-container association rejection is not diagnosable" +write_manifest one +sed -i.bak 's/binary,runtime/runtime/' "$FIX/work/binaries/scan-set.tsv" rm -f "$FIX/work/binaries/scan-set.tsv.bak" [ "$(run_gate "$clean_output")" != "0" ] || \ fail "scan-set kinds that contradict association rows must block" diff --git a/tests/test_vt_release_notes_contract.sh b/tests/test_vt_release_notes_contract.sh index 3488fa9b..6427ce48 100644 --- a/tests/test_vt_release_notes_contract.sh +++ b/tests/test_vt_release_notes_contract.sh @@ -10,32 +10,32 @@ trap 'rm -rf "$FIX"' EXIT fail() { echo "FAIL: $*" >&2; exit 1; } mkdir -p "$FIX/bin" "$FIX/binaries" -SHA_A='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' -SHA_B='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +ARCHIVE_SHA='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +MEMBER_SHA='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +ASSET_SHA='cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' cat > "$FIX/binaries/associations.tsv" < "$FIX/binaries/vt-results.tsv" < "$FIX/current.md" <<'EOF' Intro text. @@ -99,13 +99,21 @@ run_notes "$FIX/current.md" "$FIX/first.md" grep -q 'Intro text.' "$FIX/first.md" || fail "content before marked section was lost" grep -q 'Outro text.' "$FIX/first.md" || fail "content after marked section was lost" ! grep -q 'stale data' "$FIX/first.md" || fail "stale marked section was appended instead of replaced" -grep -q '2 distinct byte objects' "$FIX/first.md" || fail "unique-object scope missing" -grep -q '3 exact release associations' "$FIX/first.md" || fail "association scope missing" +grep -q '2 distinct extracted byte objects' "$FIX/first.md" || fail "unique extracted-object scope missing" +grep -q '2 exact extracted-file associations' "$FIX/first.md" || fail "extracted-file association scope missing" +grep -q '1 downloadable archive' "$FIX/first.md" || fail "archive provenance scope missing" grep -q '59–61 decisive engine results' "$FIX/first.md" || fail "measured engine range missing" grep -q 'Microsoft returned a decisive clean verdict for all 1 executable objects' "$FIX/first.md" || \ fail "executable Microsoft coverage is not stated" -grep -q "$SHA_A" "$FIX/first.md" || fail "full archive SHA-256 missing" -grep -q "gui/file/$SHA_A/detection" "$FIX/first.md" || fail "archive VirusTotal link missing" +grep -q "$ARCHIVE_SHA" "$FIX/first.md" || fail "full archive SHA-256 provenance missing" +grep -q '| Downloadable archive | SHA-256 provenance |' "$FIX/first.md" || \ + fail "archive SHA provenance table missing" +grep -q 'Downloadable .tar.gz/.zip release containers were not submitted to VirusTotal' "$FIX/first.md" || \ + fail "archive exclusion is not explicit" +! grep -q "gui/file/$ARCHIVE_SHA/detection" "$FIX/first.md" || \ + fail "archive VirusTotal link must not be published" +! grep -q '| Engines | VirusTotal |' "$FIX/first.md" || \ + fail "archive provenance table must not imply archive scans" for asset in virustotal-associations.tsv virustotal-scan-set.tsv virustotal-results.tsv virustotal-evidence-checksums.txt; do grep -q "releases/download/v1.0.0/$asset" "$FIX/first.md" || fail "public evidence link missing: $asset" done @@ -121,6 +129,14 @@ if run_notes "$FIX/current.md" "$FIX/surplus-capture.md"; then fi mv "$FIX/binaries/vt-results.clean.tsv" "$FIX/binaries/vt-results.tsv" +cp "$FIX/binaries/associations.tsv" "$FIX/binaries/associations.clean.tsv" +sed 's/# archives=1/# archives=2/' "$FIX/binaries/associations.clean.tsv" > \ + "$FIX/binaries/associations.tsv" +if run_notes "$FIX/current.md" "$FIX/archive-count-capture.md"; then + fail "archive provenance count must match association metadata" +fi +mv "$FIX/binaries/associations.clean.tsv" "$FIX/binaries/associations.tsv" + cat > "$FIX/reversed.md" <<'EOF' Intro text. @@ -146,12 +162,12 @@ run_publish() { bash "$PUBLISH") } cat > "$FIX/binaries/scan-set.tsv" < "$FIX/binaries/scan-set.tsv" +sed '1s/cbm-release-scan-set-v2/wrong-marker/' "$FIX/binaries/scan-set.clean.tsv" > "$FIX/binaries/scan-set.tsv" if run_publish; then fail "public evidence publication must reject a wrong scan-set marker" fi diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 1661dfed..d37a1da0 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -147,27 +147,24 @@ require( "Makefile.cbm must not expose a Windows launcher target or variable", ) -# Each archive variant is still produced through the ONE canonical packaging -# entry, so the five-or-six-file layout above governs all of them. The standard -# calls are conditionally skipped by VT-focused UI-only dry runs. +# The single shipped Windows runtime is UI-enabled and is produced through the +# ONE canonical packaging entry, so the six-file layout above governs it. build_workflow = read(".github/workflows/_build.yml") -for archive, call, ui in ( - ("codebase-memory-mcp-windows-amd64.zip", "scripts/package-release.sh windows amd64", False), +for archive, call in ( ("codebase-memory-mcp-ui-windows-amd64.zip", - "scripts/package-release.sh windows amd64 --variant ui", True), - ("codebase-memory-mcp-windows-arm64.zip", "scripts/package-release.sh windows arm64", False), + "scripts/package-release.sh windows amd64 --variant ui"), ("codebase-memory-mcp-ui-windows-arm64.zip", - "scripts/package-release.sh windows arm64 --variant ui", True), + "scripts/package-release.sh windows arm64 --variant ui"), ): - # The lookahead keeps a ui call from satisfying a standard check. - pattern = re.escape(call) + ("" if ui else r"(?!\s+--variant)") require( - re.search(pattern, build_workflow) is not None, + build_workflow.count(call) == 1, f"_build.yml must produce {archive} via the canonical packaging entry ('{call}')", ) require( - build_workflow.count("if: ${{ !inputs.ui_only }}") >= 4, - "_build.yml must skip every standard Windows build/archive in UI-only mode", + re.search(r"scripts/package-release\.sh windows (?:amd64|arm64)(?!\s+--variant)", build_workflow) is None + and "ui_only" not in build_workflow + and "Build standard binary" not in build_workflow, + "_build.yml must not retain a standard/non-UI Windows release path", ) # ── 3. install.ps1 delegates every runtime mutation to the candidate ─────────