From 31870fe362b529620e8f1e079f263af2100f3a75 Mon Sep 17 00:00:00 2001 From: Steve Gilvarry Date: Sun, 25 Oct 2026 16:47:22 +1100 Subject: [PATCH] Add SBOMs, full provenance, cosign signatures and vulnerability scans - Every pushed image now carries an SBOM and mode=max provenance as attestations. They survive the per-arch merge and the copies to zoneminderhq and GHCR. - The merge job signs each multi-arch index with cosign, keyless through GitHub's OIDC token, in every repo. The CUDA workflow pushes with build-push-action instead of docker push, so it gets the same attestations. It then signs its image the same way. - The amd64 build jobs scan the smoke-tested zm-es and pyzm-serve images with Grype before any registry login. Fixable findings scoring 7.0 or more go into the job summary, and on master the full results go to the Security tab. Scans never block a push. - New third-party actions are pinned to a commit: anchore/scan-action, sigstore/cosign-installer and github/codeql-action. - The README has a new section on verifying images. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CKoPm5qySMSsCYJmCo8r2J --- .github/scan-summary.jq | 12 +++++ .github/workflows/images.yml | 77 ++++++++++++++++++++++++++++++++- .github/workflows/pyzm-cuda.yml | 39 +++++++++++++++-- README.md | 27 ++++++++++++ 4 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 .github/scan-summary.jq diff --git a/.github/scan-summary.jq b/.github/scan-summary.jq new file mode 100644 index 0000000..64d3cad --- /dev/null +++ b/.github/scan-summary.jq @@ -0,0 +1,12 @@ +# Turns a Grype SARIF report into a markdown table for the job summary: one row per +# vulnerability with a CVSS score of 7.0 or more, highest first. Used by images.yml. +.runs[0] as $r +| ($r.tool.driver.rules | map({key: .id, value: (.properties["security-severity"] | tonumber? // 0)}) | from_entries) as $score +| [$r.results[] | {id: .ruleId, score: $score[.ruleId], + pkg: ((.message.text | capture("package: (?

