diff --git a/.github/workflows/trivy-image-scan.yml b/.github/workflows/trivy-image-scan.yml index dd6a4e6..043f4e9 100644 --- a/.github/workflows/trivy-image-scan.yml +++ b/.github/workflows/trivy-image-scan.yml @@ -3,22 +3,25 @@ name: trivy-image-scan -# Reusable workflow that scans an already-built and -pushed container image -# with Trivy. Emits SARIF and uploads it to GitHub Advanced Security so -# findings land in the caller's Security tab. +# Reusable workflow that scans already-pushed container images with Trivy. +# Emits SARIF and uploads it to GitHub Advanced Security so findings land in +# the caller's Security tab. # -# Expects the image to already exist (either in a registry or as a local -# `docker save` tar at `image-path`). To run as a build-time gate inside -# docker-build-release, call this workflow after the build step with the -# fully-qualified `/@` ref. +# Scans the caller's GHCR images at the build's tag (the pushed tag, or `pr-` +# on pull requests; under `workflow_run`, those of the triggering build), one +# job per image, by digest. Fork PRs publish nothing and are skipped. +# GITHUB_TOKEN can't list packages, so image names come from `images`, else the +# ghcr.io entries in the GoReleaser config (`dockers_v2[].images`), else the +# repository name (see actions/discover-ghcr-images). on: workflow_call: inputs: - image-ref: - description: 'Image to scan (e.g., ghcr.io/openchami/foo@sha256:...)' - required: true + images: + description: 'Space- or newline-separated GHCR package names to scan (default: from the GoReleaser config, else the repository name)' + required: false type: string + default: '' severity: description: 'Comma-separated severities to report (default: HIGH,CRITICAL)' required: false @@ -37,20 +40,90 @@ on: permissions: contents: read # baseline for checkout + packages: read # read image versions security-events: write # for SARIF upload to GHAS jobs: + discover: + runs-on: ubuntu-slim + outputs: + images: ${{ steps.images.outputs.images }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Under workflow_run, github.sha is the default branch; read the built revision's config instead. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false + + - name: Discover images + id: discover + uses: OpenCHAMI/github-actions/actions/discover-ghcr-images@1bcb6a89c847927a24c6abcde3b88df4d61f79a0 # v4.0 + with: + images: ${{ inputs.images }} + + # TODO: IF: Candidate for deduplication into another reusable action + # IFF another workflow needs image digests. + - name: Find image digests + id: images + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + NAMES: ${{ steps.discover.outputs.names }} + # Direct trigger, else the build run that triggered this one (workflow_run). + # Fork PRs don't publish images (build-release-goreleaser's SKIP_PUBLISH). + FORK: ${{ (github.event.pull_request.head.repo.full_name || github.event.workflow_run.head_repository.full_name || github.repository) != github.repository }} + # Match build-release-goreleaser's tags: pr- on PRs, else the pushed tag. + TAG: ${{ (github.event_name == 'pull_request' || github.event.workflow_run.event == 'pull_request') && format('pr-{0}', github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number) || github.event.workflow_run.head_branch || github.ref_name }} + run: | + set -euo pipefail + if [[ "$FORK" == true ]]; then + echo "::notice::Fork PR; no images published, nothing to scan." + echo "images=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + registry="ghcr.io/${OWNER,,}" + images='[]' + failed=false + # shellcheck disable=SC2086 # intentional word splitting + for package in $NAMES; do + # Nested package names (tools/foo) need their slash encoded. + encoded=$(jq -rn --arg name "$package" '$name | @uri') + if ! versions=$(gh api --paginate "/orgs/${OWNER}/packages/container/${encoded}/versions"); then + echo "::error::Can't read versions of ghcr.io package '${package}'." + failed=true + continue + fi + digest=$(jq -r --arg tag "$TAG" '[.[] | select((.metadata.container.tags // []) | index($tag)) | .name][0] // empty' <<< "$versions") + if [[ -z "$digest" ]]; then + echo "::error::${package} has no image tagged ${TAG}" + failed=true + continue + fi + images=$(jq -c --arg name "${registry}/${package}" --arg digest "$digest" \ + '. + [{name: $name, digest: $digest}]' <<< "$images") + done + [[ "$failed" == false ]] || exit 1 + echo "images=${images}" + echo "images=${images}" >> "$GITHUB_OUTPUT" + trivy: + needs: discover + if: ${{ needs.discover.outputs.images != '[]' }} runs-on: ubuntu-slim + strategy: + fail-fast: false + matrix: + image: ${{ fromJSON(needs.discover.outputs.images) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} persist-credentials: false - name: Run Trivy uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: ${{ inputs.image-ref }} + image-ref: ${{ matrix.image.name }}@${{ matrix.image.digest }} format: sarif output: trivy-results.sarif severity: ${{ inputs.severity }} @@ -62,4 +135,4 @@ jobs: uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: sarif_file: trivy-results.sarif - category: trivy-image-scan + category: trivy-image-scan/${{ matrix.image.name }} # one category per image diff --git a/README.md b/README.md index 9899461..5f268c7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Reusable GitHub Actions for CI/CD. - `actions/gpg-configure-release-keys`: **Deprecated** - use nfpm package signing in `build-release-goreleaser` instead. Generates and certifies a per-run ephemeral GPG key through the repo's release key chain - `actions/gpg-sign-rpm`: **Deprecated** - use nfpm package signing in `build-release-goreleaser` instead. RPM signing with ephemeral keys - `actions/gpg-check-key-expiration`: Fails CI if a signing key is expired or expiring soon +- `actions/discover-ghcr-images`: Finds and verifies a repo's GHCR image names (`images` input, GoReleaser config, or repo name) - `actions/gpg-verify-trust-chain`: **Deprecated** - use the `rpm -K` check in `validate-packages` instead. Verifies the master/repo-cert/ephemeral trust chain and optionally checksigs RPMs - `.github/workflows/go-build-release.yml`: **Deprecated** - use `build-release-goreleaser` (build-only: `build-check-goreleaser`) instead. Reusable workflow for GoReleaser builds - `.github/workflows/docker-build-release.yml`: **Deprecated** - use `build-release-goreleaser` (build-only: `build-check-goreleaser`) instead. Reusable workflow for multi-arch container image builds @@ -331,7 +332,7 @@ jobs: ``` ### trivy-image-scan (Reusable Workflow) -Scans an already-pushed container image with Trivy and uploads SARIF findings to GitHub Advanced Security. Designed to chain after `build-release-goreleaser`. +Scans already-pushed container images with Trivy and uploads SARIF findings to GitHub Advanced Security. It scans each image at the build's tag (the pushed tag, or `pr-` on pull requests; also when run from `workflow_run`), one job per image, by digest. Fork PRs publish no images and are skipped. GitHub doesn't let `GITHUB_TOKEN` list packages, so image names come from `images`, else the `ghcr.io` entries in the GoReleaser config (`dockers_v2[].images`), else the repository name (see `discover-ghcr-images`). Every name must be an existing package; all bad names are reported, then the run fails. Run it after `build-release-goreleaser`. **Usage:** ```yaml @@ -342,12 +343,16 @@ jobs: scan: needs: build uses: OpenCHAMI/github-actions/.github/workflows/trivy-image-scan.yml@v4.0 - with: - image-ref: ghcr.io/openchami/foo@ # TODO(#44): scan finds image digests itself - # Optional overrides: - # severity: CRITICAL - # ignore-unfixed: true - # exit-code: '0' # report only + permissions: + contents: read + packages: read + security-events: write + # Optional overrides: + # with: + # images: foo foo-init # default: from the GoReleaser config, else the repo name + # severity: CRITICAL + # ignore-unfixed: true + # exit-code: '0' # report only ``` ### scorecard (Reusable Workflow) @@ -592,6 +597,9 @@ Signs an RPM using a provided GPG fingerprint (works with the ephemeral key outp ### gpg-check-key-expiration Fails CI if the provided signing key is expired or expiring within a threshold. See the [action README](actions/gpg-check-key-expiration/README.md). +### discover-ghcr-images +Finds a repo's GHCR image names from the `images` input, else the GoReleaser config, else the repo name, and verifies each one exists. Used by `trivy-image-scan`. See the [action README](actions/discover-ghcr-images/README.md). + ### gpg-verify-trust-chain (Deprecated) > [!CAUTION] diff --git a/actions/discover-ghcr-images/README.md b/actions/discover-ghcr-images/README.md new file mode 100644 index 0000000..8ab0e7e --- /dev/null +++ b/actions/discover-ghcr-images/README.md @@ -0,0 +1,48 @@ + + +# discover-ghcr-images + +Finds and verifies the caller's GHCR package names. `GITHUB_TOKEN` can't list an +organization's packages, so names come from exactly one source: + +1. the `images` input, if set +2. else the `ghcr.io` entries in the GoReleaser config's `dockers_v2[].images` + (found the same way GoReleaser finds it) +3. else the repository name, lowercased + +Every name must be an existing package; all bad names are reported, then the +step fails. Check out the repository first, and grant the job `packages: read`. + +```yaml +- uses: actions/checkout@ + with: + persist-credentials: false +- id: discover + uses: OpenCHAMI/github-actions/actions/discover-ghcr-images@ + # with: + # images: foo foo-init +- run: echo "${{ steps.discover.outputs.names }} (from ${{ steps.discover.outputs.source }})" +``` + +Outputs `names` (space-separated, without registry or owner) and `source` +(`input`, `goreleaser`, or `repo`). For example, in `OpenCHAMI/foo` with this +GoReleaser config: + +```yaml +dockers_v2: + - images: + - ghcr.io/openchami/foo + - docker.io/openchami/foo + - images: + - ghcr.io/openchami/foo-init +``` + +the outputs are: + +```text +names=foo foo-init +source=goreleaser +``` diff --git a/actions/discover-ghcr-images/action.yml b/actions/discover-ghcr-images/action.yml new file mode 100644 index 0000000..9bb27cb --- /dev/null +++ b/actions/discover-ghcr-images/action.yml @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT + +name: 'Discover GHCR images' +author: 'OpenCHAMI' +branding: { icon: 'search', color: 'blue' } +description: 'Finds and verifies the GHCR package names for a repository' + +inputs: + images: + description: 'Space- or newline-separated GHCR package names (overrides discovery)' + required: false + default: '' + +outputs: + names: + description: 'Space-separated GHCR package names (without registry or owner)' + value: ${{ steps.verify.outputs.names }} + source: + description: 'Where the names came from: input, goreleaser, or repo' + value: ${{ steps.source.outputs.source }} + +runs: + using: composite + steps: + - name: Select image source + id: source + shell: bash + env: + IMAGES: ${{ inputs.images }} + run: | + set -euo pipefail + config="" + # Same lookup order as GoReleaser itself. + for f in .config/goreleaser.yml .config/goreleaser.yaml .goreleaser.yml .goreleaser.yaml goreleaser.yml goreleaser.yaml; do + [[ -f "$f" ]] && { config=$f; break; } + done + if [[ -n "$IMAGES" ]]; then + source=input + elif [[ -n "$config" ]] && grep -q '^dockers_v2:' "$config"; then + source=goreleaser + else + source=repo + fi + echo "Image names from: ${source}" + { + echo "source=${source}" + echo "config=${config}" + } >> "$GITHUB_OUTPUT" + + - name: Read image names from input + id: input + if: ${{ steps.source.outputs.source == 'input' }} + shell: bash + env: + IMAGES: ${{ inputs.images }} + run: echo "names=$(xargs <<< "$IMAGES")" >> "$GITHUB_OUTPUT" + + - name: Read image names from GoReleaser config + id: goreleaser + if: ${{ steps.source.outputs.source == 'goreleaser' }} + shell: bash + env: + CONFIG: ${{ steps.source.outputs.config }} + run: | + set -euo pipefail + names=$(yq -r '.dockers_v2[].images[] | select(test("^ghcr\\.io/")) | sub("^ghcr\\.io/[^/]+/"; "")' "$CONFIG" | xargs) + if [[ -z "$names" ]]; then + echo "::error::No ghcr.io images in ${CONFIG} dockers_v2. Set the images input." + exit 1 + fi + if [[ "$names" == *"{{"* ]]; then + echo "::error::Templated image names in ${CONFIG} can't be resolved. Set the images input." + exit 1 + fi + echo "names=${names}" >> "$GITHUB_OUTPUT" + + - name: Guess image name from repository + id: repo + if: ${{ steps.source.outputs.source == 'repo' }} + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + run: echo "names=${REPO_NAME,,}" >> "$GITHUB_OUTPUT" + + - name: Check packages exist + id: verify + shell: bash + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + SOURCE: ${{ steps.source.outputs.source }} + NAMES: ${{ steps.input.outputs.names || steps.goreleaser.outputs.names || steps.repo.outputs.names }} + run: | + set -euo pipefail + # shellcheck disable=SC2086 # intentional word splitting + names=$(printf '%s\n' $NAMES | sort -u | xargs) + failed=false + for package in $names; do + # Nested package names (tools/foo) need their slash encoded. + encoded=$(jq -rn --arg name "$package" '$name | @uri') + if ! gh api "/orgs/${OWNER}/packages/container/${encoded}" --silent; then + echo "::error::No ghcr.io package '${package}' (name from ${SOURCE}). Set the images input to the right package names." + failed=true + fi + done + [[ "$failed" == false ]] || exit 1 + echo "names=${names}" >> "$GITHUB_OUTPUT"