Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions .github/actions/cve-scan/README.md

Large diffs are not rendered by default.

30 changes: 21 additions & 9 deletions .github/actions/cve-scan/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,13 @@ inputs:
required: false
default: "false"
notify:
description: "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. Only fires on `schedule` and `release` events — a `workflow_dispatch` or PR run never notifies, so testing the action can't spam the channel. The Job Summary is written either way."
description: "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."
required: false
default: "true"
notify-events:
description: "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."
required: false
default: "schedule,release"
slack-webhook-url:
description: "Slack incoming webhook URL for the ci-test-notify action. Required when `notify: true`."
required: false
Expand Down Expand Up @@ -128,19 +132,19 @@ runs:
ENABLED: ${{ inputs.enabled }}
BLOCK_ON_FINDINGS: ${{ inputs.block-on-findings }}
NOTIFY: ${{ inputs.notify }}
NOTIFY_EVENTS: ${{ inputs.notify-events }}
run: ${{ github.action_path }}/run.sh

- name: Send Slack notification
# Two gates, for two different reasons.
#
# Event allowlist: only the two triggers this action is built for can
# post. A `workflow_dispatch` run is someone testing, and a channel that
# notify-effective carries both the tolerant `notify` comparison and the
# `notify-events` allowlist, resolved in run.sh where a mismatch can be
# annotated. The allowlist defaults to schedule,release: a channel that
# gets a message every time an engineer pokes the action is a channel
# people learn to ignore — which costs more than the alert is worth.
# `govulncheck` restricts to `schedule` for the same reason; the release
# event is added here because the prerelease scan is the case this action
# exists for. Deliberately an allowlist, not a denylist: a new trigger
# should have to opt in rather than start posting by surprise.
# people learn to ignore. Still an allowlist, not a denylist — a new
# trigger opts in by passing notify-events, rather than starting to post
# by surprise.
#
# Outcome gate: `outcome == 'failure'` is the third case. A config error
# (bad severity-threshold, a scanner that couldn't
Expand All @@ -154,10 +158,12 @@ runs:
# arm a killed scheduled scan produces no page and no Job Summary
# entry — a security scan that silently stopped scanning, which is the
# exact failure this action's error taxonomy exists to prevent.
# A cancel that lands before run.sh resolves notify-effective leaves it
# unset, so notify-events isn't applied on that path: unresolved gates
# resolve toward notifying, same as an unrecognised notify value.
if: >-
always() &&
steps.scan.outputs.notify-effective != 'false' &&
(github.event_name == 'schedule' || github.event_name == 'release') &&
(steps.scan.outputs.has-vulnerabilities == 'true' ||
steps.scan.outputs.scanner-error == 'true' ||
steps.scan.outcome == 'failure' ||
Expand All @@ -177,9 +183,15 @@ runs:
# all, so it can't be distinguished from a config error any other way.
# Otherwise ordered so a blocked scan with findings reads as findings,
# not as a config error — it fails the job too.
#
# The link is derived, not an input, so it can't drift from the upload:
# upload-sarif defaults to the same github.ref. Needs the ref filter or
# the page shows the default branch and reads as empty, and sits outside
# the fence because Slack won't linkify inside one.
details: |
${{ steps.scan.outcome == 'cancelled' && 'CANCELLED — the scan step was killed (job timeout or a manual cancel) before it could finish; nothing was verified' || (steps.scan.outputs.scanner-error == 'true' && 'SCANNER ERROR — no scan was performed, nothing was verified' || (steps.scan.outputs.has-vulnerabilities == 'true' && 'FINDINGS at or above the severity threshold' || 'CONFIGURATION ERROR — cve-scan could not run. An authoring or provisioning mistake, not a scan result; see the job log.')) }}
```
${{ steps.scan.outputs.summary }}
```
${{ steps.scan.outputs.sarif-path != '' && format('Findings: <{0}/{1}/security/code-scanning?query=ref:{2}|View in Security tab>', github.server_url, github.repository, github.ref) || '' }}
webhook-url: ${{ inputs.slack-webhook-url }}
31 changes: 30 additions & 1 deletion .github/actions/cve-scan/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ for flag in ENABLED BLOCK_ON_FINDINGS NOTIFY; do
echo "::warning::cve-scan: unrecognised ${input}='${!flag}' — using the safe default"
done

# Resolved here rather than in action.yml's if: for the same reason
# notify-effective exists — YAML can't annotate a mismatch, so "schedule, release"
# with a space would silence notifications with nothing saying why.
# Non-colon default: unset takes the default silently, but an explicitly empty
# value (`${{ vars.UNSET }}`) warns instead of looking like it worked.
NOTIFY_EVENTS=$(normalize_flag "${NOTIFY_EVENTS-schedule,release}")
if [ -z "$NOTIFY_EVENTS" ]; then
echo "::warning::cve-scan: empty notify-events — using the safe default 'schedule,release'"
NOTIFY_EVENTS="schedule,release"
fi

# Empty only when run outside Actions: no event to test, so leave NOTIFY alone
# rather than resolving toward silence.
# `!= false`, not `= true`: to_bool passes an unrecognised value through, so a
# typo'd `truee` would otherwise skip the gate and still satisfy the caller's
# `!= 'false'`. Matching the caller's comparison keeps the two gates independent,
# the way they were when the event check lived in YAML.
EVENT_NAME=$(normalize_flag "${GITHUB_EVENT_NAME:-}")
if [ -n "$EVENT_NAME" ] && [ "$NOTIFY" != false ]; then
# Comma-wrapped so entries match whole: `release` must not match `prerelease`.
if [[ ",${NOTIFY_EVENTS}," != *",${EVENT_NAME},"* ]]; then
NOTIFY=false
# Notice, not warning: staying silent here is the designed behaviour, but
# "why did no Slack message arrive" still needs an answer in the log.
echo "::notice::cve-scan: not notifying on event '${EVENT_NAME}' — notify-events allows '${NOTIFY_EVENTS}'"
fi
fi

write_output() {
echo "$1=$2" >> "$GITHUB_OUTPUT"
}
Expand All @@ -67,7 +95,8 @@ write_output() {
# pure YAML, so nothing could annotate the mismatch. This output exists so the
# caller's if: gets the same tolerant, warned comparison for free, on every
# outcome — scanner-error and config-error included, which is exactly when a
# notification matters most.
# notification matters most. The event allowlist folds in here too, so the
# caller's if: is one comparison rather than two.
write_output notify-effective "$NOTIFY"

# Every outcome leaves a Job Summary entry, including the ones with no report.
Expand Down
4 changes: 4 additions & 0 deletions .github/actions/cve-scan/test/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ grab_output() {

# The temp dir, the GITHUB_OUTPUT file and the image ref every suite needs.
setup_tmp_env() {
# Actions sets GITHUB_EVENT_NAME on every runner, and run.sh reads it for the
# notify-events allowlist, so inheriting it makes results depend on where the
# suite runs: green locally, red in CI. Tests that care set it themselves.
unset GITHUB_EVENT_NAME
TEST_DIR=$(mktemp -d)
export TEST_DIR
export GITHUB_OUTPUT="$TEST_DIR/github_output"
Expand Down
107 changes: 106 additions & 1 deletion .github/actions/cve-scan/test/run.bats
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ teardown() {
has-vulnerabilities=false
forged=yes" run bash "$SCRIPT"
[ "$status" -eq 1 ]
[[ "$output" == *"whitespace"* ]]
# A newline is a control character, so the control-character guard rejects it
# before the whitespace check is reached — same message as its three siblings.
[[ "$output" == *"control characters"* ]]
[ "$(grep -c '^forged=' "$GITHUB_OUTPUT" || true)" = "0" ]
}

Expand Down Expand Up @@ -547,6 +549,109 @@ EOF
[ "$(grab_output notify-effective)" = "true" ]
}

# --- notify-events allowlist ---------------------------------------------------------
# The allowlist lives here rather than in action.yml's if: so a mistyped or
# whitespaced list annotates instead of silently dropping every notification.
# These cases are what that move buys: none of them is reachable from YAML.

@test "the default allowlist notifies on schedule and release" {
Comment thread
vcauesantos marked this conversation as resolved.
for e in schedule release; do
GITHUB_EVENT_NAME="$e" NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "true" ] || {
echo "event='$e' should have been allowed by the default"
return 1
}
done
}

@test "the default allowlist stays silent on workflow_dispatch and pull_request" {
for e in workflow_dispatch pull_request; do
GITHUB_EVENT_NAME="$e" NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "false" ] || {
echo "event='$e' should not have been allowed by the default"
return 1
}
done
}

@test "a blocked event says so, so an absent Slack message is explainable" {
GITHUB_EVENT_NAME=workflow_dispatch NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[[ "$output" == *"not notifying on event 'workflow_dispatch'"* ]]
}

@test "a custom allowlist opts one event in without opting the defaults in" {
GITHUB_EVENT_NAME=workflow_dispatch NOTIFY_EVENTS=workflow_dispatch NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "true" ]

GITHUB_EVENT_NAME=schedule NOTIFY_EVENTS=workflow_dispatch NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "false" ]
}

