Add install-syft, generalize scan-image (target/severities), add self-CI and governance
CI / validate (push) Failing after 11s
CI / smoke (push) Failing after 8s

This commit is contained in:
mdaleo404 committed 2026-10-10 13:13:07 +01:00
1 parent 259880fca6
commit e644f1f4b0
5 files changed
+321 -26

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
name: validate
runs-on: running-man
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Validate action metadata (YAML)
run: |
set -euo pipefail
status=0
for f in install-grype/action.yml install-syft/action.yml scan-image/action.yml; do
if [ ! -f "$f" ]; then
echo "::error::missing $f"
status=1
continue
fi
python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" "$f"
echo "OK $f"
done
exit "$status"
- name: Shellcheck action scripts (best effort)
run: |
set -euo pipefail
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck not installed on the runner; skipping."
exit 0
fi
python3 - <<'PY' > /tmp/action-runblocks.sh
import glob, sys, yaml
for path in sorted(glob.glob('*/action.yml')):
doc = yaml.safe_load(open(path))
for step in doc.get('runs', {}).get('steps', []):
run = step.get('run')
if run:
sys.stdout.write(run.rstrip() + "\n\n")
PY
shellcheck -s bash -S error /tmp/action-runblocks.sh
smoke:
name: smoke
runs-on: running-man
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# Exercise the actions from the working tree, not a published tag.
- name: Install Grype
uses: https://git.sysmd.uk/mdaleo404/actions/install-grype
- name: Install Syft
uses: https://git.sysmd.uk/mdaleo404/actions/install-syft
- name: Verify installs
run: |
set -euo pipefail
grype version
syft version
- name: Generate SBOM fixture
run: |
set -euo pipefail
syft dir:. -o json > sbom.json
echo "Packages discovered: $(jq -r '.artifacts | length' sbom.json)"
- name: Scan SBOM (generalized path, non-failing)
uses: https://git.sysmd.uk/mdaleo404/actions/scan-image
with:
target: sbom:sbom.json
fail-on-fixable: 'false'
- name: Assert report and outputs
run: |
set -euo pipefail
test -s grype.json
jq -e '.matches | type == "array"' grype.json
echo "total findings: $(jq '.matches | length' grype.json)"
+5
View File
@@ -0,0 +1,5 @@
# Every path is owned by the maintainer. This repository is public and its
# actions run with consumer secrets, so require review before merge and restrict
# who can move release tags (configured in the instance, not here).
* @mdaleo404
+57 -6
View File
@@ -11,7 +11,8 @@ whether a finding notifies, fails the run, blocks a push, or blocks a deploy.
| 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. |
| `install-syft` | Installs a pinned, signature-verified Syft the same way, for SBOM generation. |
| `scan-image` | Scans an image, directory, or SBOM with Grype and fails the step on fixable findings at the configured severities; writes the raw JSON report. |
## Usage
@@ -75,6 +76,27 @@ jobs:
run: docker push "${IMAGE}"
```
### SBOM / directory scan
For a repository rather than an image, generate an SBOM with Syft and scan it
with the same action by passing `target`:
```yaml
- uses: https://git.sysmd.uk/mdaleo404/actions/install-grype@v1
- uses: https://git.sysmd.uk/mdaleo404/actions/install-syft@v1
- name: Generate SBOM
run: syft dir:. -o json > sbom.json
- name: Scan SBOM
id: scan
continue-on-error: true
uses: https://git.sysmd.uk/mdaleo404/actions/scan-image@v1
with:
target: sbom:sbom.json
severities: Medium,High,Critical
```
## Interface
### `install-grype`
@@ -90,24 +112,53 @@ Outputs: `grype-path`, `version`.
Supports `amd64` and `arm64`. Cosign is checksum-pinned per architecture; Grype
is verified against its keyless-signed checksum file.
### `install-syft`
| Input | Default | Notes |
| --- | --- | --- |
| `syft-version` | `v1.42.3` | Release tag including `v`. |
| `cosign-version` | `v3.0.5` | Cosign used only to verify Syft's checksums. |
| `install-dir` | job-local dir on `PATH` | Override to choose the install location. |
Outputs: `syft-path`, `version`.
Supports `amd64` and `arm64`, verified the same way as Grype.
### `scan-image`
| Input | Default | Notes |
| --- | --- | --- |
| `image` | — (required) | Tag or digest to scan. |
| `image` | — | Image reference to scan. Required unless `target` is set. |
| `target` | — | Grype target, e.g. `sbom:sbom.json` or `dir:.`. Overrides `image` when set. |
| `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. |
| `severities` | `High,Critical` | Comma-separated severities that count as findings. |
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
The step exits non-zero when fixable findings exist at the selected severities
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.
`install-grype` and `install-syft` centralise the Cosign, Grype, and Syft
versions. Bump them here, deliberately, and tag a new release (`v1`, `v2`, ...)
so callers can pin.
## Consuming safely
This repository is **public**, and every action here runs inside the caller's
job — with the caller's secrets, on the caller's runner. Treat a merge here as a
release to every consumer:
- **Pin a commit SHA, not a moving ref.** `@v1` can be moved; a full SHA cannot.
- Keep secrets in the calling workflow; nothing here reads or writes secrets.
- Installs verify their payloads against keyless Cosign signatures (and pinned
Cosign checksums) before use, fetching only from
`github.com/sigstore/cosign` and `github.com/anchore/{grype,syft}`.
- Report suspected issues privately to the maintainer (see `CODEOWNERS`) rather
than opening a public issue.
## Notes
+110
View File
@@ -0,0 +1,110 @@
name: Install Syft
description: >-
Install a pinned, signature-verified Syft into a job-local directory and add
it to PATH. Also bootstraps the pinned Cosign used to verify Syft's checksums.
# Mechanism only. The caller decides what to scan and what to do with the result.
inputs:
syft-version:
description: Syft release tag, including the leading "v".
required: false
default: v1.42.3
cosign-version:
description: Cosign release tag used to verify Syft's signed checksums.
required: false
default: v3.0.5
install-dir:
description: >-
Directory to install the binary into. Defaults to a job-local directory
that is added to PATH.
required: false
default: ''
outputs:
syft-path:
description: Absolute path to the installed syft binary.
value: ${{ steps.install.outputs.syft-path }}
version:
description: Installed syft version string.
value: ${{ steps.install.outputs.version }}
runs:
using: composite
steps:
- id: install
shell: bash
env:
INPUT_SYFT_VERSION: ${{ inputs.syft-version }}
INPUT_COSIGN_VERSION: ${{ inputs.cosign-version }}
INPUT_INSTALL_DIR: ${{ inputs.install-dir }}
run: |
set -euo pipefail
syft_version="${INPUT_SYFT_VERSION:?syft-version is required}"
cosign_version="${INPUT_COSIGN_VERSION:?cosign-version is required}"
install_dir="${INPUT_INSTALL_DIR:-${RUNNER_TEMP:-/tmp}/syft-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="${syft_version#v}"
tarball="syft_${version_no_v}_linux_${arch}.tar.gz"
base="https://github.com/anchore/syft/releases/download/${syft_version}"
curl --fail --location --retry 3 --output "$tarball" "${base}/${tarball}"
curl --fail --location --retry 3 --output checksums.txt \
"${base}/syft_${version_no_v}_checksums.txt"
curl --fail --location --retry 3 --output checksums.txt.sig \
"${base}/syft_${version_no_v}_checksums.txt.sig"
curl --fail --location --retry 3 --output checksums.txt.pem \
"${base}/syft_${version_no_v}_checksums.txt.pem"
# Verify Syft'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/syft" \
--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 syft "$install_dir/syft"
echo "$install_dir" >> "${GITHUB_PATH:-/dev/null}"
version="$("$install_dir/syft" version 2>/dev/null | awk '/^Version:/{print $2; exit}')"
{
echo "syft-path=$install_dir/syft"
echo "version=${version}"
} >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Installed syft ${version:-$syft_version} at $install_dir/syft"
+48 -20
View File
@@ -1,7 +1,8 @@
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.
Scan a container image, directory, or SBOM with Grype and fail the step on
fixable findings at the configured severities. 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.
#
@@ -10,16 +11,29 @@ description: >-
inputs:
image:
description: Image reference (tag or digest) to scan.
required: true
description: >-
Image reference (tag or digest) to scan. Required unless `target` is set.
required: false
default: ''
target:
description: >-
Grype target to scan instead of an image, e.g. "sbom:sbom.json" or "dir:.".
Takes precedence over `image` when set.
required: false
default: ''
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.
description: Fail the step when fixable findings exist at the selected severities.
required: false
default: 'true'
severities:
description: >-
Comma-separated severities that count as findings (e.g. High,Critical).
required: false
default: High,Critical
outputs:
fixable-count:
@@ -39,25 +53,42 @@ runs:
shell: bash
env:
INPUT_IMAGE: ${{ inputs.image }}
INPUT_TARGET: ${{ inputs.target }}
INPUT_REPORT_PATH: ${{ inputs.report-path }}
INPUT_FAIL_ON_FIXABLE: ${{ inputs.fail-on-fixable }}
INPUT_SEVERITIES: ${{ inputs.severities }}
run: |
set -euo pipefail
image="${INPUT_IMAGE:?image is required}"
image="${INPUT_IMAGE:-}"
target="${INPUT_TARGET:-}"
report="${INPUT_REPORT_PATH:-grype.json}"
fail_on_fixable="${INPUT_FAIL_ON_FIXABLE:-true}"
severities="${INPUT_SEVERITIES:-High,Critical}"
grype "$image" -o json > "$report"
if [ -n "$target" ]; then
scan_ref="$target"
elif [ -n "$image" ]; then
scan_ref="$image"
else
echo "image or target is required" >&2
exit 1
fi
echo "Fixable HIGH/CRITICAL vulnerabilities:"
jq -r '
# Normalise the comma-separated severity list into a jq array once.
sevs_json="$(printf '%s' "$severities" \
| tr ',' '\n' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
| grep -v '^$' \
| jq -Rs 'split("\n") | map(select(length > 0))')"
grype "$scan_ref" -o json > "$report"
echo "Fixable ${severities} vulnerabilities:"
jq -r --argjson sevs "$sevs_json" '
.matches[]
| select(
(
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
((.vulnerability.severity) as $s | $sevs | index($s))
and
(
(.vulnerability.fix.versions | length) > 0
@@ -66,14 +97,11 @@ runs:
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
' "$report"
fixable="$(jq '
fixable="$(jq --argjson sevs "$sevs_json" '
[
.matches[]
| select(
(
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
((.vulnerability.severity) as $s | $sevs | index($s))
and
(
(.vulnerability.fix.versions | length) > 0
@@ -90,9 +118,9 @@ runs:
echo "report-path=${report}"
} >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Fixable HIGH/CRITICAL count: ${fixable} (total findings: ${total})"
echo "Fixable ${severities} count: ${fixable} (total findings: ${total})"
if [ "$fail_on_fixable" = "true" ] && [ "$fixable" != "0" ]; then
echo "::error::Grype found ${fixable} fixable HIGH/CRITICAL vulnerabilities."
echo "::error::Grype found ${fixable} fixable ${severities} vulnerabilities."
exit 1
fi