From c64de7722fa891cf0ebd9d4f8a9fd16e183e1c40 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 16 Jul 2026 16:40:49 +0100 Subject: [PATCH] feat(release): attest artifacts and require signed tags --- .github/workflows/release.yml | 72 +++++++++++++++++++++++++-- .github/workflows/sbom-release.yml | 40 --------------- README.md | 3 ++ docs/ci-pipeline.md | 2 +- docs/release-verification.md | 50 +++++++++++++++++++ tests/test_signed_release_workflow.py | 44 ++++++++++++++++ 6 files changed, 166 insertions(+), 45 deletions(-) delete mode 100644 .github/workflows/sbom-release.yml create mode 100644 docs/release-verification.md create mode 100644 tests/test_signed_release_workflow.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6db3909..8661e9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,85 @@ -name: Release +name: Signed Release on: push: tags: - - 'v*' + - "v*" permissions: contents: write + id-token: write + attestations: write jobs: release: runs-on: ubuntu-latest + env: + TAG: ${{ github.ref_name }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Checkout signed tag + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + + - name: Verify annotated tag signature + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag_object=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.sha') + object_type=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq '.object.type') + if [ "$object_type" != "tag" ]; then + echo "Release tags must be signed annotated tags; ${TAG} is ${object_type}." + exit 1 + fi + verified=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified') + if [ "$verified" != "true" ]; then + echo "GitHub could not verify the signature on ${TAG}." + exit 1 + fi + + - name: Install Syft + env: + SYFT_VERSION: "1.46.0" + SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca + run: | + set -euo pipefail + archive="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "$archive" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}" + echo "${SYFT_SHA256} ${archive}" | sha256sum --check --strict + sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft + + - name: Build deterministic release artifacts + run: | + set -euo pipefail + mkdir -p dist + syft dir:. --source-name openshield --source-version "$TAG" \ + -o "cyclonedx-json=dist/openshield-${TAG}-sbom.cyclonedx.json" + git archive --format=tar --prefix="openshield-${TAG}/" "$TAG" | \ + gzip --no-name > "dist/openshield-${TAG}.tar.gz" + cd dist + sha256sum "openshield-${TAG}.tar.gz" "openshield-${TAG}-sbom.cyclonedx.json" > SHA256SUMS + + - name: Attest source archive + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/openshield-${{ env.TAG }}.tar.gz + + - name: Attest SBOM + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/openshield-${{ env.TAG }}-sbom.cyclonedx.json + + - name: Attest checksum manifest + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/SHA256SUMS + + - name: Publish release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: generate_release_notes: true make_latest: true + files: dist/* diff --git a/.github/workflows/sbom-release.yml b/.github/workflows/sbom-release.yml deleted file mode 100644 index 3748dba..0000000 --- a/.github/workflows/sbom-release.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: SBOM Release - -# Attach a CycloneDX SBOM to each published GitHub Release (issue #156). -on: - release: - types: [published] - -permissions: - contents: write # required to upload assets to the release - -jobs: - attach-sbom: - name: Generate and attach SBOM - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Install syft - env: - SYFT_VERSION: "1.46.0" - SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca - run: | - SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" - curl --fail --silent --show-error --location \ - --output "$SYFT_ARCHIVE" \ - "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" - echo "${SYFT_SHA256} ${SYFT_ARCHIVE}" | sha256sum --check --strict - sudo tar --extract --gzip --file "$SYFT_ARCHIVE" --directory /usr/local/bin syft - - - name: Generate SBOM - env: - TAG: ${{ github.event.release.tag_name }} - run: syft dir:. --source-name openshield --source-version "$TAG" -o "cyclonedx-json=openshield-${TAG}-sbom.cyclonedx.json" - - - name: Upload SBOM to release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG" "openshield-${TAG}-sbom.cyclonedx.json" --clobber diff --git a/README.md b/README.md index e3f43a5..43ac2de 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CHANGELOG](https://img.shields.io/badge/changelog-here-blue)](CHANGELOG.md) +Release artifacts include SHA-256 checksums, an SBOM, and identity-bound +provenance attestations. See [release verification](docs/release-verification.md). + > **Open source Cloud Security Posture Management (CSPM) for Azure - detect misconfigurations, map to CIS/NIST/ISO27001/SOC2, fix them with one command, and identify cryptographic assets requiring quantum-safe migration.** [![GitHub Repo stars](https://img.shields.io/github/stars/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/stargazers) diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md index ba11313..d63e0ef 100644 --- a/docs/ci-pipeline.md +++ b/docs/ci-pipeline.md @@ -26,7 +26,7 @@ This document explains each job, how to reproduce every check locally before ope `.github/workflows/codeql.yml` (separate workflow, PRs to `dev`/`main` + weekly cron): **Analyze (python)** and **Analyze (javascript)** — CodeQL semantic/taint analysis. -`.github/workflows/sbom-release.yml` (triggered `on: release: published`): generates a CycloneDX SBOM from the tagged code and uploads it to the GitHub Release assets. +`.github/workflows/release.yml` (triggered by `v*` tags): requires a verified signed annotated tag, builds a deterministic source archive and CycloneDX SBOM, publishes SHA-256 checksums and identity-bound provenance attestations, then creates the GitHub Release. The **Container Scan** job is intentionally **not** a required check yet: no `Dockerfile` exists, so it has nothing to scan. It activates automatically once INFRA 1 (#154) adds one. diff --git a/docs/release-verification.md b/docs/release-verification.md new file mode 100644 index 0000000..d1244df --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,50 @@ +# Verifying OpenShield Releases + +OpenShield release artifacts are produced only from a GitHub-verified signed +annotated tag. GitHub Actions generates a deterministic source archive, a +CycloneDX SBOM and SHA-256 checksums, then creates identity-bound Sigstore +provenance attestations before uploading the files to the release. + +## Verify checksums + +Download all release assets into one directory, then run: + +```bash +sha256sum --check SHA256SUMS +``` + +## Verify provenance + +Install the GitHub CLI and verify each artifact against this repository: + +```bash +gh attestation verify openshield-vX.Y.Z.tar.gz \ + --repo openshield-org/openshield \ + --signer-workflow openshield-org/openshield/.github/workflows/release.yml + +gh attestation verify openshield-vX.Y.Z-sbom.cyclonedx.json \ + --repo openshield-org/openshield \ + --signer-workflow openshield-org/openshield/.github/workflows/release.yml + +gh attestation verify SHA256SUMS \ + --repo openshield-org/openshield \ + --signer-workflow openshield-org/openshield/.github/workflows/release.yml +``` + +Successful verification proves that the artifact digest was attested by the +OpenShield release workflow for this public repository. It does not mean that +GitHub or Sigstore audited the source code. + +## Maintainer release procedure + +1. Confirm the release commit is on the approved `main` history and CI passes. +2. Create a signed annotated tag: `git tag -s vX.Y.Z -m "OpenShield vX.Y.Z"`. + SSH signing may be used when Git is configured with `gpg.format=ssh`. +3. Verify locally with `git tag -v vX.Y.Z` using the project's trusted signer + configuration. +4. Push only the tag: `git push origin vX.Y.Z`. +5. The workflow independently asks GitHub to verify the tag signature. A + lightweight or unverified tag fails before artifacts are produced. +6. After publication, download and verify every asset using the commands above. + +Existing historical lightweight tags are not retroactively described as signed. diff --git a/tests/test_signed_release_workflow.py b/tests/test_signed_release_workflow.py new file mode 100644 index 0000000..f296042 --- /dev/null +++ b/tests/test_signed_release_workflow.py @@ -0,0 +1,44 @@ +"""Safety checks for the signed release workflow.""" + +from pathlib import Path + +import yaml + + +WORKFLOW = Path(__file__).parents[1] / ".github" / "workflows" / "release.yml" + + +def _workflow(): + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def test_release_workflow_has_keyless_attestation_permissions(): + assert _workflow()["permissions"] == { + "contents": "write", + "id-token": "write", + "attestations": "write", + } + + +def test_release_requires_verified_annotated_tag(): + source = WORKFLOW.read_text(encoding="utf-8") + assert 'object_type" != "tag"' in source + assert ".verification.verified" in source + assert 'verified" != "true"' in source + + +def test_release_attests_every_distributed_manifest(): + source = WORKFLOW.read_text(encoding="utf-8") + action = "actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373" + assert source.count(action) == 3 + assert "openshield-${{ env.TAG }}.tar.gz" in source + assert "openshield-${{ env.TAG }}-sbom.cyclonedx.json" in source + assert "subject-path: dist/SHA256SUMS" in source + + +def test_release_artifact_is_deterministic_and_checksums_are_published(): + source = WORKFLOW.read_text(encoding="utf-8") + assert "git archive --format=tar" in source + assert "gzip --no-name" in source + assert "sha256sum" in source + assert "files: dist/*" in source