Skip to content
Open
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
83 changes: 71 additions & 12 deletions .github/workflows/trivy-image-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<registry>/<name>@<digest>` ref.
# Scans the caller's GHCR images at the release tag (the tag that triggered the
# run, the triggering run's tag under `workflow_run`, or `pr-<N>` on pull
# requests), one job per image, by digest. 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
Expand All @@ -37,11 +40,67 @@ 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:
persist-credentials: false
Comment on lines +52 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we specify which ref to checkout here? I think this might read the default branch's config instead of the build revision's config.


- name: Discover images
id: discover
uses: OpenCHAMI/github-actions/actions/discover-ghcr-images@26748ab38b9b17fa895919ef937aca32376a23e2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No version comment here yet?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks!

Should one be there? Yes absolutely!

Why isn't it there? Pinning a SHA ref requires a commit to pin it to, meaning I had to commit the feature first, then do another commit and switch a zeros todo hash with a proper hash.

I will fix this tomorrow.

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 }}
# PR builds are tagged pr-<N> by build-release-goreleaser.
TAG: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.workflow_run.head_branch || github.ref_name }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this workflow is called by a workflow_run handler after a PR build, github.event_name is workflow_run, so this expression selects workflow_run.head_branch. The build publishes pr-<N>, not the branch name. Consequently, discovery finds no digest, outputs [], and skips every Trivy job while leaving the workflow successful.

Essentially, the scanner confuses the event that started the scanner with the event that started the build.

When scanning in the same caller workflow:

pull_request #44 → build publishes pr-44 → scan selects pr-44

When scanning in a separate workflow:

pull_request #44 → build publishes pr-44
                 → workflow_run starts scanner
                 → scan selects seantronsen/feat/decouple-trivy

Evidence: This PR's build run (https://github.com/OpenCHAMI/github-actions/actions/runs/38472346439) returns:

{
  "event": "pull_request",
  "head_branch": "seantronsen/feat/decouple-trivy",
  "pull_requests": [44]
}

This expression only recognizes a direct pull_request event. For workflow_run, it takes head_branch, which identifies the source branch. The build workflow's tagging convention is pr-44, so the lookup searches for the wrong image tag. The missing tag produces a notice, discovery returns an empty matrix, and the scan job is skipped.

One way to address this is to first resolve the image tag explicitly before looking up digests. Add an optional image-tag input and use this precedence:

Situation Tag to use
Caller supplies image-tag Use it
Direct pull_request event pr-<pull_request.number>
workflow_run from a PR build pr-<associated PR number>
Direct tag push ${{ github.ref_name }}
Anything else or ambiguous metadata Fail with a useful error

An explicit input also gives callers a reliable way to handle release builds or custom tagging conventions.

Second, make missing expected images fail discovery. The existing missing-digest branch can change from:

echo "::notice::${package} has no image tagged ${TAG}; skipping"
continue

to:

echo "::error::${package} has no image tagged ${TAG}"
failed=true
continue

The existing final check then fails discovery after reporting all missing images:

[[ "$failed" == false ]] || exit 1

run: |
set -euo pipefail
registry="ghcr.io/${OWNER,,}"
images='[]'
failed=false
# shellcheck disable=SC2086 # intentional word splitting
for package in $NAMES; do
if ! versions=$(gh api --paginate "/orgs/${OWNER}/packages/container/${package}/versions"); then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for extra safety, we'll need to URL-encode the package name before inserting it into the API path. Discovery preserves nested names such as tools/foo, but this request turns the slash into another path segment. That causes verification to reject an existing nested GHCR package.

We'll also need to address that here too:

if ! gh api "/orgs/${OWNER}/packages/container/${package}" --silent; then

We can actually use jq to do this:

encoded_package=$(jq -rn --arg name "$package" '$name | @uri')

Then pass this variable to gh api:

gh api "/orgs/${OWNER}/packages/container/${encoded_package}" --silent
gh api --paginate "/orgs/${OWNER}/packages/container/${encoded_package}/versions"

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 "::notice::${package} has no image tagged ${TAG}; skipping"
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:
Expand All @@ -50,7 +109,7 @@ jobs:
- 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 }}
Expand All @@ -62,4 +121,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
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 release tag (`pr-<N>` on pull requests), one job per image, by digest. 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
Expand All @@ -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@<digest> # 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)
Expand Down Expand Up @@ -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]
Expand Down
31 changes: 31 additions & 0 deletions actions/discover-ghcr-images/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!--
SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC
SPDX-License-Identifier: MIT
-->

# 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@<sha>
with:
persist-credentials: false
- id: discover
uses: OpenCHAMI/github-actions/actions/discover-ghcr-images@<sha>
# 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`).
Comment on lines +30 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a concrete example of what the output would look like?

106 changes: 106 additions & 0 deletions actions/discover-ghcr-images/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# 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
if ! gh api "/orgs/${OWNER}/packages/container/${package}" --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"
Loading