# The reason the gate is in bash: `${{ }}` can't strip a space or lowercase a
# word, so "Schedule, Release" typed into a repo variable would match nothing.
@test "the allowlist tolerates whitespace and case, like the other switches" {
GITHUB_EVENT_NAME=release NOTIFY_EVENTS=" Schedule, Release " NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "true" ]
}

@test "allowlist entries match whole, so release does not match prerelease" {
GITHUB_EVENT_NAME=prerelease NOTIFY_EVENTS=release NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "false" ]
}

# `notify-events: ${{ vars.UNSET_VARIABLE }}` arrives empty. Reverting to the
# default silently would look like the caller's widening worked.
@test "an empty allowlist warns and falls back to the default" {
GITHUB_EVENT_NAME=schedule NOTIFY_EVENTS="" NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "true" ]
[[ "$output" == *"empty notify-events"* ]]
}

# The two gates have to stay independent. to_bool passes an unrecognised value
# through, so guarding the allowlist on `= true` would let a typo'd notify skip
# it and still satisfy the caller's `!= 'false'`.
@test "an unrecognised notify value still cannot post on a blocked event" {
GITHUB_EVENT_NAME=workflow_dispatch NOTIFY=truee run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "false" ]
[[ "$output" == *"unrecognised notify"* ]]
}

