Repository navigation
feat(workflows)!: step 4, scan every published image with Trivy #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||
|
|
@@ -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 | ||||||||||||||
|
|
||||||||||||||
| - name: Discover images | ||||||||||||||
| id: discover | ||||||||||||||
| uses: OpenCHAMI/github-actions/actions/discover-ghcr-images@26748ab38b9b17fa895919ef937aca32376a23e2 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No version comment here yet?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }} | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When this workflow is called by a Essentially, the scanner confuses the event that started the scanner with the event that started the build. When scanning in the same caller workflow: When scanning in a separate workflow: 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 One way to address this is to first resolve the image tag explicitly before looking up digests. Add an optional
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 echo "::notice::${package} has no image tagged ${TAG}; skipping"
continueto: echo "::error::${package} has no image tagged ${TAG}"
failed=true
continueThe 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 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 We'll also need to address that here too:
We can actually use encoded_package=$(jq -rn --arg name "$package" '$name | @uri')Then pass this variable to 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: | ||||||||||||||
|
|
@@ -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 }} | ||||||||||||||
|
|
@@ -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 | ||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
| 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" |
There was a problem hiding this comment.
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.