Add reusable Grype composite actions

This commit is contained in:
mdaleo404 committed 2026-10-10 11:07:38 +01:00
commit 259880fca6
4 files changed
+325

No files matched your search

+1
View File
@@ -0,0 +1 @@
.opencode/
+116
View File
@@ -0,0 +1,116 @@
# actions
Reusable composite actions shared across the `git.sysmd.uk` CI workflows.
These actions implement **mechanism only** (install a tool, scan an image).
Each calling workflow keeps its own **policy**: what triggers a build, and
whether a finding notifies, fails the run, blocks a push, or blocks a deploy.
## Actions
| Action | Purpose |
| --- | --- |
| `install-grype` | Installs a pinned, signature-verified Grype (and the pinned Cosign used to verify it) into a job-local directory on `PATH`. |
| `scan-image` | Scans an image with Grype and fails the step on fixable High/Critical findings; writes the raw JSON report. |
## Usage
Reference the actions from another repository with the **full instance URL**. An
unprefixed `owner/repo@ref` is fetched from **github.com** (unless
`DEFAULT_ACTIONS_URL=self`), and the `self:` prefix is not honoured by all
runner versions. The absolute form works regardless:
```yaml
uses: https://git.sysmd.uk/mdaleo404/actions/install-grype@v1
```
Pin the ref (`@v1`, or a commit SHA for stronger supply-chain guarantees)
rather than floating `@main`. The path is any directory containing
`action.yml` — these live at the repo root, so the refs are short.
The repository must be reachable by the runner without credentials. If it is
**private**, either make it public or vendor the action into the consuming
repository and reference it locally as `./.gitea/actions/<name>`.
```yaml
jobs:
build-scan-push:
runs-on: running-man
env:
IMAGE: git.sysmd.uk/team/example:latest
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: echo "$REGISTRY_TOKEN" | docker login git.sysmd.uk -u "$REGISTRY_USER" --password-stdin
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Build image
run: docker build --no-cache -t "${IMAGE}" .
- uses: https://git.sysmd.uk/mdaleo404/actions/install-grype@v1
# continue-on-error lets the caller inspect the outcome and decide policy.
- name: Scan image
id: scan
continue-on-error: true
uses: https://git.sysmd.uk/mdaleo404/actions/scan-image@v1
with:
image: ${{ env.IMAGE }}
- name: Notify on vulnerabilities
if: steps.scan.outcome == 'failure'
run: |
# read grype.json (written by scan-image) and post wherever you like
jq '{repo: "owner/repo", image: env.IMAGE, count: (.matches | length)}' grype.json
- name: Fail if vulnerabilities found
if: steps.scan.outcome == 'failure'
run: exit 1
- name: Push image
if: steps.scan.outcome != 'failure'
run: docker push "${IMAGE}"
```
## Interface
### `install-grype`
| Input | Default | Notes |
| --- | --- | --- |
| `grype-version` | `v0.110.0` | Release tag including `v`. |
| `cosign-version` | `v3.0.5` | Cosign used only to verify Grype's checksums. |
| `install-dir` | job-local dir on `PATH` | Override to choose the install location. |
Outputs: `grype-path`, `version`.
Supports `amd64` and `arm64`. Cosign is checksum-pinned per architecture; Grype
is verified against its keyless-signed checksum file.
### `scan-image`
| Input | Default | Notes |
| --- | --- | --- |
| `image` | — (required) | Tag or digest to scan. |
| `report-path` | `grype.json` | Raw JSON report location, relative to the workspace. |
| `fail-on-fixable` | `true` | Set `false` to always pass and inspect outputs instead. |
Outputs: `fixable-count`, `total-count`, `report-path`.
The step exits non-zero when fixable High/Critical findings exist and
`fail-on-fixable` is `true`. Combine with `continue-on-error: true` and
`steps.<id>.outcome` to implement notify-then-fail, as above.
## Versioning
`install-grype` centralises the Cosign and Grype versions. Bump them here,
deliberately, and tag a new release (`v1`, `v2`, ...) so callers can pin.
## Notes
- The `scan-image` action writes the raw report to the workspace; the caller's
notification step can keep using the same `grype.json` it already expects.
- Nothing here reads or writes secrets. Tokens stay in the calling workflow.
+110
View File
@@ -0,0 +1,110 @@
name: Install Grype
description: >-
Install a pinned, signature-verified Grype into a job-local directory and add
it to PATH. Also bootstraps the pinned Cosign used to verify Grype's checksums.
# Mechanism only. The caller decides what to scan and what to do with the result.
inputs:
grype-version:
description: Grype release tag, including the leading "v".
required: false
default: v0.110.0
cosign-version:
description: Cosign release tag used to verify Grype's signed checksums.
required: false
default: v3.0.5
install-dir:
description: >-
Directory to install the binaries into. Defaults to a job-local directory
that is added to PATH.
required: false
default: ''
outputs:
grype-path:
description: Absolute path to the installed grype binary.
value: ${{ steps.install.outputs.grype-path }}
version:
description: Installed grype version string.
value: ${{ steps.install.outputs.version }}
runs:
using: composite
steps:
- id: install
shell: bash
env:
INPUT_GRYPE_VERSION: ${{ inputs.grype-version }}
INPUT_COSIGN_VERSION: ${{ inputs.cosign-version }}
INPUT_INSTALL_DIR: ${{ inputs.install-dir }}
run: |
set -euo pipefail
grype_version="${INPUT_GRYPE_VERSION:?grype-version is required}"
cosign_version="${INPUT_COSIGN_VERSION:?cosign-version is required}"
install_dir="${INPUT_INSTALL_DIR:-${RUNNER_TEMP:-/tmp}/grype-bin}"
# Pinned, checksum-verified Cosign bootstrap binaries. Bump deliberately.
case "$(uname -m)" in
x86_64|amd64)
arch=amd64
cosign_sha=db15cc99e6e4837daabab023742aaddc3841ce57f193d11b7c3e06c8003642b2
;;
aarch64|arm64)
arch=arm64
cosign_sha=d098f3168ae4b3aa70b4ca78947329b953272b487727d1722cb3cb098a1a20ab
;;
*)
echo "Unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac
mkdir -p "$install_dir"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
cd "$work"
# Bootstrap Cosign: pinned by checksum before it is trusted.
curl --fail --location --retry 3 --output cosign \
"https://github.com/sigstore/cosign/releases/download/${cosign_version}/cosign-linux-${arch}"
printf '%s %s\n' "$cosign_sha" cosign | sha256sum --check --status -
chmod 0755 cosign
version_no_v="${grype_version#v}"
tarball="grype_${version_no_v}_linux_${arch}.tar.gz"
base="https://github.com/anchore/grype/releases/download/${grype_version}"
curl --fail --location --retry 3 --output "$tarball" "${base}/${tarball}"
curl --fail --location --retry 3 --output checksums.txt \
"${base}/grype_${version_no_v}_checksums.txt"
curl --fail --location --retry 3 --output checksums.txt.sig \
"${base}/grype_${version_no_v}_checksums.txt.sig"
curl --fail --location --retry 3 --output checksums.txt.pem \
"${base}/grype_${version_no_v}_checksums.txt.pem"
# Verify Grype's signed checksum file with keyless Cosign.
./cosign verify-blob \
--signature checksums.txt.sig \
--certificate checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/grype" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
checksums.txt
check="$(grep " ${tarball}$" checksums.txt)"
[ -n "$check" ] || { echo "No checksum entry for ${tarball}" >&2; exit 1; }
printf '%s\n' "$check" | sha256sum --check --status -
tar -xzf "$tarball"
install -m 0755 grype "$install_dir/grype"
echo "$install_dir" >> "${GITHUB_PATH:-/dev/null}"
version="$("$install_dir/grype" version 2>/dev/null | awk '/^Version:/{print $2; exit}')"
{
echo "grype-path=$install_dir/grype"
echo "version=${version}"
} >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Installed grype ${version:-$grype_version} at $install_dir/grype"
+98
View File
@@ -0,0 +1,98 @@
name: Scan Image
description: >-
Scan a container image with Grype and fail the step on fixable High/Critical
findings. Writes the raw JSON report so the caller can notify or act on it.
# Mechanism only. The caller keeps the policy: notify, fail, block a push, etc.
#
# Intended to be used with `continue-on-error: true` so the caller can inspect
# `steps.<id>.outcome` and decide what happens next.
inputs:
image:
description: Image reference (tag or digest) to scan.
required: true
report-path:
description: Where to write the raw Grype JSON report, relative to the workspace.
required: false
default: grype.json
fail-on-fixable:
description: Fail the step when fixable High/Critical findings exist.
required: false
default: 'true'
outputs:
fixable-count:
description: Number of fixable High/Critical findings.
value: ${{ steps.scan.outputs.fixable-count }}
total-count:
description: Total number of findings reported by Grype.
value: ${{ steps.scan.outputs.total-count }}
report-path:
description: Path to the raw Grype JSON report.
value: ${{ steps.scan.outputs.report-path }}
runs:
using: composite
steps:
- id: scan
shell: bash
env:
INPUT_IMAGE: ${{ inputs.image }}
INPUT_REPORT_PATH: ${{ inputs.report-path }}
INPUT_FAIL_ON_FIXABLE: ${{ inputs.fail-on-fixable }}
run: |
set -euo pipefail
image="${INPUT_IMAGE:?image is required}"
report="${INPUT_REPORT_PATH:-grype.json}"
fail_on_fixable="${INPUT_FAIL_ON_FIXABLE:-true}"
grype "$image" -o json > "$report"
echo "Fixable HIGH/CRITICAL vulnerabilities:"
jq -r '
.matches[]
| select(
(
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
and
(
(.vulnerability.fix.versions | length) > 0
)
)
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
' "$report"
fixable="$(jq '
[
.matches[]
| select(
(
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
and
(
(.vulnerability.fix.versions | length) > 0
)
)
]
| length
' "$report")"
total="$(jq '.matches | length' "$report")"
{
echo "fixable-count=${fixable}"
echo "total-count=${total}"
echo "report-path=${report}"
} >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Fixable HIGH/CRITICAL count: ${fixable} (total findings: ${total})"
if [ "$fail_on_fixable" = "true" ] && [ "$fixable" != "0" ]; then
echo "::error::Grype found ${fixable} fixable HIGH/CRITICAL vulnerabilities."
exit 1
fi