@test "an unrecognised notify value still notifies on an allowed event" {
GITHUB_EVENT_NAME=schedule NOTIFY=truee run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" != "false" ]
}

@test "an allowed event cannot re-enable a caller who asked not to notify" {
GITHUB_EVENT_NAME=schedule NOTIFY=false run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "false" ]
}

# Outside Actions there is no event to test. Suppressing on that would make the
# gate fire on every direct invocation, including this suite's other cases.
@test "an absent event name leaves notify untouched" {
NOTIFY=true run bash "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(grab_output notify-effective)" = "true" ]
[[ "$output" != *"not notifying on event"* ]]
}

@test "the event gate applies on a config-error outcome too" {
GITHUB_EVENT_NAME=pull_request NOTIFY=true SCANNER=made-up-tool run bash "$SCRIPT"
[ "$status" -eq 1 ]
[ "$(grab_output notify-effective)" = "false" ]
}

# --- Input passthrough + output plumbing ----------------------------------------------

@test "passes IMAGE_REF through to process-findings.sh" {
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/test-cve-scan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# This action installs bats, it does not run anything. It has no `tests`
# input, and an unknown input is a warning rather than an error, so a job
# that only calls it passes without executing a single test.
- uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0
with:
tests: .github/actions/cve-scan/test
support-install: false
assert-install: false
detik-install: false
file-install: false
- name: Run cve-scan tests
run: bats .github/actions/cve-scan/test/*.bats

# Drives the composite through `uses:` so action.yml's own wiring is exercised.
# The bats suite invokes src/*.sh directly with env exported by hand, so none
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,8 @@ jobs:
- `enabled` (optional, default: `true`): kill switch; resolves toward scanning on an unrecognised value
- `block-on-findings` (optional, default: `false`): advisory vs. blocking posture
- `registry` (optional, default: `ghcr.io`) / `registry-username` / `registry-password`: the action pulls the image with the caller's own credentials rather than relying on the scanner vendor's registry integration
- `notify` / `slack-webhook-url` (optional, default: `true` / —)
- `notify` / `slack-webhook-url` (optional, default: `true` / —): gated by `notify-events` as well, so both have to admit a run before it posts
- `notify-events` (optional, default: `schedule,release`): comma-separated allowlist of `github.event_name` values permitted to notify, so a `workflow_dispatch` or PR run stays silent unless it opts in. A `workflow_call` callee sees the caller's event name

**Outputs:**

Expand Down
Loading