Add reusable Grype composite actions
This commit is contained in:
commit
259880fca6
4 files changed
+325
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
.opencode/
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user