diff --git a/.github/workflows/post-release-audit.yml b/.github/workflows/post-release-audit.yml new file mode 100644 index 0000000..fa81c15 --- /dev/null +++ b/.github/workflows/post-release-audit.yml @@ -0,0 +1,158 @@ +name: Post-release audit + +on: + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Existing release tag to audit + required: true + type: string + +permissions: + contents: read + packages: read + id-token: write + +env: + IMAGE_NAME: ghcr.io/ploos-as/soju-web + +jobs: + audit: + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Select release bound to producer commit + id: release + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }} + PRODUCER_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || '' }} + shell: bash + run: | + set -euo pipefail + git fetch --force --tags origin + tag="$INPUT_TAG" + if [[ -z "$tag" ]]; then + while IFS= read -r candidate; do + [[ -n "$candidate" ]] || continue + commit="$(git rev-list -n 1 "$candidate" 2>/dev/null || true)" + if [[ "$commit" == "$PRODUCER_SHA" ]]; then + tag="$candidate" + break + fi + done < <(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isDraft --jq '.[] | select(.isDraft == false) | .tagName') + fi + [[ -n "$tag" ]] + tag_commit="$(git rev-list -n 1 "$tag")" + sh scripts/verify-release-ref.sh "$tag" "$tag_commit" + release_json="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json tagName,isDraft,isPrerelease)" + jq -e --arg tag "$tag" '.tagName == $tag and .isDraft == false' <<<"$release_json" >/dev/null + if [[ -n "$PRODUCER_SHA" ]]; then + test "$tag_commit" = "$PRODUCER_SHA" + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "commit=$tag_commit" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve immutable release digest and verify platforms + id: image + env: + TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + set -euo pipefail + image_tag="${IMAGE_NAME}:${TAG#v}" + docker buildx imagetools inspect --raw "$image_tag" > /tmp/manifest.json + jq -e 'any(.manifests[]; .platform.os == "linux" and .platform.architecture == "amd64")' /tmp/manifest.json >/dev/null + jq -e 'any(.manifests[]; .platform.os == "linux" and .platform.architecture == "arm64")' /tmp/manifest.json >/dev/null + digest="$(docker buildx imagetools inspect "$image_tag" | awk '/^Digest:/ {print $2; exit}')" + test -n "$digest" + sh scripts/verify-exact-digest-runtime.sh "$IMAGE_NAME" "$digest" >/dev/null + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Install Cosign + uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 + + - name: Verify exact-digest signature + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + shell: bash + run: | + set -euo pipefail + cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/Ploos-AS/soju-web/.github/workflows/release.yml@refs/tags/v.*$' \ + "${IMAGE_NAME}@${IMAGE_DIGEST}" > /tmp/cosign-verification.json + jq -e 'length > 0' /tmp/cosign-verification.json >/dev/null + + - name: Verify immutable labels and runtime after release + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + TAG: ${{ steps.release.outputs.tag }} + RELEASE_COMMIT: ${{ steps.release.outputs.commit }} + shell: bash + run: | + set -euo pipefail + image_ref="${IMAGE_NAME}@${IMAGE_DIGEST}" + docker pull --platform linux/amd64 "$image_ref" + revision="$(docker image inspect "$image_ref" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" + version="$(docker image inspect "$image_ref" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" + test "$revision" = "$RELEASE_COMMIT" + test "$version" = "$TAG" + test "$(docker run --rm --platform linux/amd64 --entrypoint id "$image_ref" -u)" = "1000" + test "$(docker run --rm --platform linux/amd64 --entrypoint id "$image_ref" -g)" = "1000" + cid=$(docker run -d --rm --platform linux/amd64 \ + -e SOJU_WEB_ADMIN_PASSWORD=post-release-audit-password \ + -e SOJU_WEB_SESSION_SECRET=post-release-audit-session-secret-0123456789 \ + -p 127.0.0.1:18081:8080 "$image_ref") + trap 'docker logs "$cid" 2>&1 || true; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT + healthy=false + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:18081/healthz | grep -qx ok; then + healthy=true + break + fi + sleep 1 + done + test "$healthy" = true + docker rm -f "$cid" + trap - EXIT + + - name: Record post-release audit summary + env: + TAG: ${{ steps.release.outputs.tag }} + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: | + { + echo '### M11.5 post-release audit' + echo + echo "- release: $TAG" + echo "- immutable digest: $IMAGE_DIGEST" + echo '- GitHub Release/tag/commit binding: verified' + echo '- linux/amd64 + linux/arm64 manifest coverage: verified' + echo '- Cosign keyless signature and release workflow OIDC identity: verified' + echo '- OCI revision/version labels: verified' + echo '- non-root UID/GID 1000: verified' + echo '- exact-digest post-publication /healthz runtime: verified' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/M11_5_POST_RELEASE_AUDIT.md b/docs/M11_5_POST_RELEASE_AUDIT.md new file mode 100644 index 0000000..167e26f --- /dev/null +++ b/docs/M11_5_POST_RELEASE_AUDIT.md @@ -0,0 +1,9 @@ +# M11.5 post-release audit + +M11.5 adds an independent post-publication audit consumer for successful `Release` workflow runs. + +The audit selects the published GitHub Release whose tag resolves to the producer workflow commit, re-validates the release tag policy, resolves the immutable OCI digest from the version tag, and requires both `linux/amd64` and `linux/arm64` manifests. + +It then verifies the Cosign keyless signature against GitHub Actions OIDC and the exact `release.yml@refs/tags/v*` workflow identity. Finally, it pulls the immutable digest for amd64, verifies OCI revision/version labels, UID/GID 1000, and requires a successful `/healthz` response from that exact post-publication image. + +The workflow is also manually dispatchable for an explicit existing release tag. It is fail-closed: missing release binding, malformed digest, missing architecture, bad signature identity, label mismatch, non-root mismatch, or runtime failure fails the audit.