Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 141 additions & 21 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,89 @@
name: Docker Build and Push
name: Verified Container Release

# Only the signed source-release workflow calls this publisher.
on:
push:
tags:
- 'v*'
workflow_dispatch:
workflow_call:
inputs:
release_tag:
required: true
type: string
release_commit:
required: true
type: string
tag_object:
required: true
type: string

permissions:
contents: read
packages: write
id-token: write
attestations: write

jobs:
docker:
# Owner opt-in after confirming OWASP package rights and the first-release plan.
if: github.repository == 'OWASP/openshield' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.CONTAINER_RELEASE_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
env:
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_COMMIT: ${{ inputs.release_commit }}
EXPECTED_TAG_OBJECT: ${{ inputs.tag_object }}
IMAGE_NAME: ghcr.io/owasp/openshield
CANDIDATE: ghcr.io/owasp/openshield:candidate-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Checkout immutable release commit
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ inputs.release_commit }}
fetch-depth: 0
persist-credentials: false

- name: Reverify signed source before build
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/release_integrity.py verify > "$RUNNER_TEMP/release-source.json"

- name: Build once and save the exact image
run: |
set -euo pipefail
docker build --tag "$CANDIDATE" \
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.revision=${RELEASE_COMMIT}" \
--label "org.opencontainers.image.version=${RELEASE_TAG}" .
docker image inspect "$CANDIDATE" --format '{{.Id}}' > "$RUNNER_TEMP/image-id"
docker save --output "$RUNNER_TEMP/image.tar" "$CANDIDATE"

- name: Scan saved image before registry publication
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
input: ${{ runner.temp }}/image.tar
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
format: json
output: ${{ runner.temp }}/image-trivy.json

- name: Install checksum-verified Syft
env:
SYFT_VERSION: "1.46.0"
SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca
run: |
set -euo pipefail
archive="$RUNNER_TEMP/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}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"
echo "${SYFT_SHA256} ${archive}" | sha256sum --check --strict
sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft

- name: Generate image SBOM from scanned archive
run: syft "docker-archive:$RUNNER_TEMP/image.tar" -o "cyclonedx-json=$RUNNER_TEMP/image.cdx.json"

- name: Reverify tag before any registry write
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/release_integrity.py verify

- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
Expand All @@ -23,20 +92,71 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
- name: Push scanned candidate and capture registry digest
id: push
run: |
set -euo pipefail
test "$(docker image inspect "$CANDIDATE" --format '{{.Id}}')" = "$(< "$RUNNER_TEMP/image-id")"
docker push "$CANDIDATE"
docker image inspect "$CANDIDATE" --format '{{json .RepoDigests}}' | \
python3 scripts/release_integrity.py digest --image "$IMAGE_NAME" > "$RUNNER_TEMP/image-digest.json"

- name: Attest image provenance by digest
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

- name: Attest image SBOM by the same digest
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3
with:
images: ghcr.io/openshield-org/openshield
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: ${{ runner.temp }}/image.cdx.json
push-to-registry: true

