diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8fefb74d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,260 @@ +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 + 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 + 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" + 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 - 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" + 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_DIR=$package" >> "$GITHUB_ENV" + + - name: Generate SPDX JSON SBOM from packaged release contents + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + path: ${{ env.PACKAGE_DIR }} + 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: 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 + 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: ${{ 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 }} + 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 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + 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: ${{ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d80680..52da7a18 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. diff --git a/docs/doctoring/release-evidence-supply-chain.md b/docs/doctoring/release-evidence-supply-chain.md new file mode 100644 index 00000000..1d1d87db --- /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/ diff --git a/tests/release_evidence_contract.rs b/tests/release_evidence_contract.rs new file mode 100644 index 00000000..d1b9038f --- /dev/null +++ b/tests/release_evidence_contract.rs @@ -0,0 +1,113 @@ +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:?}" + ); +} + +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(); + + 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", + "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@", + "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" + ); +} + +#[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", + "actions/download-artifact@", + "actions/attest@", + ] { + assert!( + attest_job.contains(required), + "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" + ); +} + +#[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" + ); +}