diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e056f59..a0817e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,17 @@ jobs: echo "tag/commit mismatch unexpectedly accepted" >&2 exit 1 fi + if sh scripts/verify-release-ref.sh v0.0.0-m11.2 "$GITHUB_SHA" invalid-mode; then + echo "invalid verification mode unexpectedly accepted" >&2 + exit 1 + fi + parent="$(git rev-parse HEAD^)" + git tag v0.0.0-m11.5-audit "$parent" + if sh scripts/verify-release-ref.sh v0.0.0-m11.5-audit "$parent"; then + echo "strict mode unexpectedly accepted historical tag" >&2 + exit 1 + fi + sh scripts/verify-release-ref.sh v0.0.0-m11.5-audit "$parent" --allow-head-mismatch-for-audit - name: Verify exact-digest policy run: | diff --git a/.github/workflows/post-release-audit.yml b/.github/workflows/post-release-audit.yml new file mode 100644 index 0000000..7adc507 --- /dev/null +++ b/.github/workflows/post-release-audit.yml @@ -0,0 +1,166 @@ +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 + +jobs: + audit: + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + 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")" + if [[ -n "$INPUT_TAG" ]]; then + sh scripts/verify-release-ref.sh "$tag" "$tag_commit" --allow-head-mismatch-for-audit + else + sh scripts/verify-release-ref.sh "$tag" "$tag_commit" + fi + 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 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + 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 manifest inspect "$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 + docker pull --platform linux/amd64 "$image_tag" >/dev/null + repo_digest="$(docker image inspect "$image_tag" --format '{{index .RepoDigests 0}}')" + digest="${repo_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 + + - 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/.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" >/dev/null + 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" + mkdir -p data-audit run-audit + sudo chown 1000:1000 data-audit run-audit + cid=$(docker run -d --rm --platform linux/amd64 \ + -v "$PWD/data-audit:/var/lib/soju" \ + -v "$PWD/run-audit:/run/soju" \ + "$image_ref") + trap 'docker logs "$cid" 2>&1 || true; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT + for i in $(seq 1 30); do + if docker ps --filter "id=$cid" --format '{{.ID}}' | grep -q . \ + && test -f data-audit/main.db \ + && test -S run-audit/admin; then + healthy=true + break + fi + sleep 1 + done + test "${healthy:-false}" = 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 SQLite + admin-socket runtime: verified' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/verify-release-ref.sh b/scripts/verify-release-ref.sh index 1c6c644..c374c6a 100644 --- a/scripts/verify-release-ref.sh +++ b/scripts/verify-release-ref.sh @@ -3,12 +3,21 @@ set -eu tag=${1:-${GITHUB_REF_NAME:-}} expected_sha=${2:-${GITHUB_SHA:-}} +mode=${3:-strict} if [ -z "$tag" ] || [ -z "$expected_sha" ]; then - echo "usage: verify-release-ref.sh " >&2 + echo "usage: verify-release-ref.sh [--allow-head-mismatch-for-audit]" >&2 exit 2 fi +case "$mode" in + strict|--allow-head-mismatch-for-audit) ;; + *) + echo "invalid verification mode: $mode" >&2 + exit 2 + ;; +esac + if ! printf '%s\n' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then echo "invalid release tag: $tag" >&2 exit 1 @@ -24,10 +33,12 @@ if [ "$actual_sha" != "$expected_sha" ]; then exit 1 fi -head_sha=$(git rev-parse HEAD) -if [ "$head_sha" != "$expected_sha" ]; then - echo "checkout/commit mismatch: HEAD=$head_sha expected=$expected_sha" >&2 - exit 1 +if [ "$mode" = "strict" ]; then + head_sha=$(git rev-parse HEAD) + if [ "$head_sha" != "$expected_sha" ]; then + echo "checkout/commit mismatch: HEAD=$head_sha expected=$expected_sha" >&2 + exit 1 + fi fi -printf 'release ref verified: tag=%s commit=%s\n' "$tag" "$expected_sha" +printf 'release ref verified: tag=%s commit=%s mode=%s\n' "$tag" "$expected_sha" "$mode"