diff --git a/.github/actions/cve-scan/README.md b/.github/actions/cve-scan/README.md
index cdedb137..5c90154a 100644
--- a/.github/actions/cve-scan/README.md
+++ b/.github/actions/cve-scan/README.md
@@ -1,6 +1,6 @@
# cve-scan
-Scans a container image for CVEs with a swappable scanner backend (Snyk ships first), gates optionally on severity, and reports via Slack, a short markdown summary, and the scanner's own SARIF for the Security tab.
+Scans a container image, or a dependency manifest such as a library's `go.mod`, for CVEs with a swappable scanner backend (Snyk ships first), gates optionally on severity, and reports via Slack, a short markdown summary, and the scanner's own SARIF for the Security tab.
Three failure modes are handled differently, on purpose:
@@ -8,7 +8,7 @@ Three failure modes are handled differently, on purpose:
|---------|----------------|---------------|
| **Findings** | Only when `block-on-findings: true` | CVEs at or above `severity-threshold`. The default is advisory (report only), matching the "non-gating in the first iteration" requirement this action was built against. |
| **Scanner error** | Never | The scan couldn't complete — registry failure, timeout, unparseable output. A flaky scanner must never block a release. Sets `scanner-error: true`. |
-| **Config error** | Always, regardless of `block-on-findings` | An authoring or provisioning mistake: an unrecognised `severity-threshold`, an invalid `image-ref` or `scanner`, or a scanner that can't be set up (no CLI, no credential). Not a scan result, so it is deliberately *not* reported as an inconclusive scan — a permanently broken setup that reports "inconclusive" forever pages nobody. |
+| **Config error** | Always, regardless of `block-on-findings` | An authoring or provisioning mistake: an unrecognised `severity-threshold`, an invalid `image-ref`, `source-path` or `scanner`, or a scanner that can't be set up (no CLI, no credential). Not a scan result, so it is deliberately *not* reported as an inconclusive scan — a permanently broken setup that reports "inconclusive" forever pages nobody. |
If you are wiring this into a release workflow on the advertised advisory-only posture, the third row is the surprising one: a typo'd threshold reds the job even with `block-on-findings: false`.
@@ -33,7 +33,7 @@ Be deliberate about `severity-threshold` on the scheduled sweep. Real images usu
| block-on-findings | string | false | `"false"` | When `true` (or `yes`/`1`/`on`), findings at or
above `severity-threshold` fail the job. When
`false` (default) or unrecognised, findings are
always reported but never fail the
job. A scanner error never fails
the job either way; a config
error always does — see README. |
| dockerfile-path | string | false | | Optional path to the Dockerfile that
built the image, passed to the
scanner as `--file`. This buys base-image
remediation advice only; it does not
change which vulnerabilities are found, since
application dependencies are scanned by default.
Leave empty when scanning a published
image with no checkout. If set,
it must exist. |
| enabled | string | false | `"true"` | Kill switch. Only an explicit falsey
value (`false`/`no`/`0`/`off`, any case) skips the scan, and
the skip is annotated rather than
silent. An unrecognised value scans anyway
and warns — resolving toward scanning
so a typo in a repo
variable can't quietly disable a security
control. |
-| image-ref | string | true | | Full registry reference to scan, e.g.
`ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. |
+| image-ref | string | false | | Full registry reference to scan, e.g.
`ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. Set this or
`source-path`, not both. |
| notify | string | false | `"true"` | Send a Slack notification on findings,
a scanner error, or a config
error. Accepts the same tolerant spellings
as `enabled`/`block-on-findings` (`yes`/`1`/`on`, `no`/`0`/`off`, any case); an unrecognised value
still notifies and warns. Also gated
by `notify-events`, which by default keeps
a `workflow_dispatch` or PR run silent
so testing the action can't spam
the channel. The Job Summary is
written either way. |
| notify-events | string | false | `"schedule,release"` | Comma-separated allowlist of `github.event_name` values permitted
to notify. Case- and whitespace-insensitive, and
entries match whole, so `release` does
not match `prerelease`. An empty value
warns and falls back to the
default. Note a reusable workflow sees
the *caller's* event name, so a
`workflow_call` callee must allow whatever triggered
the caller. |
| notify-findings | string | false | `"true"` | Post findings to Slack. `false` (or `no`/`0`/`off`)
keeps scanner and config errors posting
under `notify` but drops the findings
post, unless `block-on-findings` is on. An
unrecognised value still posts and warns. |
@@ -45,6 +45,7 @@ Be deliberate about `severity-threshold` on the scheduled sweep. Real images usu
| scanner-version | string | false | | Version of the scanner CLI to
install when it isn't already on
the runner. Empty installs the version
pinned in the adapter script, where
Renovate bumps it. The adapter checksum-verifies
whatever it installs. |
| severity-threshold | string | false | `"high"` | Minimum severity that counts toward gating:
`critical`, `high`, `medium`, or `low`. |
| slack-webhook-url | string | false | | Slack incoming webhook URL for the
ci-test-notify action. Required when `notify: true`. |
+| source-path | string | false | | Dependency manifest to scan instead of
an image, relative to the workspace,
e.g. `go.mod`. For a library that
ships no image. The caller checks
out the code and sets up
its toolchain first (for Go, `actions/setup-go`), because the
scanner resolves dependencies with it. Skips
the registry login and the pull.
Set this or `image-ref`, not both. |
@@ -201,6 +202,44 @@ Uploading SARIF to the Security tab (caller's choice — kept out of this action
The Snyk adapter namespaces each SARIF run with the image repository while preserving Snyk's run suffix. This keeps shared modules distinct across image repositories on the same ref. The repository comes from the caller's `image-ref`, so changing registry aliases starts a new Code Scanning category.
+## Scanning a library (source mode)
+
+A Go library ships no image, so there is nothing for `image-ref` to point at. Set `source-path` to its `go.mod` instead. The action then skips the registry login and the pull, and the Snyk adapter runs `snyk test --file=` in place of `snyk container test`. Gating, the report, Slack and SARIF work the same way; the summaries say `Source:` and name `//`.
+
+Set exactly one of `image-ref` and `source-path`. Both, or neither, is a config error. `source-path` must be a relative path to a file in the checkout, and `dockerfile-path` must stay empty.
+
+Snyk resolves Go modules with the Go toolchain, so check out the code and set up Go first:
+
+```yaml
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ security-events: write
+ steps:
+ - uses: actions/checkout@ # vX.Y.Z
+ with:
+ persist-credentials: false
+ - uses: actions/setup-go@ # vX.Y.Z
+ with:
+ go-version-file: go.mod
+ cache: false
+ - id: scan
+ uses: loft-sh/github-actions/.github/actions/cve-scan@cve-scan/v1
+ with:
+ source-path: go.mod
+ scanner-token: ${{ secrets.SNYK_TOKEN }}
+ slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_CI_SECURITY_SCANS }}
+ - uses: github/codeql-action/upload-sarif@ # vX.Y.Z
+ if: always() && steps.scan.outputs.sarif-path != ''
+ with:
+ sarif_file: ${{ steps.scan.outputs.sarif-path }}
+```
+
+`snyk test` lists a vulnerability once per dependency path, so source mode counts each one once. Its SARIF runs are keyed `Snyk/OpenSource//...`, so they never share a Code Scanning category with an image scan. A source scan sees module versions only: a library does not choose the Go standard library its consumers build with, so no standard library findings are expected.
+
## Suppressing a finding
Use Snyk's own [`.snyk` policy file](https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues). Snyk reads it from the working directory during the scan, so suppressed findings never reach this action and no input is needed:
@@ -219,7 +258,7 @@ Note `.snyk` does not *require* a reason or expiry the way a bespoke format coul
## Scanner adapters
-`scanner` selects `src/scanners/.sh`. Given `IMAGE_REF`, `DOCKERFILE_PATH`, `SCANNER_TOKEN` and `SCANNER_VERSION` in its environment, an adapter writes the canonical findings shape to the path in `FINDINGS_JSON` and exits:
+`scanner` selects `src/scanners/.sh`. Given `IMAGE_REF` or `SOURCE_PATH` (exactly one), `DOCKERFILE_PATH`, `SCANNER_TOKEN` and `SCANNER_VERSION` in its environment, an adapter writes the canonical findings shape to the path in `FINDINGS_JSON` and exits:
| Exit | Meaning | Effect on the job |
|------|---------|-------------------|
@@ -229,7 +268,7 @@ Note `.snyk` does not *require* a reason or expiry the way a bespoke format coul
The `2` case is why a missing prerequisite doesn't disappear: folded in with the inconclusive codes, an unusable scanner would report "scanner error" on every scheduled run forever while scanning nothing, and nothing about that outcome pages anyone.
-An adapter also **provisions its own tool** — deliberately the adapter's job and not a step in `action.yml`, which keeps vendor specifics out of the manifest. The image is already pulled locally by the time an adapter runs, so an adapter can assume the layers are available.
+An adapter also **provisions its own tool** — deliberately the adapter's job and not a step in `action.yml`, which keeps vendor specifics out of the manifest. In image mode the image is already pulled locally by the time an adapter runs, so an adapter can assume the layers are available.
Everything downstream (gating, the report, the summary) reads only this shape, never a tool's native output:
diff --git a/.github/actions/cve-scan/action.yml b/.github/actions/cve-scan/action.yml
index 3a3fe2ed..0831f3de 100644
--- a/.github/actions/cve-scan/action.yml
+++ b/.github/actions/cve-scan/action.yml
@@ -1,13 +1,18 @@
name: 'cve-scan'
-description: 'Scan a container image for CVEs with a swappable scanner backend, gate optionally on severity, and report findings.'
+description: 'Scan a container image or a dependency manifest for CVEs with a swappable scanner backend, gate optionally on severity, and report findings.'
branding:
icon: "shield"
color: "red"
inputs:
image-ref:
- description: "Full registry reference to scan, e.g. `ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`."
- required: true
+ description: "Full registry reference to scan, e.g. `ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. Set this or `source-path`, not both."
+ required: false
+ default: ""
+ source-path:
+ description: "Dependency manifest to scan instead of an image, relative to the workspace, e.g. `go.mod`. For a library that ships no image. The caller checks out the code and sets up its toolchain first (for Go, `actions/setup-go`), because the scanner resolves dependencies with it. Skips the registry login and the pull. Set this or `image-ref`, not both."
+ required: false
+ default: ""
dockerfile-path:
description: "Optional path to the Dockerfile that built the image, passed to the scanner as `--file`. This buys base-image remediation advice only; it does not change which vulnerabilities are found, since application dependencies are scanned by default. Leave empty when scanning a published image with no checkout. If set, it must exist."
required: false
@@ -121,7 +126,7 @@ runs:
run: echo "::add-mask::${REGISTRY_PASSWORD}"
- name: Log in to the image registry
- if: inputs.registry-password != '' && !contains(fromJSON('["false","no","0","off"]'), inputs.enabled)
+ if: inputs.registry-password != '' && inputs.source-path == '' && !contains(fromJSON('["false","no","0","off"]'), inputs.enabled)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
# A registry that can't be reached is inconclusive, not a finding, so this
# must not fail the caller's job — run.sh classifies the pull failure that
@@ -132,12 +137,13 @@ runs:
username: ${{ inputs.registry-username }}
password: ${{ inputs.registry-password }}
- - name: Scan image for vulnerabilities
+ - name: Scan for vulnerabilities
id: scan
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
IMAGE_REF: ${{ inputs.image-ref }}
+ SOURCE_PATH: ${{ inputs.source-path }}
DOCKERFILE_PATH: ${{ inputs.dockerfile-path }}
SCANNER: ${{ inputs.scanner }}
SCANNER_TOKEN: ${{ inputs.scanner-token }}
@@ -206,7 +212,7 @@ runs:
# Summary is written either way, so nothing is lost when this is skipped.
continue-on-error: true
with:
- test-name: "cve-scan: ${{ inputs.image-ref }}"
+ test-name: "cve-scan: ${{ inputs.image-ref || format('{0}/{1}', github.repository, inputs.source-path) }}"
# Every branch of this `if:` is a failure, so the status is a
# literal rather than a ternary that could only ever pick one value.
status: failure
diff --git a/.github/actions/cve-scan/run.sh b/.github/actions/cve-scan/run.sh
index c5b2d3af..5f8da307 100755
--- a/.github/actions/cve-scan/run.sh
+++ b/.github/actions/cve-scan/run.sh
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
-# Orchestrates one cve-scan run: kill switch, image pull, scanner adapter, then
-# process-findings.sh for gating and reporting. The four outcomes (disabled /
+# Orchestrates one cve-scan run: kill switch, image pull (image mode only),
+# scanner adapter, then process-findings.sh for gating and reporting. The four outcomes (disabled /
# scanner error / config error / completed) map onto the three failure modes
# documented in README.md, plus the disabled and completed cases.
# DOCKER_BIN is a test seam.
@@ -11,7 +11,11 @@ set -euo pipefail
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
: "${ACTION_PATH:?ACTION_PATH is required}"
-: "${IMAGE_REF:?IMAGE_REF is required}"
+
+# Exactly one of these is set: image-ref scans a published image, source-path
+# scans a dependency manifest in the checkout (a library with no image).
+IMAGE_REF="${IMAGE_REF:-}"
+export SOURCE_PATH="${SOURCE_PATH:-}"
SCANNER="${SCANNER:-snyk}"
# Exported so the adapter inherits the same value this script validated —
@@ -87,6 +91,16 @@ write_output() {
echo "$1=$2" >> "$GITHUB_OUTPUT"
}
+# What the summaries name. Source mode prefixes the repository, since a bare
+# go.mod says nothing in a shared Slack channel.
+if [ -n "$SOURCE_PATH" ] && [ -z "$IMAGE_REF" ]; then
+ SCAN_KIND=source
+ SCAN_TARGET="${GITHUB_REPOSITORY:+${GITHUB_REPOSITORY}/}${SOURCE_PATH}"
+else
+ SCAN_KIND=image
+ SCAN_TARGET="$IMAGE_REF"
+fi
+
# Written here, before any exit path, because action.yml's Slack step used to
# compare inputs.notify to the literal string 'true' with no normalisation at
# all — unlike its two siblings above, since notify never reached this script.
@@ -117,7 +131,7 @@ fi
# noticing, and it is otherwise visible only in Annotations.
write_step_summary() {
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
- printf '# CVE Scan — %s\n\n%s\n\n' "$IMAGE_REF" "$1" >> "$GITHUB_STEP_SUMMARY"
+ printf '# CVE Scan — %s\n\n%s\n\n' "$SCAN_TARGET" "$1" >> "$GITHUB_STEP_SUMMARY"
fi
}
@@ -136,7 +150,7 @@ finish_with_no_result() {
done
write_output report-path ""
write_output sarif-path ""
- write_output summary "cve-scan — \`${IMAGE_REF}\`: ${detail}"
+ write_output summary "cve-scan — \`${SCAN_TARGET}\`: ${detail}"
write_step_summary "${icon} **No scan was performed** — ${detail}."
exit 0
}
@@ -148,7 +162,7 @@ finish_with_no_result() {
# exits below drift.
finish_with_config_error() {
local detail="$1"
- write_output summary "cve-scan — \`${IMAGE_REF}\`: ${detail}"
+ write_output summary "cve-scan — \`${SCAN_TARGET}\`: ${detail}"
write_step_summary ":x: **Configuration error** — ${detail}."
exit 1
}
@@ -164,7 +178,7 @@ finish_with_config_error() {
# finish_with_no_result before that site ever runs. Checking all three here,
# before anything can be written, closes every reachable path at once and
# covers whatever calls write_output next.
-for _var in IMAGE_REF SCANNER DOCKERFILE_PATH; do
+for _var in IMAGE_REF SOURCE_PATH SCANNER DOCKERFILE_PATH; do
case "${!_var-}" in
*[[:cntrl:]]*)
echo "::error::${_var} must not contain control characters" >&2
@@ -183,19 +197,46 @@ fi
# --- 2. Validate config before spending a scan -------------------------------
# All cheap, none needs the scanner: a typo shouldn't cost a full pull and scan.
+if [ -n "$IMAGE_REF" ] && [ -n "$SOURCE_PATH" ]; then
+ echo "::error::set image-ref or source-path, not both" >&2
+ finish_with_config_error "image-ref and source-path are both set. No scan was performed"
+fi
+
+if [ "$SCAN_KIND" = source ]; then
+ # Relative, inside the checkout: the SARIF locations point at this path, and
+ # code scanning only places alerts on files in the repository.
+ case "$SOURCE_PATH" in
+ /* | ../* | */../* | *[[:space:]]*)
+ echo "::error::source-path must be a relative path inside the checkout with no whitespace (got '${SOURCE_PATH}')" >&2
+ finish_with_config_error "source-path is not a relative path inside the checkout. No scan was performed"
+ ;;
+ esac
+ if [ ! -f "$SOURCE_PATH" ]; then
+ echo "::error::source-path '${SOURCE_PATH}' does not exist; check out the code before cve-scan" >&2
+ finish_with_config_error "source-path '${SOURCE_PATH}' does not exist. No scan was performed"
+ fi
+ # base-image advice has no meaning without an image.
+ if [ -n "$DOCKERFILE_PATH" ]; then
+ echo "::error::dockerfile-path only applies to image-ref; leave it empty with source-path" >&2
+ finish_with_config_error "dockerfile-path is set with source-path. No scan was performed"
+ fi
+fi
+
# A container reference never legitimately contains whitespace, and image-ref
# is the one value here that comes from outside the action.
-case "$IMAGE_REF" in
- '' | *[[:space:]]*)
- echo "::error::image-ref must be a non-empty reference with no whitespace (got '${IMAGE_REF}')" >&2
- # Written by hand rather than via finish_with_config_error: IMAGE_REF is
- # the bad value here, and a newline in it would forge extra GITHUB_OUTPUT
- # entries off the summary line.
- write_output summary "cve-scan: image-ref is not a valid image reference"
- write_step_summary ":x: **Configuration error** — image-ref is not a valid image reference. No scan was performed."
- exit 1
- ;;
-esac
+if [ "$SCAN_KIND" = image ]; then
+ case "$IMAGE_REF" in
+ '' | *[[:space:]]*)
+ echo "::error::image-ref must be a non-empty reference with no whitespace (got '${IMAGE_REF}'), or set source-path to scan a checkout" >&2
+ # Written by hand rather than via finish_with_config_error: IMAGE_REF is
+ # the bad value here, and a newline in it would forge extra GITHUB_OUTPUT
+ # entries off the summary line.
+ write_output summary "cve-scan: image-ref is not a valid image reference"
+ write_step_summary ":x: **Configuration error** — image-ref is not a valid image reference. No scan was performed."
+ exit 1
+ ;;
+ esac
+fi
# Adapters are in-repo and enumerable, so resolve by name rather than
# interpolating caller input into a path that then gets executed.
@@ -240,22 +281,25 @@ SARIF_PATH="${WORKDIR}/findings.sarif"
# stops scanning, with no signal anywhere. `timeout` exits 124, which lands in
# the "nonzero" branch below and reports as inconclusive — the right call for
# a pull that didn't finish.
-PULL_TIMEOUT=300
-TIMEOUT_BIN="${TIMEOUT_BIN:-timeout}"
-if command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
- PULL_CMD=("$TIMEOUT_BIN" "$PULL_TIMEOUT" "$DOCKER_BIN")
-else
- echo "::warning::'${TIMEOUT_BIN}' not available — running the pull without a time bound"
- PULL_CMD=("$DOCKER_BIN")
-fi
+# Source mode reads the checkout, so there is nothing to pull.
+if [ "$SCAN_KIND" = image ]; then
+ PULL_TIMEOUT=300
+ TIMEOUT_BIN="${TIMEOUT_BIN:-timeout}"
+ if command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
+ PULL_CMD=("$TIMEOUT_BIN" "$PULL_TIMEOUT" "$DOCKER_BIN")
+ else
+ echo "::warning::'${TIMEOUT_BIN}' not available — running the pull without a time bound"
+ PULL_CMD=("$DOCKER_BIN")
+ fi
-echo "Pulling ${IMAGE_REF}"
-PULL_EXIT=0
-"${PULL_CMD[@]}" pull "$IMAGE_REF" || PULL_EXIT=$?
-if [ "$PULL_EXIT" -ne 0 ]; then
- finish_with_no_result true \
- "cve-scan could not pull '${IMAGE_REF}' (exit ${PULL_EXIT}) — treating as inconclusive, not a finding" \
- "could not pull the image (exit ${PULL_EXIT}), see job log" ":warning:"
+ echo "Pulling ${IMAGE_REF}"
+ PULL_EXIT=0
+ "${PULL_CMD[@]}" pull "$IMAGE_REF" || PULL_EXIT=$?
+ if [ "$PULL_EXIT" -ne 0 ]; then
+ finish_with_no_result true \
+ "cve-scan could not pull '${IMAGE_REF}' (exit ${PULL_EXIT}) — treating as inconclusive, not a finding" \
+ "could not pull the image (exit ${PULL_EXIT}), see job log" ":warning:"
+ fi
fi
# --- 4. Run the adapter ------------------------------------------------------
@@ -286,6 +330,8 @@ fi
# bought nothing but a second parser.
PROCESS_EXIT=0
FINDINGS_JSON="$FINDINGS_JSON" \
+ SCAN_TARGET="$SCAN_TARGET" \
+ SCAN_KIND="$SCAN_KIND" \
REPORT_PATH="${WORKDIR}/report.md" \
BLOCK_ON_FINDINGS="$BLOCK_ON_FINDINGS" \
"${ACTION_PATH}/src/process-findings.sh" || PROCESS_EXIT=$?
diff --git a/.github/actions/cve-scan/src/process-findings.sh b/.github/actions/cve-scan/src/process-findings.sh
index 669ecb6b..2f7de6d0 100755
--- a/.github/actions/cve-scan/src/process-findings.sh
+++ b/.github/actions/cve-scan/src/process-findings.sh
@@ -20,7 +20,19 @@ set -euo pipefail
: "${FINDINGS_JSON:?FINDINGS_JSON is required}"
: "${REPORT_PATH:?REPORT_PATH is required}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
-: "${IMAGE_REF:?IMAGE_REF is required}"
+# SCAN_TARGET names what was scanned; IMAGE_REF stays accepted for callers
+# that run this script on its own. SCAN_KIND picks the label: image or source.
+SCAN_TARGET="${SCAN_TARGET:-${IMAGE_REF:-}}"
+: "${SCAN_TARGET:?SCAN_TARGET or IMAGE_REF is required}"
+SCAN_KIND="${SCAN_KIND:-image}"
+case "$SCAN_KIND" in
+ image) TARGET_LABEL=Image ;;
+ source) TARGET_LABEL=Source ;;
+ *)
+ echo "::error::SCAN_KIND must be image or source (got '${SCAN_KIND}')" >&2
+ exit 2
+ ;;
+esac
SEVERITY_THRESHOLD="${SEVERITY_THRESHOLD:-high}"
BLOCK_ON_FINDINGS="${BLOCK_ON_FINDINGS:-false}"
@@ -119,7 +131,7 @@ fi
# Scanner strings become mrkdwn text in ci-test-notify. Strip controls, encode
# Slack's entity delimiters, and bound each field before it reaches a webhook.
-SLACK_IMAGE=$(jq -nr --arg value "$IMAGE_REF" '
+SLACK_IMAGE=$(jq -nr --arg value "$SCAN_TARGET" '
$value
| gsub("[[:cntrl:]]"; " ")
| gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">")
@@ -208,8 +220,8 @@ fi
# slack-details is a declared output, so a caller can read it on a scan with
# nothing at or above the threshold, where an unconditional heading promised a
# list and then showed none.
-SLACK_DETAILS=$(printf 'Image: %s\nThreshold: %s · %s' \
- "$SLACK_IMAGE" "$SEVERITY_THRESHOLD" "$BLOCKING_CONTEXT")
+SLACK_DETAILS=$(printf '%s: %s\nThreshold: %s · %s' \
+ "$TARGET_LABEL" "$SLACK_IMAGE" "$SEVERITY_THRESHOLD" "$BLOCKING_CONTEXT")
if [ "$THRESHOLD_COUNT" -gt 0 ]; then
SLACK_DETAILS="${SLACK_DETAILS}"$'\n\n'"Findings at or above threshold"$'\n\n'"${PREVIEW_GROUPS}"
fi
@@ -227,20 +239,21 @@ write_multiline_output() {
}
RESULT_JSON=$(jq -cn \
- --arg image "$IMAGE_REF" \
+ --arg kind "$SCAN_KIND" \
+ --arg target "$SCAN_TARGET" \
--argjson critical "$CRITICAL_COUNT" \
--argjson high "$HIGH_COUNT" \
--argjson medium "$MEDIUM_COUNT" \
--argjson low "$LOW_COUNT" \
- '{image: $image, critical: $critical, high: $high, medium: $medium, low: $low, status: "completed"}')
+ '{($kind): $target, critical: $critical, high: $high, medium: $medium, low: $low, status: "completed"}')
# --- Markdown report ---------------------------------------------------------
{
- echo "# CVE Scan — ${IMAGE_REF} — ${TODAY}"
+ echo "# CVE Scan — ${SCAN_TARGET} — ${TODAY}"
echo
echo "| Field | Value |"
echo "|-------|-------|"
- echo "| Image | \`${IMAGE_REF}\` |"
+ echo "| ${TARGET_LABEL} | \`${SCAN_TARGET}\` |"
echo "| Trigger | ${TRIGGER_CONTEXT} |"
echo "| Severity threshold | ${SEVERITY_THRESHOLD} |"
echo
@@ -270,6 +283,6 @@ RESULT_JSON=$(jq -cn \
echo "slack-title=${SLACK_TITLE}"
echo "report-path=${REPORT_PATH}"
echo "result-json=${RESULT_JSON}"
- echo "summary=cve-scan — \`${IMAGE_REF}\` (${TRIGGER_CONTEXT}): critical=${CRITICAL_COUNT} high=${HIGH_COUNT} medium=${MEDIUM_COUNT} low=${LOW_COUNT}"
+ echo "summary=cve-scan — \`${SCAN_TARGET}\` (${TRIGGER_CONTEXT}): critical=${CRITICAL_COUNT} high=${HIGH_COUNT} medium=${MEDIUM_COUNT} low=${LOW_COUNT}"
} >> "$GITHUB_OUTPUT"
write_multiline_output slack-details "$SLACK_DETAILS"
diff --git a/.github/actions/cve-scan/src/scanners/snyk.sh b/.github/actions/cve-scan/src/scanners/snyk.sh
index 3359786f..6c80f923 100755
--- a/.github/actions/cve-scan/src/scanners/snyk.sh
+++ b/.github/actions/cve-scan/src/scanners/snyk.sh
@@ -15,9 +15,23 @@ set -euo pipefail
#
# SNYK_BIN, CURL_BIN and TIMEOUT_BIN are test seams.
-: "${IMAGE_REF:?IMAGE_REF is required}"
: "${FINDINGS_JSON:?FINDINGS_JSON is required}"
+# Image mode runs `snyk container test` on IMAGE_REF. Source mode runs
+# `snyk test` on the manifest at SOURCE_PATH. run.sh enforces exactly one.
+IMAGE_REF="${IMAGE_REF:-}"
+SOURCE_PATH="${SOURCE_PATH:-}"
+if [ -n "$SOURCE_PATH" ] && [ -z "$IMAGE_REF" ]; then
+ SCAN_KIND=source
+ SCAN_NAME="snyk test"
+elif [ -n "$IMAGE_REF" ] && [ -z "$SOURCE_PATH" ]; then
+ SCAN_KIND=image
+ SCAN_NAME="snyk container test"
+else
+ echo "::error::the snyk adapter needs exactly one of IMAGE_REF and SOURCE_PATH" >&2
+ exit 2
+fi
+
# No default here: run.sh owns it and exports the value it validated.
DOCKERFILE_PATH="${DOCKERFILE_PATH:-}"
SARIF_PATH="${SARIF_PATH:-}"
@@ -33,7 +47,7 @@ CURL_BIN="${CURL_BIN:-curl}"
TIMEOUT_BIN="${TIMEOUT_BIN:-timeout}"
SCAN_TIMEOUT=900
-# `snyk container test` cannot run unauthenticated. Exit 2 so a missing
+# Snyk cannot scan unauthenticated. Exit 2 so a missing
# credential fails the job instead of reading as an inconclusive scan.
if [ -z "$SCANNER_TOKEN" ]; then
echo "::error::scanner-token is empty — the snyk adapter needs a Snyk API token (map secrets.SNYK_TOKEN into scanner-token)" >&2
@@ -119,8 +133,13 @@ command -v "$SNYK_BIN" >/dev/null 2>&1 || install_snyk
# while `--sarif-file-output` is the standard artifact the caller uploads for
# GitHub code scanning. Writing the per-CVE table by hand was strictly worse
# than the Security tab that SARIF feeds.
-ARGS=(container test --severity-threshold=low --json "$IMAGE_REF")
-if [ -n "$DOCKERFILE_PATH" ]; then
+if [ "$SCAN_KIND" = source ]; then
+ # snyk resolves Go modules with the go toolchain, so the caller sets up Go.
+ ARGS=(test "--file=${SOURCE_PATH}" --severity-threshold=low --json)
+else
+ ARGS=(container test --severity-threshold=low --json "$IMAGE_REF")
+fi
+if [ -n "$DOCKERFILE_PATH" ] && [ "$SCAN_KIND" = image ]; then
ARGS+=("--file=${DOCKERFILE_PATH}")
fi
if [ -n "$SARIF_PATH" ]; then
@@ -159,12 +178,12 @@ if [ -s "$RAW_STDERR_FILE" ]; then
fi
if [ "$EXIT_CODE" -eq 124 ]; then
- echo "snyk container test timed out after ${SCAN_TIMEOUT}s" >&2
+ echo "${SCAN_NAME} timed out after ${SCAN_TIMEOUT}s" >&2
exit 1
fi
if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 1 ]; then
- echo "snyk container test exited ${EXIT_CODE} (expected 0 or 1)" >&2
+ echo "${SCAN_NAME} exited ${EXIT_CODE} (expected 0 or 1)" >&2
# Under --json snyk puts its error on stdout, so stderr is empty here and
# returning without this left no statement of the cause anywhere. Only the
# error field is printed: the payload can also carry scan results, and a
@@ -177,13 +196,15 @@ if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 1 ]; then
fi
if ! jq -e . "$RAW_JSON_FILE" >/dev/null 2>&1; then
- echo "snyk container test did not produce valid JSON (exit ${EXIT_CODE}):" >&2
+ echo "${SCAN_NAME} did not produce valid JSON (exit ${EXIT_CODE}):" >&2
cat "$RAW_JSON_FILE" >&2
exit 1
fi
-jq '
- ((.vulnerabilities // [])
+# `snyk test` lists a vulnerability once per dependency path, so source mode
+# dedupes by id. Image mode keeps its counting unchanged.
+jq --arg kind "$SCAN_KIND" '
+ ((.vulnerabilities // [] | if $kind == "source" then unique_by(.id) else . end)
+ ([.applications[]?.vulnerabilities[]?] | unique_by(.id))
) as $all
| {findings: [$all[] | {
@@ -210,6 +231,8 @@ jq '
# which fails the upload too. Code scanning never reads `fixes`, so drop them.
#
# Snyk's IDs omit the image repository, so shared modules collide across scans.
+# Source mode is keyed by the manifest path, so two manifests in one repository
+# keep separate analyses and never share a category with an image scan.
if [ -n "$SARIF_PATH" ] && [ -s "$SARIF_PATH" ]; then
SARIF_IMAGE_REF="${IMAGE_REF%@*}"
SARIF_IMAGE_PATH="$SARIF_IMAGE_REF"
@@ -221,7 +244,13 @@ if [ -n "$SARIF_PATH" ] && [ -s "$SARIF_PATH" ]; then
fi
fi
SARIF_IMAGE_PATH="${SARIF_IMAGE_PATH%:*}"
- if jq --arg image_name "$SARIF_IMAGE_PATH" '
+ SARIF_PREFIX="Snyk/Container/"
+ SARIF_NAME="$SARIF_IMAGE_PATH"
+ if [ "$SCAN_KIND" = source ]; then
+ SARIF_PREFIX="Snyk/OpenSource/"
+ SARIF_NAME="$SOURCE_PATH"
+ fi
+ if jq --arg prefix "$SARIF_PREFIX" --arg image_name "$SARIF_NAME" '
def valid_severity:
if type == "number" then true
elif type == "string" then test("^[0-9]+(\\.[0-9]+)?$")
@@ -243,9 +272,9 @@ if [ -n "$SARIF_PATH" ] && [ -s "$SARIF_PATH" ]; then
. as $run
| (.value.automationDetails.id? // "") as $id
| (if $id == "" then "run-\($run.key)"
- else ($id | ltrimstr("Snyk/Container/")) end) as $suffix
+ else ($id | ltrimstr($prefix)) end) as $suffix
| .value.automationDetails = ((.value.automationDetails // {}) + {
- id: "Snyk/Container/\($image_name)/\($suffix)"
+ id: "\($prefix)\($image_name)/\($suffix)"
})
| .value
))
diff --git a/.github/actions/cve-scan/test/process_findings.bats b/.github/actions/cve-scan/test/process_findings.bats
index 2a8b4659..833e6227 100644
--- a/.github/actions/cve-scan/test/process_findings.bats
+++ b/.github/actions/cve-scan/test/process_findings.bats
@@ -473,3 +473,31 @@ JSON
[ "$status" -eq 0 ]
grep -qF 'ghcr.io/loft-sh/vcluster-pro@sha256:abcdef1234567890' "$REPORT_PATH"
}
+
+# --- Source mode labels ----------------------------------------------------------
+
+@test "source mode labels the target as a source in Slack, the report and result-json" {
+ unset IMAGE_REF
+ echo '{"findings":[{"id":"S1","severity":"high","package":"golang.org/x/net","version":"v0.1.0","fixedIn":"0.2.0","title":"t"}]}' > "$FINDINGS_JSON"
+ SCAN_KIND=source SCAN_TARGET="loft-sh/example-lib/go.mod" run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ details=$(grab_multiline_output slack-details)
+ [[ "$details" == "Source: loft-sh/example-lib/go.mod"* ]]
+ grep -qF '| Source | `loft-sh/example-lib/go.mod` |' "$REPORT_PATH"
+ [ "$(grab_output result-json | jq -r '.source')" = "loft-sh/example-lib/go.mod" ]
+ [ "$(grab_output result-json | jq -r 'has("image")')" = "false" ]
+ [[ "$(grab_output summary)" == *'`loft-sh/example-lib/go.mod`'* ]]
+}
+
+@test "image mode keeps the Image label and the image key by default" {
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$(grab_multiline_output slack-details)" == "Image: ${IMAGE_REF}"* ]]
+ grep -qF "| Image | \`${IMAGE_REF}\` |" "$REPORT_PATH"
+ [ "$(grab_output result-json | jq -r '.image')" = "$IMAGE_REF" ]
+}
+
+@test "an unknown SCAN_KIND is a config error" {
+ SCAN_KIND=tarball run bash "$SCRIPT"
+ [ "$status" -eq 2 ]
+}
diff --git a/.github/actions/cve-scan/test/run.bats b/.github/actions/cve-scan/test/run.bats
index ee36c65d..9e821c3d 100644
--- a/.github/actions/cve-scan/test/run.bats
+++ b/.github/actions/cve-scan/test/run.bats
@@ -34,6 +34,7 @@ cp "$ADAPTER_FINDINGS_FILE" "$FINDINGS_JSON"
# Single-dash: distinguishes set-but-empty (the correct default) from a real
# value, without the colon-form masking that difference for this assertion.
printf '[%s]' "${DOCKERFILE_PATH-UNSET}" > "$TEST_DIR/adapter_saw_dockerfile_path"
+printf '[%s]' "${SOURCE_PATH-UNSET}" > "$TEST_DIR/adapter_saw_source_path"
# The real adapter has snyk write SARIF here via --sarif-file-output, so run.sh
# can publish sarif-path. Mirrored so the output plumbing is exercised.
if [ -n "${SARIF_PATH:-}" ]; then
@@ -63,6 +64,7 @@ EOF
#!/usr/bin/env bash
touch "$PROCESS_CALLED"
echo "$IMAGE_REF" > "$TEST_DIR/process_saw_image_ref"
+echo "${SCAN_KIND-UNSET}|${SCAN_TARGET-UNSET}" > "$TEST_DIR/process_saw_target"
echo "report body" > "$REPORT_PATH"
rc="$(cat "$PROCESS_EXIT_FILE")"
# Mirrors the real script: every exit-2 path returns before the output block, so
@@ -813,3 +815,84 @@ EOF
[[ "$(grab_output summary)" == *"configuration error"* ]]
[ -z "$(grab_output has-vulnerabilities)" ]
}
+
+# --- Source mode -----------------------------------------------------------------
+# source-path scans a manifest in the checkout, for a library with no image.
+
+@test "source-path scans without pulling and passes the path to the adapter" {
+ touch "$TEST_DIR/go.mod"
+ IMAGE_REF="" SOURCE_PATH="go.mod" GITHUB_REPOSITORY="loft-sh/example-lib" run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ -f "$ADAPTER_CALLED" ]
+ [ ! -s "$DOCKER_CALLS" ]
+ [ "$(cat "$TEST_DIR/adapter_saw_source_path")" = "[go.mod]" ]
+ [ "$(cat "$TEST_DIR/process_saw_target")" = "source|loft-sh/example-lib/go.mod" ]
+ [[ "$output" != *"Pulling"* ]]
+}
+
+@test "image mode passes the image as the target, as before source mode" {
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(cat "$TEST_DIR/process_saw_target")" = "image|${IMAGE_REF}" ]
+ [ "$(cat "$TEST_DIR/adapter_saw_source_path")" = "[]" ]
+ grep -qxF "pull ${IMAGE_REF}" "$DOCKER_CALLS"
+}
+
+@test "setting both image-ref and source-path is a config error" {
+ touch "$TEST_DIR/go.mod"
+ SOURCE_PATH="go.mod" run "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"set image-ref or source-path, not both"* ]]
+ [ ! -f "$ADAPTER_CALLED" ]
+ [ ! -s "$DOCKER_CALLS" ]
+ [[ "$(grab_output summary)" == *"both set"* ]]
+}
+
+@test "setting neither image-ref nor source-path is a config error" {
+ IMAGE_REF="" SOURCE_PATH="" run "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [ ! -f "$ADAPTER_CALLED" ]
+ [ -n "$(grab_output summary)" ]
+}
+
+@test "a source-path that does not exist is a config error" {
+ IMAGE_REF="" SOURCE_PATH="missing/go.mod" run "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"source-path 'missing/go.mod' does not exist"* ]]
+ [ ! -f "$ADAPTER_CALLED" ]
+}
+
+@test "a source-path outside the checkout is a config error" {
+ for path in /etc/passwd ../go.mod sub/../../go.mod "go .mod"; do
+ : > "$GITHUB_OUTPUT"
+ IMAGE_REF="" SOURCE_PATH="$path" run "$SCRIPT"
+ [ "$status" -eq 1 ] || { echo "accepted: $path"; return 1; }
+ [[ "$output" == *"relative path inside the checkout"* ]] || { echo "wrong error for: $path"; return 1; }
+ done
+ [ ! -f "$ADAPTER_CALLED" ]
+}
+
+@test "dockerfile-path with source-path is a config error" {
+ touch "$TEST_DIR/go.mod"
+ IMAGE_REF="" SOURCE_PATH="go.mod" DOCKERFILE_PATH="Dockerfile.release" run "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"dockerfile-path only applies to image-ref"* ]]
+ [ ! -f "$ADAPTER_CALLED" ]
+}
+
+@test "a source-path containing a newline is rejected" {
+ IMAGE_REF="" SOURCE_PATH="go.mod
+forged=yes" run "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"SOURCE_PATH must not contain control characters"* ]]
+ [ "$(grep -c '^forged=' "$GITHUB_OUTPUT" || true)" = "0" ]
+}
+
+@test "a source-mode scanner error names the source, not an empty image" {
+ touch "$TEST_DIR/go.mod"
+ echo "1" > "$ADAPTER_EXIT_FILE"
+ IMAGE_REF="" SOURCE_PATH="go.mod" GITHUB_REPOSITORY="loft-sh/example-lib" run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output scanner-error)" = "true" ]
+ [[ "$(grab_output summary)" == *'`loft-sh/example-lib/go.mod`'* ]]
+}
diff --git a/.github/actions/cve-scan/test/scanners_snyk.bats b/.github/actions/cve-scan/test/scanners_snyk.bats
index 264f136a..654ee671 100644
--- a/.github/actions/cve-scan/test/scanners_snyk.bats
+++ b/.github/actions/cve-scan/test/scanners_snyk.bats
@@ -541,3 +541,102 @@ JSON
[ "$status" -eq 0 ]
[ "$(jq '.findings | length' "$FINDINGS_JSON")" = "1" ]
}
+
+# --- Source mode ---------------------------------------------------------------
+# A library ships no image, so source mode runs `snyk test` on a manifest in the
+# checkout instead of `snyk container test` on a pulled image.
+
+@test "source mode runs snyk test on the manifest, not snyk container test" {
+ touch "$TEST_DIR/go.mod"
+ IMAGE_REF="" SOURCE_PATH="go.mod" SARIF_PATH="$TEST_DIR/out.sarif" run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ grep -qxF -- "test --file=go.mod --severity-threshold=low --json --sarif-file-output=${TEST_DIR}/out.sarif" "$SNYK_CALLS"
+ ! grep -qF -- "container" "$SNYK_CALLS"
+}
+
+@test "source mode never passes a dockerfile" {
+ IMAGE_REF="" SOURCE_PATH="go.mod" DOCKERFILE_PATH="Dockerfile.release" run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ ! grep -qF -- "Dockerfile.release" "$SNYK_CALLS"
+}
+
+@test "both IMAGE_REF and SOURCE_PATH set is a setup error" {
+ SOURCE_PATH="go.mod" run bash "$SCRIPT"
+ [ "$status" -eq 2 ]
+ [ ! -s "$SNYK_CALLS" ]
+}
+
+@test "neither IMAGE_REF nor SOURCE_PATH set is a setup error" {
+ IMAGE_REF="" run bash "$SCRIPT"
+ [ "$status" -eq 2 ]
+ [ ! -s "$SNYK_CALLS" ]
+}
+
+@test "source mode errors name snyk test, not snyk container test" {
+ echo "3" > "$SNYK_EXIT"
+ IMAGE_REF="" SOURCE_PATH="go.mod" run bash "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"snyk test exited 3"* ]]
+ [[ "$output" != *"container"* ]]
+}
+
+# snyk test lists a vulnerability once per dependency path.
+@test "source mode maps snyk test findings and counts each vulnerability once" {
+ cat > "$SNYK_OUTPUT" <<'JSON'
+{
+ "ok": false,
+ "vulnerabilities": [
+ {"id":"SNYK-GOLANG-1","identifiers":{"CVE":["CVE-2025-1"]},"severity":"high","packageName":"golang.org/x/net/html","version":"v0.42.0","fixedIn":["0.45.0"],"title":"path A","from":["a","golang.org/x/net/html@v0.42.0"]},
+ {"id":"SNYK-GOLANG-1","identifiers":{"CVE":["CVE-2025-1"]},"severity":"high","packageName":"golang.org/x/net/html","version":"v0.42.0","fixedIn":["0.45.0"],"title":"path B","from":["b","golang.org/x/net/html@v0.42.0"]},
+ {"id":"SNYK-GOLANG-2","identifiers":{"CVE":[]},"severity":"medium","packageName":"golang.org/x/crypto/ssh","version":"v0.40.0","fixedIn":[],"title":"other"}
+ ]
+}
+JSON
+ echo "1" > "$SNYK_EXIT"
+ IMAGE_REF="" SOURCE_PATH="go.mod" run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(jq '.findings | length' "$FINDINGS_JSON")" = "2" ]
+ jq -e '.findings[] | select(.id == "SNYK-GOLANG-1") | .cve == "CVE-2025-1" and .package == "golang.org/x/net/html" and .version == "v0.42.0" and .fixedIn == "0.45.0" and .severity == "high"' "$FINDINGS_JSON" >/dev/null
+ jq -e '.findings[] | select(.id == "SNYK-GOLANG-2") | .cve == null and .fixedIn == null' "$FINDINGS_JSON" >/dev/null
+}
+
+@test "image mode still counts every top-level entry, as before source mode" {
+ cat > "$SNYK_OUTPUT" <<'JSON'
+{"vulnerabilities":[
+ {"id":"SNYK-OS-1","severity":"high","packageName":"libssl3","version":"1"},
+ {"id":"SNYK-OS-1","severity":"high","packageName":"libssl3","version":"1"}
+],"applications":[]}
+JSON
+ echo "1" > "$SNYK_EXIT"
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(jq '.findings | length' "$FINDINGS_JSON")" = "2" ]
+}
+
+@test "source mode keys SARIF runs by manifest path under Snyk/OpenSource" {
+ cat > "$MOCK_DIR/snyk" <<'MOCK'
+#!/usr/bin/env bash
+for a in "$@"; do
+ case "$a" in
+ --sarif-file-output=*)
+ cat > "${a#--sarif-file-output=}" <<'SARIF'
+{"runs":[
+ {"automationDetails":{"id":"Snyk/OpenSource/abc"},"tool":{"driver":{"rules":[{"id":"r1","properties":{"security-severity":"null","cvssv3_baseScore":7.5}}]}},"results":[{"ruleId":"r1","fixes":[{}]}]},
+ {"tool":{"driver":{"rules":[]}},"results":[]}
+]}
+SARIF
+ ;;
+ esac
+done
+cat "$SNYK_OUTPUT"
+MOCK
+ chmod +x "$MOCK_DIR/snyk"
+
+ IMAGE_REF="" SOURCE_PATH="sdk/go.mod" SARIF_PATH="$TEST_DIR/out.sarif" run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(jq -r '.runs[0].automationDetails.id' "$TEST_DIR/out.sarif")" = "Snyk/OpenSource/sdk/go.mod/abc" ]
+ [ "$(jq -r '.runs[1].automationDetails.id' "$TEST_DIR/out.sarif")" = "Snyk/OpenSource/sdk/go.mod/run-1" ]
+ # The shared cleanup still runs in source mode.
+ [ "$(jq -r '.runs[0].tool.driver.rules[0].properties["security-severity"]' "$TEST_DIR/out.sarif")" = "7.5" ]
+ [ "$(jq -c '.runs[0].results[0]' "$TEST_DIR/out.sarif")" = '{"ruleId":"r1"}' ]
+}
diff --git a/.github/workflows/test-cve-scan.yaml b/.github/workflows/test-cve-scan.yaml
index 4cc22400..8c803be5 100644
--- a/.github/workflows/test-cve-scan.yaml
+++ b/.github/workflows/test-cve-scan.yaml
@@ -128,3 +128,24 @@ jobs:
run: |
[ "$OUTCOME" = "failure" ] \
|| { echo "::error::expected the missing-dockerfile-path config error to fail the step"; exit 1; }
+
+ # Source mode reads SOURCE_PATH, so its wiring needs its own scenario. A
+ # missing manifest fails before the scanner runs, and the summary names
+ # the path, which only happens when the input reached run.sh.
+ - id: scan-missing-source-path
+ continue-on-error: true
+ uses: ./.github/actions/cve-scan
+ with:
+ source-path: definitely-missing/go.mod
+ enabled: true
+ notify: false
+
+ - name: Assert a missing source-path is reported as a config error
+ env:
+ OUTCOME: ${{ steps.scan-missing-source-path.outcome }}
+ SUMMARY: ${{ steps.scan-missing-source-path.outputs.summary }}
+ run: |
+ [ "$OUTCOME" = "failure" ] \
+ || { echo "::error::expected the missing-source-path config error to fail the step"; exit 1; }
+ [[ "$SUMMARY" == *"source-path 'definitely-missing/go.mod' does not exist"* ]] \
+ || { echo "::error::expected the summary to name the missing source-path, got: ${SUMMARY}"; exit 1; }
diff --git a/README.md b/README.md
index d5d98421..44a7acdb 100644
--- a/README.md
+++ b/README.md
@@ -1239,7 +1239,9 @@ jobs:
- `scanner-error`: `true` if the scan couldn't complete — distinct both from finding CVEs and from a setup error, which fails the job
- `report-path`, `sarif-path` (written by the scanner), `summary` (Slack-ready text)
-See [cve-scan README](./.github/actions/cve-scan/README.md) for suppressing findings via `.snyk`, the scanner-adapter exit-code contract, how to resolve the image tag per repo, and the SARIF upload example.
+Set `source-path: go.mod` instead of `image-ref` to scan a library that ships no image.
+
+See [cve-scan README](./.github/actions/cve-scan/README.md) for source mode, suppressing findings via `.snyk`, the scanner-adapter exit-code contract, how to resolve the image tag per repo, and the SARIF upload example.
### Scheduled CVE Scan