- name: Verify both attestations before version promotion
env:
GH_TOKEN: ${{ github.token }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml" \
--source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG"
gh attestation verify "oci://${IMAGE_NAME}@${DIGEST}" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/docker.yml" \
--source-digest "$RELEASE_COMMIT" --source-ref "refs/tags/$RELEASE_TAG" \
--predicate-type https://cyclonedx.org/bom

- name: Promote verified image without rebuilding
env:
GH_TOKEN: ${{ github.token }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
python3 scripts/release_integrity.py verify
test "$(docker image inspect "$CANDIDATE" --format '{{.Id}}')" = "$(< "$RUNNER_TEMP/image-id")"
VERSION_REF="${IMAGE_NAME}:${RELEASE_TAG#v}"
docker tag "$CANDIDATE" "$VERSION_REF"
docker push "$VERSION_REF"
docker image inspect "$VERSION_REF" --format '{{json .RepoDigests}}' | \
python3 scripts/release_integrity.py digest --image "$IMAGE_NAME" --expect "$DIGEST"
printf 'Verified image: `%s@%s`\nSource: `%s`\nVersion: `%s`\n' \
"$IMAGE_NAME" "$DIGEST" "$RELEASE_COMMIT" "$VERSION_REF" >> "$GITHUB_STEP_SUMMARY"

- name: Retain container release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
name: container-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/image-trivy.json
${{ runner.temp }}/image.cdx.json
${{ runner.temp }}/image-digest.json
${{ runner.temp }}/release-source.json
if-no-files-found: warn
retention-days: 90
51 changes: 36 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
tags:
- "v*"

concurrency:
group: signed-release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write
id-token: write
Expand All @@ -13,30 +17,25 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.verify.outputs.release_tag }}
release_commit: ${{ steps.verify.outputs.release_commit }}
tag_object: ${{ steps.verify.outputs.tag_object }}
env:
TAG: ${{ github.ref_name }}
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Checkout signed tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
persist-credentials: false

- name: Verify annotated tag signature
- name: Verify signed tag and main ancestry
id: verify
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
run: python3 scripts/release_integrity.py verify

- name: Install Syft
env:
Expand All @@ -52,12 +51,14 @@ jobs:
sudo tar --extract --gzip --file "$archive" --directory /usr/local/bin syft

- name: Build deterministic release artifacts
env:
RELEASE_COMMIT: ${{ steps.verify.outputs.release_commit }}
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" | \
git archive --format=tar --prefix="openshield-${TAG}/" "$RELEASE_COMMIT" | \
gzip --no-name > "dist/openshield-${TAG}.tar.gz"
cd dist
sha256sum "openshield-${TAG}.tar.gz" "openshield-${TAG}-sbom.cyclonedx.json" > SHA256SUMS
Expand All @@ -77,9 +78,29 @@ jobs:
with:
subject-path: dist/SHA256SUMS

- name: Reverify source before release publication
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ steps.verify.outputs.release_commit }}
EXPECTED_TAG_OBJECT: ${{ steps.verify.outputs.tag_object }}
run: python3 scripts/release_integrity.py verify