[^ ,]+), version (?[^ ]+)") | "\(.p) \(.v)") // ""), + at: ((.message.text | capture(" at: (?.+)$").a) // "")}] +| map(select(.score >= 7)) | unique_by(.id) | sort_by(-.score) +| if length == 0 then "None." else + ("| Vulnerability | Score | Package | Where |", "|---|---|---|---|", + (.[] | "| \(.id) | \(.score) | \(.pkg) | \(.at) |")) + end diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index a50c73c..1e821ba 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -14,6 +14,12 @@ name: images # zoneminder/pyzm-serve # Every tag is also published to GitHub's registry as ghcr.io/zoneminder/zoneminder and # ghcr.io/zoneminder/pyzm-serve, pushed with the workflow's GITHUB_TOKEN. +# +# Supply chain: each image carries an SBOM and full build provenance as attestations, and the +# merge job signs every multi-arch index with cosign (keyless, through GitHub's OIDC token). +# The amd64 build job scans the smoke-tested images with Grype and reports to the repo's +# Security tab. Scan results never block a push: holding back an image would also hold back +# the Debian security fixes in it. Third-party actions are pinned to a commit. on: push: @@ -101,6 +107,9 @@ jobs: needs: prep runs-on: ${{ matrix.runner }} timeout-minutes: 90 + permissions: + contents: read + security-events: write # upload scan results to the Security tab strategy: fail-fast: false matrix: @@ -129,6 +138,55 @@ jobs: ZM_VERSION: ${{ steps.meta.outputs.pin }} run: tests/smoke.sh + # Scan the images the smoke test just built and ran, before any registry login, so the + # scanner never runs with Docker Hub credentials available. amd64 only: the arm64 images + # hold the same packages, and scanning both would report every finding twice. + - name: Scan zm-es + id: scan_zm + if: matrix.arch == 'amd64' + uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 + with: + image: zmsmoke:es + grype-version: v0.121.0 + only-fixed: true + fail-build: false + output-format: sarif + output-file: scan-zm-es.sarif + - name: Scan pyzm-serve + id: scan_pyzm + if: matrix.arch == 'amd64' && matrix.channel == 'release' + uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 + with: + image: zmsmoke-pyzm-serve:latest + grype-version: v0.121.0 + only-fixed: true + fail-build: false + output-format: sarif + output-file: scan-pyzm-serve.sarif + - name: Scan summary + if: matrix.arch == 'amd64' + run: | + { + echo "### Fixable vulnerabilities scoring 7.0 or more (${{ matrix.channel }})" + for f in scan-*.sarif; do + name=${f#scan-}; echo; echo "**${name%.sarif}**"; echo + jq -r -f .github/scan-summary.jq "$f" + done + } >> "$GITHUB_STEP_SUMMARY" + # Pull requests from forks get a read-only token, so they only get the summary above. + - name: Upload zm-es scan + if: matrix.arch == 'amd64' && github.event_name != 'pull_request' + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: scan-zm-es.sarif + category: grype-zm-es-${{ matrix.channel }} + - name: Upload pyzm-serve scan + if: matrix.arch == 'amd64' && matrix.channel == 'release' && github.event_name != 'pull_request' + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: scan-pyzm-serve.sarif + category: grype-pyzm-serve + - uses: docker/setup-buildx-action@v3 if: steps.meta.outputs.push == 'true' - uses: docker/login-action@v3 @@ -165,6 +223,8 @@ jobs: org.opencontainers.image.licenses=GPL-2.0-or-later tags: ${{ steps.meta.outputs.zm_repo }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true + sbom: true + provenance: mode=max cache-from: type=gha,scope=${{ matrix.channel }}-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.channel }}-${{ matrix.arch }} @@ -194,6 +254,8 @@ jobs: org.opencontainers.image.licenses=GPL-2.0-or-later tags: ${{ steps.meta.outputs.zm_repo }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true + sbom: true + provenance: mode=max cache-from: type=gha,scope=${{ matrix.channel }}-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.channel }}-${{ matrix.arch }} @@ -214,6 +276,8 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} tags: ${{ steps.meta.outputs.pyzm_repo }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true + sbom: true + provenance: mode=max cache-from: type=gha,scope=pyzm-serve-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=pyzm-serve-${{ matrix.arch }} @@ -243,6 +307,7 @@ jobs: timeout-minutes: 15 permissions: packages: write # push to ghcr.io + id-token: write # cosign keyless signing strategy: fail-fast: false matrix: @@ -276,8 +341,12 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + if: steps.meta.outputs.push == 'true' + with: + cosign-release: v3.1.4 - - name: Tag multi-arch images + - name: Tag and sign multi-arch images if: steps.meta.outputs.push == 'true' env: CHANNEL: ${{ matrix.channel }} @@ -291,6 +360,7 @@ jobs: # merge : one index from both architectures' digests, # under every tag. imagetools copies the images into the other repos and registries. # GHCR reads the source annotation on the index to link the package to this repo. + # Then cosign signs the index in each repo, since signatures are stored per repo. merge() { local sources=() tags=() arch f for arch in amd64 arm64; do @@ -304,6 +374,11 @@ jobs: --annotation "index:org.opencontainers.image.description=$4" \ "${sources[@]}" docker buildx imagetools inspect "${3%% *}" + local digest repo + digest=$(docker buildx imagetools inspect "${3%% *}" --format '{{json .Manifest}}' | jq -r .digest) + for repo in $(for t in $3; do echo "${t%:*}"; done | sort -u); do + cosign sign --yes "$repo@$digest" + done } merge zm "$ZM_REPO" "$ZM_TAGS" "ZoneMinder video surveillance on Debian" merge zm-es "$ZM_REPO" "$ES_TAGS" "ZoneMinder video surveillance on Debian, with zmeventnotificationNg" diff --git a/.github/workflows/pyzm-cuda.yml b/.github/workflows/pyzm-cuda.yml index bec26a3..b010dd6 100644 --- a/.github/workflows/pyzm-cuda.yml +++ b/.github/workflows/pyzm-cuda.yml @@ -35,6 +35,7 @@ jobs: permissions: contents: read packages: write # push to ghcr.io + id-token: write # cosign keyless signing steps: - uses: actions/checkout@v4 @@ -59,7 +60,7 @@ jobs: { echo "push=$push" echo "pyzm=$pyzm" - echo "tags=$(for r in $PYZM_REPOS; do printf '%s ' "$r:cuda" "$r:$pyzm-cuda"; done)" + echo "tags=$(for r in $PYZM_REPOS; do printf '%s,' "$r:cuda" "$r:$pyzm-cuda"; done)" } | tee -a "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@v3 @@ -101,10 +102,40 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Same inputs as Build, so every layer comes from the cache and the pushed image is the one + # just tested. This step adds the SBOM and provenance attestations, which a docker push of + # the loaded image can't carry. - name: Push + id: push if: steps.meta.outputs.push == 'true' + uses: docker/build-push-action@v6 + with: + context: release/pyzm-serve + file: release/pyzm-serve/Dockerfile.cuda + platforms: linux/amd64 + push: true + sbom: true + provenance: mode=max + build-args: BUILD_JOBS=4 + labels: | + org.opencontainers.image.title=pyzm-serve (CUDA) + org.opencontainers.image.description=Object detection server for the ZoneMinder event server, on NVIDIA GPUs + org.opencontainers.image.version=${{ steps.meta.outputs.pyzm }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha,scope=pyzm-cuda + + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + if: steps.meta.outputs.push == 'true' + with: + cosign-release: v3.1.4 + - name: Sign + if: steps.meta.outputs.push == 'true' + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.push.outputs.digest }} run: | - for tag in ${{ steps.meta.outputs.tags }}; do - docker tag "$TEST_IMAGE" "$tag" - docker push "$tag" + for repo in $(echo "$TAGS" | tr , '\n' | sed '/^$/d; s/:[^:]*$//' | sort -u); do + cosign sign --yes "$repo@$DIGEST" done diff --git a/README.md b/README.md index da5eec9..a8de1cd 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,33 @@ docker compose up -d live in the same place (`/var/cache/zoneminder/events`), so point the `events` volume at the old directory, or bind mount it. +## Verifying images + +CI signs every published image with [cosign](https://docs.sigstore.dev/), using GitHub's OIDC +identity, so there is no key to distribute. To check that an image came from this repo's master +branch: + +```bash +cosign verify zoneminder/zoneminder:latest \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/ZoneMinder/zmdockerfiles/\.github/workflows/(images|pyzm-cuda)\.yml@refs/heads/master$' +``` + +The same works for `zoneminderhq/zoneminder`, `zoneminder/pyzm-serve` and the `ghcr.io` names. + +Each image also carries an SBOM, a list of every package inside it with versions, and a record of +how it was built: the commit, workflow and build arguments. Read them without pulling the image: + +```bash +docker buildx imagetools inspect zoneminder/zoneminder:latest --format '{{json .SBOM}}' +docker buildx imagetools inspect zoneminder/zoneminder:latest --format '{{json .Provenance}}' +``` + +CI scans the images with [Grype](https://github.com/anchore/grype) on every build. Results for +master are in the repo's Security tab, and each run's summary lists fixable findings scoring 7.0 +or more. Findings don't block publishing: an image held back for one CVE would also hold back the +Debian security fixes in the same rebuild. + ## Building ```bash