From bf6ba08557b0900ef7959d8e9e2b83b0ad0f7fe9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:43:58 +0900 Subject: [PATCH 01/13] test(release): require immutable release evidence contract --- tests/release_evidence_contract.rs | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/release_evidence_contract.rs diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs new file mode 100644 index 0000000..c4b4a2d --- /dev/null +++ b/tests/release_evidence_contract.rs @@ -0,0 +1,43 @@ +use std::fs; + +fn release_workflow() -> String { + fs::read_to_string(".github/workflows/release.yml") + .expect("release workflow must exist before Wardnet can publish immutable evidence") +} + +fn require(workflow: &str, needle: &str) { + assert!( + workflow.contains(needle), + "release workflow must contain {needle:?}" + ); +} + +#[test] +fn release_workflow_binds_reviewed_source_to_verifiable_evidence() { + let workflow = release_workflow(); + + for required in [ + "pull_request:", + "workflow_dispatch:", + "GITHUB_REF_PROTECTED", + "refs/heads/main", + "cargo fmt --check", + "cargo test --locked --workspace", + "cargo clippy --locked --workspace --all-targets -- -D warnings", + "cargo build --locked --release", + "cargo metadata --locked --format-version=1", + "sha256sum", + "spdx-json", + "actions/attest@", + "sbom-path:", + "actions/upload-artifact@", + "persist-credentials: false", + ] { + require(&workflow, required); + } + + assert!( + !workflow.contains("@main") && !workflow.contains("@master"), + "release actions must be pinned to immutable revisions" + ); +} From 5ce56d19e2c37ce1824b1d4d8657f6391387238f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:44:40 +0900 Subject: [PATCH 02/13] build(release): generate source-bound SBOM and provenance evidence --- .github/workflows/release.yml | 206 ++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0118c4b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,206 @@ +name: Release evidence + +on: + pull_request: + paths: + - ".github/workflows/release.yml" + - "tests/release_evidence_contract.rs" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "Dockerfile" + - "src/**" + - "crates/**" + - "CHANGELOG.md" + workflow_dispatch: + inputs: + version: + description: Canonical MAJOR.MINOR.PATCH version already declared by Cargo.toml + required: true + type: string + +concurrency: + group: release-evidence-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + release-evidence: + name: Build exact-source release evidence + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Harden release evidence runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + disable-telemetry: true + + - name: Check out the exact candidate without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Read the reviewed Rust toolchain + id: toolchain + shell: bash + run: | + set -euo pipefail + version="$(sed -n 's/^channel = "\([0-9][0-9.]*\)"$/\1/p' rust-toolchain.toml)" + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::rust-toolchain.toml must pin an exact numeric Rust release." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install the reviewed Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # immutable pin + with: + toolchain: ${{ steps.toolchain.outputs.version }} + components: llvm-tools-preview, rustfmt, clippy + + - name: Verify release source identity + id: identity + shell: bash + env: + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + checked_sha="$(git rev-parse HEAD)" + if [ "$checked_sha" != "$GITHUB_SHA" ] && [ "${{ github.event_name }}" != "pull_request" ]; then + echo "::error::Checked-out source does not match the workflow event commit." + exit 1 + fi + + package_version="$(cargo metadata --locked --no-deps --format-version=1 | jq -r '.packages[] | select(.name == "waf-ids-ai-soc") | .version')" + if ! [[ "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Cargo.toml must expose one canonical MAJOR.MINOR.PATCH package version." + exit 1 + fi + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "$GITHUB_REF" != "refs/heads/main" ] || [ "$GITHUB_REF_PROTECTED" != "true" ]; then + echo "::error::Stable release evidence must be dispatched from protected main." + exit 1 + fi + if [ "$REQUESTED_VERSION" != "$package_version" ]; then + echo "::error::Requested release version must equal the reviewed Cargo.toml package version." + exit 1 + fi + fi + + echo "sha=$checked_sha" >> "$GITHUB_OUTPUT" + echo "version=$package_version" >> "$GITHUB_OUTPUT" + + - name: Repeat exact-source quality gates + shell: bash + run: | + set -euo pipefail + cargo fmt --check + cargo test --locked --workspace + cargo clippy --locked --workspace --all-targets -- -D warnings + + - name: Build locked release binary + shell: bash + run: cargo build --locked --release + + - name: Assemble deterministic source-bound package evidence + shell: bash + env: + RELEASE_SHA: ${{ steps.identity.outputs.sha }} + RELEASE_VERSION: ${{ steps.identity.outputs.version }} + RUST_TOOLCHAIN: ${{ steps.toolchain.outputs.version }} + run: | + set -euo pipefail + evidence="$RUNNER_TEMP/wardnet-release-evidence" + package="$RUNNER_TEMP/wardnet-package" + rm -rf "$evidence" "$package" + mkdir -p "$evidence" "$package" + + install -m 0755 target/release/waf-ids-ai-soc "$package/waf-ids-ai-soc" + install -m 0644 LICENSE "$package/LICENSE" + + commit_epoch="$(git show -s --format=%ct "$RELEASE_SHA")" + tar --sort=name --mtime="@$commit_epoch" --owner=0 --group=0 --numeric-owner \ + -C "$package" -cf - LICENSE waf-ids-ai-soc \ + | gzip -n > "$evidence/wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" + + cargo metadata --locked --format-version=1 > "$evidence/cargo-metadata.json" + sha256sum Cargo.toml Cargo.lock rust-toolchain.toml Dockerfile \ + > "$evidence/source-inputs.sha256" + ( + cd "$evidence" + sha256sum "wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" cargo-metadata.json \ + > artifacts.sha256 + ) + + cargo_lock_sha="$(sha256sum Cargo.lock | awk '{print $1}')" + artifact_sha="$(sha256sum "$evidence/wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" | awk '{print $1}')" + jq -n \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg source_sha "$RELEASE_SHA" \ + --arg version "$RELEASE_VERSION" \ + --arg rust_toolchain "$RUST_TOOLCHAIN" \ + --arg cargo_lock_sha256 "$cargo_lock_sha" \ + --arg artifact_sha256 "$artifact_sha" \ + '{schema_version:"1",repository:$repository,source_sha:$source_sha,version:$version,rust_toolchain:$rust_toolchain,cargo_lock_sha256:$cargo_lock_sha256,artifact:{name:("wardnet-"+$version+"-linux-x86_64.tar.gz"),sha256:$artifact_sha256}}' \ + > "$evidence/release-manifest.json" + + echo "EVIDENCE_DIR=$evidence" >> "$GITHUB_ENV" + echo "PACKAGE_ARCHIVE=$evidence/wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" >> "$GITHUB_ENV" + + - name: Generate SPDX JSON SBOM from the reviewed release binary + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + file: ./target/release/waf-ids-ai-soc + format: spdx-json + output-file: ${{ env.EVIDENCE_DIR }}/wardnet.spdx.json + upload-artifact: false + upload-release-assets: false + + - name: Bind SBOM digest into the release manifest + shell: bash + run: | + set -euo pipefail + sbom_sha="$(sha256sum "$EVIDENCE_DIR/wardnet.spdx.json" | awk '{print $1}')" + manifest_tmp="$(mktemp)" + jq --arg sbom_sha256 "$sbom_sha" '. + {sbom:{format:"spdx-json",sha256:$sbom_sha256}}' \ + "$EVIDENCE_DIR/release-manifest.json" > "$manifest_tmp" + mv "$manifest_tmp" "$EVIDENCE_DIR/release-manifest.json" + ( + cd "$EVIDENCE_DIR" + sha256sum wardnet.spdx.json release-manifest.json >> artifacts.sha256 + ) + + - name: Attest build provenance for protected-main dispatch + if: github.event_name == 'workflow_dispatch' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ env.PACKAGE_ARCHIVE }} + + - name: Attest SBOM for protected-main dispatch + if: github.event_name == 'workflow_dispatch' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ env.PACKAGE_ARCHIVE }} + sbom-path: ${{ env.EVIDENCE_DIR }}/wardnet.spdx.json + + - name: Upload exact-source release evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wardnet-release-evidence-${{ steps.identity.outputs.sha }} + path: ${{ env.EVIDENCE_DIR }}/ + if-no-files-found: error + retention-days: 30 From e2c51dd083a1bd61689a9d4e7c64f828df2bc092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:45:18 +0900 Subject: [PATCH 03/13] docs(release): trace source-bound evidence design --- .../release-evidence-supply-chain.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/doctoring/release-evidence-supply-chain.md diff --git a/docs/doctoring/release-evidence-supply-chain.md b/docs/doctoring/release-evidence-supply-chain.md new file mode 100644 index 0000000..1d1d87d --- /dev/null +++ b/docs/doctoring/release-evidence-supply-chain.md @@ -0,0 +1,50 @@ +# Release evidence supply-chain decision + +Status: Proposed while issue #84 prerequisites remain outside protected `main`. + +## Problem + +Wardnet has no immutable GitHub Release and protected `main` has no repository-owned release workflow. A production release therefore cannot yet bind one reviewed source revision to a packaged binary, dependency/build inputs, an SBOM, provenance, exact quality evidence, deployment identity, and rollback evidence. + +Issue #84 is intentionally broader than this slice. Authentication, fail-closed egress, durable data authority, proven WAF/IDS enforcement, pinned deployment assets, production-shaped attack tests, promotion, and rehearsed rollback remain separate prerequisites. This change establishes the smallest release-evidence boundary that can be reviewed before those product prerequisites are protected truth. + +## Constraints and rejected alternatives + +- Do not create a GitHub Release from a pull-request head or another mutable development branch. A stable evidence dispatch must originate from protected `main` and its requested version must equal the reviewed Cargo package version. +- Do not rebuild an artifact later merely to attach evidence. The package archive, source-input hashes, SBOM, and provenance are generated in the same workflow from one checked-out source identity. +- Do not use floating GitHub Action refs. Release actions are pinned to immutable commit SHAs. +- Do not treat an attestation as a security verdict. Provenance establishes the relationship between an artifact and its build context; it does not prove that the artifact is vulnerability-free or policy-compliant. +- Do not claim SLSA v1.2 as an approved baseline. As rechecked on 2026-09-04, SLSA v1.1 is the latest Approved Specification published by the SLSA project. Likewise, NIST SP 800-218r1 / SSDF 1.2 is an Initial Public Draft, while NIST SP 800-218 / SSDF 1.1 remains the finalized publication baseline. +- SPDX 3.1 is still a release candidate as of this decision. The workflow emits SPDX JSON through the pinned Anchore/Syft action; later promotion policy must explicitly validate the emitted schema/profile rather than inferring compliance from a filename. + +## Selected boundary + +`.github/workflows/release.yml` is a repository-specific release-evidence workflow rather than a copied organization-wide release implementation. Pull requests exercise the build/evidence path without publishing attestations. A manual stable-evidence dispatch is accepted only from protected `main` with a canonical `MAJOR.MINOR.PATCH` value equal to `Cargo.toml`. + +The workflow repeats Wardnet's repository quality contract, builds the locked Rust release binary, creates a deterministic tar archive, records SHA-256 digests for source/build inputs and outputs, emits a machine-readable release manifest, generates an SPDX JSON SBOM, and uploads the complete evidence bundle. Protected-main dispatch additionally creates GitHub/Sigstore-backed build-provenance and SBOM attestations for the exact package archive. + +The workflow deliberately has `contents: read`. It does not create a tag, GitHub Release, package-registry object, container image, deployment, or promotion. That prevents a partial evidence foundation from becoming accidental production publication before issue #84's prerequisites and rollback/promotion acceptance are complete. + +## RED → GREEN evidence + +`tests/release_evidence_contract.rs` is the executable architecture fence. Its first commit requires a release workflow with protected-main binding, exact quality gates, locked build metadata, SHA-256 evidence, SPDX generation, attestation, immutable action pins, and non-persisted checkout credentials. That RED precedes the workflow implementation. The GREEN candidate adds only the release-evidence workflow and supporting documentation; it does not weaken any existing gate. + +Exact-head hosted execution remains authoritative. Source inspection or a predecessor run is not GREEN. The candidate remains dependent on the Rust toolchain prerequisite represented by PR #77 and must be non-force restacked or retargeted when that prerequisite reaches protected `main`. + +## Risks and follow-up + +The binary-only SBOM is not yet the final container-filesystem SBOM required by #84. The workflow also does not prove bit-for-bit reproducibility across independent builders, image-digest promotion, Sigstore verification at admission, Kubernetes deployment-by-digest, attack-path execution, database migration compatibility, canary criteria, or measured rollback. Those omissions remain fail-closed release gaps rather than implied future behavior. + +Before #84 can close, extend the same source identity into the final OCI image and release manifest, verify attestations before promotion, exercise production-shaped deployment/attack/rollback paths, preserve all evidence independently of ephemeral workflow retention, and publish an immutable release only after the exact protected candidate satisfies the then-live ruleset and security contract. + +## Traceability + +GitHub. (2026). *Using artifact attestations to establish provenance for builds*. GitHub Docs. https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (Initial Public Draft, NIST SP 800-218r1). https://csrc.nist.gov/pubs/sp/800/218/r1/ipd + +SPDX Workgroup. (2024). *SPDX specification 3.0.1*. Linux Foundation. https://spdx.github.io/spdx-spec/v3.0.1/ + +Supply-chain Levels for Software Artifacts. (2025). *SLSA version 1.1*. https://slsa.dev/spec/v1.1/ From f90e9312a6c911c60e999db612afdb18e5a7ae49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:45:29 +0900 Subject: [PATCH 04/13] docs(changelog): record release evidence foundation --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d8068..52da7a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ - Removed the distributable Kubernetes administrator `Secret` and historical placeholder credential. Production deployments must provision `waf-ids-ai-soc-admin` / `ADMIN_TOKEN` through the external secret-management control plane; the workload's `secretKeyRef` is explicitly non-optional. - Added a structural regression contract that rejects shipped administrator Secret objects, placeholder credentials, decoy workloads, init-container false positives, and optional administrator Secret references. +- Added a source-bound release-evidence workflow that produces a deterministic release archive, SHA-256 evidence, SPDX JSON SBOM, and protected-main GitHub/Sigstore provenance and SBOM attestations without granting the workflow publication authority. ### Operations - Documented administrator credential provisioning, rotation, rollout verification, rollback, evidence handling, and the boundary with the separate runtime-authentication fail-closed work tracked in issue #78. +- Added an executable release-workflow architecture contract and supply-chain decision record. Full immutable publication, OCI promotion, production-shaped attack verification, and rehearsed rollback remain tracked by issue #84. From 26541c9558b3c64363318e3042f787df3d019f65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:47:05 +0900 Subject: [PATCH 05/13] test(release): require dependency-aware package SBOM --- tests/release_evidence_contract.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs index c4b4a2d..0e6c25a 100644 --- a/tests/release_evidence_contract.rs +++ b/tests/release_evidence_contract.rs @@ -26,6 +26,11 @@ fn release_workflow_binds_reviewed_source_to_verifiable_evidence() { "cargo clippy --locked --workspace --all-targets -- -D warnings", "cargo build --locked --release", "cargo metadata --locked --format-version=1", + "install -m 0644 Cargo.toml", + "install -m 0644 Cargo.lock", + "install -m 0644 rust-toolchain.toml", + "PACKAGE_DIR=$package", + "path: ${{ env.PACKAGE_DIR }}", "sha256sum", "spdx-json", "actions/attest@", From 2bacb6c1b330e6c657195f2b77b32e9862479df6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:52:38 +0900 Subject: [PATCH 06/13] fix(release): bind SBOM to packaged dependency manifests --- .github/workflows/release.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0118c4b..1771792 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,10 +131,13 @@ jobs: install -m 0755 target/release/waf-ids-ai-soc "$package/waf-ids-ai-soc" install -m 0644 LICENSE "$package/LICENSE" + install -m 0644 Cargo.toml "$package/Cargo.toml" + install -m 0644 Cargo.lock "$package/Cargo.lock" + install -m 0644 rust-toolchain.toml "$package/rust-toolchain.toml" commit_epoch="$(git show -s --format=%ct "$RELEASE_SHA")" tar --sort=name --mtime="@$commit_epoch" --owner=0 --group=0 --numeric-owner \ - -C "$package" -cf - LICENSE waf-ids-ai-soc \ + -C "$package" -cf - Cargo.lock Cargo.toml LICENSE rust-toolchain.toml waf-ids-ai-soc \ | gzip -n > "$evidence/wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" cargo metadata --locked --format-version=1 > "$evidence/cargo-metadata.json" @@ -159,12 +162,13 @@ jobs: > "$evidence/release-manifest.json" echo "EVIDENCE_DIR=$evidence" >> "$GITHUB_ENV" + echo "PACKAGE_DIR=$package" >> "$GITHUB_ENV" echo "PACKAGE_ARCHIVE=$evidence/wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" >> "$GITHUB_ENV" - - name: Generate SPDX JSON SBOM from the reviewed release binary + - name: Generate SPDX JSON SBOM from packaged release contents uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 with: - file: ./target/release/waf-ids-ai-soc + path: ${{ env.PACKAGE_DIR }} format: spdx-json output-file: ${{ env.EVIDENCE_DIR }}/wardnet.spdx.json upload-artifact: false From ea56a03acf747a0fc14ee85690cfce0eeeae1a56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:08:06 +0900 Subject: [PATCH 07/13] test(release): isolate attestation authority from PR builds --- tests/release_evidence_contract.rs | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs index 0e6c25a..21c3dcb 100644 --- a/tests/release_evidence_contract.rs +++ b/tests/release_evidence_contract.rs @@ -12,6 +12,16 @@ fn require(workflow: &str, needle: &str) { ); } +fn section<'a>(workflow: &'a str, start: &str, end: &str) -> &'a str { + let start_offset = workflow + .find(start) + .unwrap_or_else(|| panic!("release workflow must contain section start {start:?}")); + let end_offset = workflow[start_offset..] + .find(end) + .unwrap_or_else(|| panic!("release workflow must contain section end {end:?}")); + &workflow[start_offset..start_offset + end_offset] +} + #[test] fn release_workflow_binds_reviewed_source_to_verifiable_evidence() { let workflow = release_workflow(); @@ -46,3 +56,44 @@ fn release_workflow_binds_reviewed_source_to_verifiable_evidence() { "release actions must be pinned to immutable revisions" ); } + +#[test] +fn pull_request_release_build_cannot_mint_attestation_identity() { + let workflow = release_workflow(); + let build_job = section( + &workflow, + " release-evidence:\n", + " attest-release-evidence:\n", + ); + + for forbidden in [ + "id-token: write", + "attestations: write", + "artifact-metadata: write", + "actions/attest@", + ] { + assert!( + !build_job.contains(forbidden), + "the PR-executable release build job must not carry attestation authority {forbidden:?}" + ); + } + + let attest_job = workflow + .split_once(" attest-release-evidence:\n") + .map(|(_, body)| body) + .expect("release workflow must isolate protected-main attestation in a separate job"); + for required in [ + "if: github.event_name == 'workflow_dispatch'", + "needs: release-evidence", + "id-token: write", + "attestations: write", + "artifact-metadata: write", + "actions/download-artifact@", + "actions/attest@", + ] { + assert!( + attest_job.contains(required), + "protected-main attestation job must contain {required:?}" + ); + } +} From c3b450ee321ff399a67d1d9d4d8d1f5ca0936b63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:08:45 +0900 Subject: [PATCH 08/13] fix(release): isolate attestation authority from PR execution --- .github/workflows/release.yml | 81 +++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1771792..59ef629 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,9 +36,9 @@ jobs: timeout-minutes: 30 permissions: contents: read - id-token: write - attestations: write - artifact-metadata: write + outputs: + source_sha: ${{ steps.identity.outputs.sha }} + version: ${{ steps.identity.outputs.version }} steps: - name: Harden release evidence runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -163,7 +163,6 @@ jobs: echo "EVIDENCE_DIR=$evidence" >> "$GITHUB_ENV" echo "PACKAGE_DIR=$package" >> "$GITHUB_ENV" - echo "PACKAGE_ARCHIVE=$evidence/wardnet-${RELEASE_VERSION}-linux-x86_64.tar.gz" >> "$GITHUB_ENV" - name: Generate SPDX JSON SBOM from packaged release contents uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 @@ -188,23 +187,77 @@ jobs: sha256sum wardnet.spdx.json release-manifest.json >> artifacts.sha256 ) + - name: Upload exact-source release evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wardnet-release-evidence-${{ steps.identity.outputs.sha }} + path: ${{ env.EVIDENCE_DIR }}/ + if-no-files-found: error + retention-days: 30 + + attest-release-evidence: + name: Attest protected-main release evidence + if: github.event_name == 'workflow_dispatch' + needs: release-evidence + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + env: + EVIDENCE_DIR: ${{ runner.temp }}/wardnet-release-evidence + PACKAGE_ARCHIVE: ${{ runner.temp }}/wardnet-release-evidence/wardnet-${{ needs.release-evidence.outputs.version }}-linux-x86_64.tar.gz + steps: + - name: Harden attestation runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + disable-telemetry: true + + - name: Download exact-source release evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: wardnet-release-evidence-${{ needs.release-evidence.outputs.source_sha }} + path: ${{ env.EVIDENCE_DIR }} + + - name: Verify protected-main evidence before granting attestation authority + shell: bash + env: + RELEASE_SHA: ${{ needs.release-evidence.outputs.source_sha }} + RELEASE_VERSION: ${{ needs.release-evidence.outputs.version }} + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$GITHUB_REF" != "refs/heads/main" ] || [ "$GITHUB_REF_PROTECTED" != "true" ]; then + echo "::error::Attestation authority is restricted to protected main." + exit 1 + fi + if [ "$REQUESTED_VERSION" != "$RELEASE_VERSION" ]; then + echo "::error::Requested release version changed across the evidence boundary." + exit 1 + fi + if [ "$(jq -r '.source_sha' "$EVIDENCE_DIR/release-manifest.json")" != "$RELEASE_SHA" ]; then + echo "::error::Downloaded release manifest does not match the reviewed source SHA." + exit 1 + fi + if [ "$(jq -r '.version' "$EVIDENCE_DIR/release-manifest.json")" != "$RELEASE_VERSION" ]; then + echo "::error::Downloaded release manifest does not match the reviewed version." + exit 1 + fi + ( + cd "$EVIDENCE_DIR" + sha256sum --check artifacts.sha256 + ) + - name: Attest build provenance for protected-main dispatch - if: github.event_name == 'workflow_dispatch' uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: ${{ env.PACKAGE_ARCHIVE }} - name: Attest SBOM for protected-main dispatch - if: github.event_name == 'workflow_dispatch' uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: ${{ env.PACKAGE_ARCHIVE }} sbom-path: ${{ env.EVIDENCE_DIR }}/wardnet.spdx.json - - - name: Upload exact-source release evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: wardnet-release-evidence-${{ steps.identity.outputs.sha }} - path: ${{ env.EVIDENCE_DIR }}/ - if-no-files-found: error - retention-days: 30 From 3634074e2061c1f7b66a5de2fec0ce1e695a73e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:10:11 +0900 Subject: [PATCH 09/13] test(release): require least-privilege attestation permissions --- tests/release_evidence_contract.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs index 21c3dcb..425f18c 100644 --- a/tests/release_evidence_contract.rs +++ b/tests/release_evidence_contract.rs @@ -87,7 +87,6 @@ fn pull_request_release_build_cannot_mint_attestation_identity() { "needs: release-evidence", "id-token: write", "attestations: write", - "artifact-metadata: write", "actions/download-artifact@", "actions/attest@", ] { @@ -96,4 +95,8 @@ fn pull_request_release_build_cannot_mint_attestation_identity() { "protected-main attestation job must contain {required:?}" ); } + assert!( + !attest_job.contains("artifact-metadata: write"), + "binary/SBOM attestations without linked-artifact registry publishing must not request artifact-metadata write authority" + ); } From b040c878b9809a80f87d5bb52c3125512e4ba622 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:10:43 +0900 Subject: [PATCH 10/13] fix(release): drop unused artifact-metadata authority --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59ef629..cadcbad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,7 +205,6 @@ jobs: contents: read id-token: write attestations: write - artifact-metadata: write env: EVIDENCE_DIR: ${{ runner.temp }}/wardnet-release-evidence PACKAGE_ARCHIVE: ${{ runner.temp }}/wardnet-release-evidence/wardnet-${{ needs.release-evidence.outputs.version }}-linux-x86_64.tar.gz From 3a34d35de477155c4bdb2d45f7f09db830f31800 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:11:51 +0900 Subject: [PATCH 11/13] test(release): reject runner context in job-level env --- tests/release_evidence_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs index 425f18c..04b1cdc 100644 --- a/tests/release_evidence_contract.rs +++ b/tests/release_evidence_contract.rs @@ -100,3 +100,18 @@ fn pull_request_release_build_cannot_mint_attestation_identity() { "binary/SBOM attestations without linked-artifact registry publishing must not request artifact-metadata write authority" ); } + +#[test] +fn job_level_configuration_does_not_use_runner_only_context() { + let workflow = release_workflow(); + let attest_header = section( + &workflow, + " attest-release-evidence:\n", + " steps:\n", + ); + + assert!( + !attest_header.contains("${{ runner."), + "runner context is step/runtime scoped and must not be referenced from job-level env" + ); +} From 1d3f5a4bd618084031f3e722804b7c61303baeb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:12:22 +0900 Subject: [PATCH 12/13] fix(release): move runner paths into step scope --- .github/workflows/release.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cadcbad..8fefb74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,9 +205,6 @@ jobs: contents: read id-token: write attestations: write - env: - EVIDENCE_DIR: ${{ runner.temp }}/wardnet-release-evidence - PACKAGE_ARCHIVE: ${{ runner.temp }}/wardnet-release-evidence/wardnet-${{ needs.release-evidence.outputs.version }}-linux-x86_64.tar.gz steps: - name: Harden attestation runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -219,11 +216,12 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wardnet-release-evidence-${{ needs.release-evidence.outputs.source_sha }} - path: ${{ env.EVIDENCE_DIR }} + path: ${{ runner.temp }}/wardnet-release-evidence - name: Verify protected-main evidence before granting attestation authority shell: bash env: + EVIDENCE_DIR: ${{ runner.temp }}/wardnet-release-evidence RELEASE_SHA: ${{ needs.release-evidence.outputs.source_sha }} RELEASE_VERSION: ${{ needs.release-evidence.outputs.version }} REQUESTED_VERSION: ${{ inputs.version }} @@ -253,10 +251,10 @@ jobs: - name: Attest build provenance for protected-main dispatch uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: ${{ env.PACKAGE_ARCHIVE }} + subject-path: ${{ runner.temp }}/wardnet-release-evidence/wardnet-${{ needs.release-evidence.outputs.version }}-linux-x86_64.tar.gz - name: Attest SBOM for protected-main dispatch uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: ${{ env.PACKAGE_ARCHIVE }} - sbom-path: ${{ env.EVIDENCE_DIR }}/wardnet.spdx.json + subject-path: ${{ runner.temp }}/wardnet-release-evidence/wardnet-${{ needs.release-evidence.outputs.version }}-linux-x86_64.tar.gz + sbom-path: ${{ runner.temp }}/wardnet-release-evidence/wardnet.spdx.json From f5eb6d3f5df03ce221b396afbb76a708d18cbc2a Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 4 Sep 2026 22:35:10 +0900 Subject: [PATCH 13/13] fix(release): satisfy release contract rustfmt --- tests/release_evidence_contract.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs index 04b1cdc..d1b9038 100644 --- a/tests/release_evidence_contract.rs +++ b/tests/release_evidence_contract.rs @@ -104,11 +104,7 @@ fn pull_request_release_build_cannot_mint_attestation_identity() { #[test] fn job_level_configuration_does_not_use_runner_only_context() { let workflow = release_workflow(); - let attest_header = section( - &workflow, - " attest-release-evidence:\n", - " steps:\n", - ); + let attest_header = section(&workflow, " attest-release-evidence:\n", " steps:\n"); assert!( !attest_header.contains("${{ runner."),