- name: Publish release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
generate_release_notes: true
make_latest: true
files: dist/*

container:
needs: release
permissions:
contents: read
packages: write
id-token: write
attestations: write
uses: ./.github/workflows/docker.yml
with:
release_tag: ${{ needs.release.outputs.release_tag }}
release_commit: ${{ needs.release.outputs.release_commit }}
tag_object: ${{ needs.release.outputs.tag_object }}
109 changes: 109 additions & 0 deletions docs/container-release-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Container release integrity

This is the container-publication slice of #304. It does not claim that the
deployment topology, restore drills or complete enterprise release criteria are
finished. #336 separately addresses Python dependency locking.

## Release path

1. A stable `vMAJOR.MINOR.PATCH` tag push starts `release.yml`. Branch pushes,
manual dispatch, lightweight tags, unverified signatures and nested tags do
not qualify. The tag must directly identify the event/checkout commit, and
that commit must be on the fetched `main` history.
2. Source artifacts are built from that immutable commit, attested and published
through the signed-release job. The tag object is checked again before
publishing source assets.
3. Only after the source job succeeds can its dependent reusable `docker.yml`
job run. It requires owner opt-in via the repository variable
`CONTAINER_RELEASE_ENABLED=true`.
4. The container gate rechecks repository, event, annotated-tag identity,
signature, commit and `main` ancestry. It builds one Linux runner-native image
and exports a Docker archive. Trivy scans that archive; Syft generates the
image CycloneDX SBOM from the same archive, not from the source directory.
5. A passing scan and another tag check permit pushing a unique
`candidate-RUN_ID-RUN_ATTEMPT` tag. The image ID must still match the built
image. Both provenance and SBOM attestations bind to the exact registry
manifest digest, using GitHub's keyless Sigstore-backed attestation actions.
6. Both attestations must verify against the repository, reusable signer
workflow, source commit and tag ref. Only then is the existing local image
tagged as `MAJOR.MINOR.PATCH` and pushed, without rebuilding. The promoted
digest is checked against the attested digest. No moving `latest` or
`MAJOR.MINOR` aliases are updated by this workflow.

All publishing steps use normal success dependencies. Scan errors, unavailable
verification services, ambiguous digests and failed attestations stop version
promotion. The last evidence-upload step may run on failure but cannot publish
an image. Trivy retains the current CI policy: fail on HIGH/CRITICAL findings
with available fixes (`ignore-unfixed: true`). This is not a claim that the
image has no vulnerabilities; no new ignore list is introduced.

## OWASP transfer and first-release approval

GitHub now identifies this repository as `OWASP/openshield`. New image releases
target **`ghcr.io/owasp/openshield`**, not the historical organization namespace.
Nothing in this change migrates, deletes or overwrites historical packages.

Before enabling the container job, an OWASP repository/package administrator must:

- Confirm that the repository's `GITHUB_TOKEN` may create/write this package
and that its intended visibility and repository association are correct.
- Confirm the tag-creation/signing authority, `main` protections and review
process. A GitHub-verified signature plus ancestry is not an independent
authorization check on the signer; trusted tag writers and protected workflow
files remain essential. Effective protection enforcement is tracked in #298.
- Review the workflow and approve a first-release verification plan, then set
`CONTAINER_RELEASE_ENABLED=true`. Leaving it unset disables container
publication; it does not disable source releases.
- Use a new signed stable release tag only after this workflow is promoted to
`main` through the normal process. Old tags retain their old workflow code;
this change is not a retroactive gate for historical workflows.
- Verify the published image and evidence below before announcing availability.

No administrator settings, tags, registry writes, release dispatches or deployments
are required to review this PR. Local unit tests use fake GitHub responses and
temporary local Git histories; they do not prove live OIDC/registry integration.
The first owner-authorized release must supply that operating evidence.

## Verify an image

Use the digest recorded in the successful workflow summary and
`container-release-evidence-RUN_ID-RUN_ATTEMPT` artifact. That artifact retains
the Trivy report, image SBOM, source identity and registry digest for 90 days.
Image attestations are also pushed to the registry and recorded by GitHub.

```bash
# Substitute the actual digest and source commit recorded by the release.
IMAGE=ghcr.io/owasp/openshield@sha256:ACTUAL_DIGEST
COMMIT=ACTUAL_SOURCE_COMMIT
TAG=vX.Y.Z

gh attestation verify "oci://$IMAGE" --repo OWASP/openshield \
--signer-workflow OWASP/openshield/.github/workflows/docker.yml \
--source-digest "$COMMIT" --source-ref "refs/tags/$TAG"

gh attestation verify "oci://$IMAGE" --repo OWASP/openshield \
--signer-workflow OWASP/openshield/.github/workflows/docker.yml \
--source-digest "$COMMIT" --source-ref "refs/tags/$TAG" \
--predicate-type https://cyclonedx.org/bom
```

Authenticate to GHCR if required for package access. Use a current GitHub CLI
supporting these attestation flags. For reusable workflows, the reusable workflow
is the signer identity, not the caller. See the
[GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify).

## Failures and reruns

A failed run can leave an unpromoted candidate in GHCR: registry publication and
signing are not one atomic transaction. Never deploy a candidate or infer trust
from its tag. Verify the image by digest with both predicates. A source release
may already exist when the dependent container job fails; do not announce the
container until its job and verification finish.

Tag moves are rechecked before writes but are not locked atomically across GitHub
and GHCR. An administrator must restrict moving/deleting release tags. Reruns
can rebuild different bytes from mutable base/OS dependencies and can replace
the version tag; use a new release version for changed images and deploy pinned
digests. Digest pinning, not tag spelling, gives immutable consumption. Candidate
cleanup, immutable package-tag enforcement, multi-architecture publishing and
base-image reproducibility remain follow-up work.
Loading
Loading