From 7cf2e305f9a5369335318be77a3ace271c234ab1 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 15:40:08 -0600 Subject: [PATCH 1/9] feat(ci-test-notify): support advisory Slack messages The action had one layout: a failure message. A CVE finding on the default advisory posture is not a failure, so reporting it that way overstated it and left no room for the finding detail itself. Add an advisory layout and a details field the caller fills, so a scanner, configuration, or cancellation alert keeps the failure layout while findings get their own. --- .github/actions/ci-test-notify/README.md | 13 ++++--- .github/actions/ci-test-notify/action.yml | 7 +++- .../actions/ci-test-notify/build-payload.sh | 37 ++++++++++++++++--- .../actions/ci-test-notify/should-notify.sh | 10 ++--- .../ci-test-notify/test/build-payload.bats | 33 +++++++++++++++++ .../ci-test-notify/test/should-notify.bats | 6 +++ 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/.github/actions/ci-test-notify/README.md b/.github/actions/ci-test-notify/README.md index 0ea6f139..843015f3 100644 --- a/.github/actions/ci-test-notify/README.md +++ b/.github/actions/ci-test-notify/README.md @@ -8,12 +8,13 @@ Replaces the nightly-specific `ci-notify-nightly-tests` action with a generic in -| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | -|-------------|--------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| details | string | false | | Markdown text appended after the build
URL (test results, versions, artifact links, etc.) | -| status | string | true | | Run status, typically `needs..result` or `job.status`.
`success` and `failure` notify; `cancelled` and
`skipped` are treated as no-ops and
send nothing. | -| test-name | string | true | | Test suite name for the header
(e.g. "E2E Ginkgo Nightly Tests"). Keep under ~130 chars —
Slack header blocks have a 150-char
limit and the status suffix takes
~15 chars. | -| webhook-url | string | true | | Slack incoming webhook URL | +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|-------------------|--------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| details | string | false | | Markdown text appended after the build
URL (test results, versions, artifact links, etc.) | +| run-link-position | string | false | `"top"` | Where to render the immutable workflow-run
link: `top` (default) or `bottom`. Invalid
values fall back to `top`. | +| status | string | true | | Run status, typically `needs..result` or `job.status`.
`success`, `failure`, and `warning` notify; `cancelled`
and `skipped` are treated as no-ops
and send nothing. | +| test-name | string | true | | Test suite name for the header
(e.g. "E2E Ginkgo Nightly Tests"). Keep under ~130 chars —
Slack header blocks have a 150-char
limit and the status suffix takes
~15 chars. | +| webhook-url | string | true | | Slack incoming webhook URL | diff --git a/.github/actions/ci-test-notify/action.yml b/.github/actions/ci-test-notify/action.yml index 4f40dd6e..bdc63a5e 100644 --- a/.github/actions/ci-test-notify/action.yml +++ b/.github/actions/ci-test-notify/action.yml @@ -8,7 +8,7 @@ inputs: description: 'Test suite name for the header (e.g. "E2E Ginkgo Nightly Tests"). Keep under ~130 chars — Slack header blocks have a 150-char limit and the status suffix takes ~15 chars.' required: true status: - description: 'Run status, typically `needs..result` or `job.status`. `success` and `failure` notify; `cancelled` and `skipped` are treated as no-ops and send nothing.' + description: 'Run status, typically `needs..result` or `job.status`. `success`, `failure`, and `warning` notify; `cancelled` and `skipped` are treated as no-ops and send nothing.' required: true details: description: 'Markdown text appended after the build URL (test results, versions, artifact links, etc.)' @@ -17,6 +17,10 @@ inputs: webhook-url: description: 'Slack incoming webhook URL' required: true + run-link-position: + description: 'Where to render the immutable workflow-run link: `top` (default) or `bottom`. Invalid values fall back to `top`.' + required: false + default: 'top' runs: using: "composite" @@ -40,6 +44,7 @@ runs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} REPO: ${{ github.repository }} RUN_NUMBER: ${{ github.run_number }} + RUN_LINK_POSITION: ${{ inputs.run-link-position }} run: ${{ github.action_path }}/build-payload.sh - name: Send Slack notification diff --git a/.github/actions/ci-test-notify/build-payload.sh b/.github/actions/ci-test-notify/build-payload.sh index 1e1ffb78..db19ef4f 100755 --- a/.github/actions/ci-test-notify/build-payload.sh +++ b/.github/actions/ci-test-notify/build-payload.sh @@ -2,12 +2,14 @@ set -euo pipefail # Required env vars: TEST_NAME, STATUS, DETAILS, PAYLOAD_FILE, RUN_URL, REPO, RUN_NUMBER +# Optional env vars: RUN_LINK_POSITION (top or bottom; defaults to top) command -v jq >/dev/null || { echo "::error::jq is required but not found"; exit 1; } case "$STATUS" in success) EMOJI="✅"; STATUS_TEXT="Success" ;; failure) EMOJI="❌"; STATUS_TEXT="Failed" ;; + warning) EMOJI="⚠️"; STATUS_TEXT="Warning" ;; cancelled) EMOJI="⚠️"; STATUS_TEXT="Cancelled" ;; skipped) EMOJI="⏭️"; STATUS_TEXT="Skipped" ;; *) EMOJI="❓"; STATUS_TEXT="Unknown ($STATUS)" ;; @@ -21,15 +23,40 @@ if [[ ${#HEADER} -gt 150 ]]; then HEADER="${HEADER:0:147}..." fi -SECTION="Build URL: ${RUN_URL}" -if [[ "$DETAILS" =~ [^[:space:]] ]]; then - SECTION="$(printf '%s\n\n%s' "$SECTION" "$DETAILS")" -fi +RUN_LINK_POSITION="${RUN_LINK_POSITION:-top}" +RUN_LINK="Workflow: <${RUN_URL}|View workflow run>" +case "$RUN_LINK_POSITION" in + top) + SECTION="Build URL: ${RUN_URL}" + if [[ "$DETAILS" =~ [^[:space:]] ]]; then + SECTION="$(printf '%s\n\n%s' "$SECTION" "$DETAILS")" + fi + ;; + bottom) + SECTION="$RUN_LINK" + if [[ "$DETAILS" =~ [^[:space:]] ]]; then + SECTION="$(printf '%s\n\n%s' "$DETAILS" "$SECTION")" + fi + ;; + *) + echo "::warning::invalid RUN_LINK_POSITION '$RUN_LINK_POSITION', defaulting to top" + SECTION="Build URL: ${RUN_URL}" + if [[ "$DETAILS" =~ [^[:space:]] ]]; then + SECTION="$(printf '%s\n\n%s' "$SECTION" "$DETAILS")" + fi + ;; +esac # Slack section blocks reject >3000 chars if [[ ${#SECTION} -gt 3000 ]]; then echo "::warning::Section exceeds 3000-char Slack limit (${#SECTION} chars), truncating" - SECTION="${SECTION:0:2997}..." + if [[ "$RUN_LINK_POSITION" == "bottom" ]]; then + DETAILS_LIMIT=$((3000 - ${#RUN_LINK} - 2)) + SECTION="${SECTION:0:$((DETAILS_LIMIT - 3))}..." + SECTION="$(printf '%s\n\n%s' "$SECTION" "$RUN_LINK")" + else + SECTION="${SECTION:0:2997}..." + fi fi jq -n \ diff --git a/.github/actions/ci-test-notify/should-notify.sh b/.github/actions/ci-test-notify/should-notify.sh index a229af83..d406726d 100755 --- a/.github/actions/ci-test-notify/should-notify.sh +++ b/.github/actions/ci-test-notify/should-notify.sh @@ -5,10 +5,10 @@ set -euo pipefail # `notify=true|false` to $GITHUB_OUTPUT for the composite action to gate on. # # Callers pass the run conclusion straight from `needs..result` or -# `job.status`, which can be success, failure, cancelled, or skipped. Only -# success and failure are actionable: a cancelled run was aborted by a human -# (or superseded), and a skipped job never executed. Neither warrants a Slack -# alert, so both are silenced here rather than in every caller. +# `job.status`, which can be success, failure, warning, cancelled, or skipped. +# Cancelled and skipped runs are silenced: a cancelled run was aborted by a +# human (or superseded), and a skipped job never executed. A warning is an +# advisory result and should notify without being labelled as a failure. # # An empty webhook (fork PRs, where secrets are unavailable) also suppresses # the notification, same as before. @@ -22,7 +22,7 @@ if [[ -z "${WEBHOOK_URL:-}" ]]; then echo "::warning::webhook-url is empty (expected on fork PRs where secrets are unavailable), skipping notification" notify=false elif [[ "${STATUS:?STATUS is required}" == "cancelled" || "$STATUS" == "skipped" ]]; then - echo "::notice::status is '$STATUS' — only success and failure notify, skipping Slack notification" + echo "::notice::status is '$STATUS' — cancelled and skipped runs do not notify, skipping Slack notification" notify=false fi diff --git a/.github/actions/ci-test-notify/test/build-payload.bats b/.github/actions/ci-test-notify/test/build-payload.bats index 8c10ae00..3bb3aa58 100644 --- a/.github/actions/ci-test-notify/test/build-payload.bats +++ b/.github/actions/ci-test-notify/test/build-payload.bats @@ -14,6 +14,7 @@ setup() { export RUN_URL="https://github.com/org/repo/actions/runs/12345" export REPO="org/repo" export RUN_NUMBER="42" + export RUN_LINK_POSITION="top" } teardown() { @@ -41,6 +42,12 @@ payload_field() { [[ "$(payload_field '.text')" == *"Failed"* ]] } +@test "warning status produces an advisory header" { + STATUS="warning" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(payload_field '.blocks[0].text.text')" = "⚠️ My Test Suite Warning" ] +} + @test "cancelled status produces correct emoji and text" { STATUS="cancelled" run bash "$SCRIPT" [ "$status" -eq 0 ] @@ -110,6 +117,22 @@ payload_field() { [[ "$section" == *"Line three"* ]] } +@test "bottom run link position appends the workflow link after details" { + RUN_LINK_POSITION="bottom" DETAILS="High findings: 6" run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local section + section=$(payload_field '.blocks[1].text.text') + [[ "$section" == "High findings: 6"$'\n\n'"Workflow: " ]] +} + +@test "invalid run link position safely falls back to the top" { + RUN_LINK_POSITION="hidden" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"invalid RUN_LINK_POSITION"* ]] + [ "$(payload_field '.blocks[1].text.text')" = "Build URL: https://github.com/org/repo/actions/runs/12345" ] +} + # --- Block Kit structure --- @test "payload has correct block structure" { @@ -178,6 +201,16 @@ payload_field() { [[ "$section" == *"..."* ]] } +@test "bottom run link is retained when details exceed the section limit" { + RUN_LINK_POSITION="bottom" DETAILS="$(printf 'X%.0s' {1..3000})" run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local section + section=$(payload_field '.blocks[1].text.text') + [ "${#section}" -le 3000 ] + [[ "$section" == *"Workflow: " ]] +} + @test "section is not truncated when under 3000 chars" { DETAILS="Short details" run bash "$SCRIPT" [ "$status" -eq 0 ] diff --git a/.github/actions/ci-test-notify/test/should-notify.bats b/.github/actions/ci-test-notify/test/should-notify.bats index a306861e..d345f04e 100644 --- a/.github/actions/ci-test-notify/test/should-notify.bats +++ b/.github/actions/ci-test-notify/test/should-notify.bats @@ -37,6 +37,12 @@ notify_value() { [ "$(notify_value)" = "true" ] } +@test "warning notifies" { + STATUS="warning" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(notify_value)" = "true" ] +} + # --- Statuses that must stay silent (the bug this fixes) --- @test "cancelled does not notify" { From b4c69bcf92cc13df0eb23bfc893b4d8507909e9d Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 15:40:16 -0600 Subject: [PATCH 2/9] feat(cve-scan): preview findings in Slack A notification that only carried counts made every alert look the same, so triage always started with opening the run. Emit a bounded, scanner-neutral preview of the findings at or above severity-threshold, grouped by package, and pass it to ci-test-notify's advisory layout. The preview is built in the adapter-neutral layer, so changing scanner never changes what a notification consumer receives. Scanner strings become mrkdwn, so each field has its control characters stripped, its Slack entity delimiters encoded, and its length bounded before it can reach a webhook. Groups and titles are capped, ordered by weight so the cap keeps what dominates rather than what sorts first. Closes DEVOPS-1414 --- .github/actions/cve-scan/README.md | 2 + .github/actions/cve-scan/action.yml | 59 +++++++------ .github/actions/cve-scan/run.sh | 1 + .../actions/cve-scan/src/process-findings.sh | 88 +++++++++++++++++++ .github/actions/cve-scan/test/helpers.bash | 18 ++++ .../cve-scan/test/process_findings.bats | 25 ++++++ 6 files changed, 164 insertions(+), 29 deletions(-) diff --git a/.github/actions/cve-scan/README.md b/.github/actions/cve-scan/README.md index 39b600a9..c78eb3b8 100644 --- a/.github/actions/cve-scan/README.md +++ b/.github/actions/cve-scan/README.md @@ -61,7 +61,9 @@ Be deliberate about `severity-threshold` on the scheduled sweep. Real images usu | report-path | string | Path to a short markdown summary
— the per-severity counts and what
was scanned. Per-finding detail is in
`sarif-path`, not here. | | sarif-path | string | Path to the SARIF file the
scanner emitted, or empty if it
produced none. Upload it with `github/codeql-action/upload-sarif`
from the caller workflow to get
per-finding detail in the Security tab
— this action does not upload
it itself, to keep `security-events: write` out of
its own permission footprint. | | scanner-error | string | `true` if the scan could not
complete (timeout, registry failure, unparseable output). Distinct from finding CVEs
— never fails the job regardless
of `block-on-findings`. Also distinct from a *setup* error
(missing CLI or credential), which fails the job and leaves
this unset — config errors write
no result outputs at all. | +| slack-details | string | Bounded, scanner-neutral Slack preview for findings
at or above `severity-threshold`. | | summary | string | Slack-ready text summary, distinct from `report-path`
(the full file). Always set: a completed scan
reports the per-severity counts even when
they are all zero, and a
skipped or inconclusive run says which
it was. | +| threshold-count | string | Finding count at or above `severity-threshold`. | diff --git a/.github/actions/cve-scan/action.yml b/.github/actions/cve-scan/action.yml index 927d0227..a026c908 100644 --- a/.github/actions/cve-scan/action.yml +++ b/.github/actions/cve-scan/action.yml @@ -78,6 +78,12 @@ outputs: low-count: description: "Low-severity finding count." value: ${{ steps.scan.outputs.low-count }} + threshold-count: + description: "Finding count at or above `severity-threshold`." + value: ${{ steps.scan.outputs.threshold-count }} + slack-details: + description: "Bounded, scanner-neutral Slack preview for findings at or above `severity-threshold`." + value: ${{ steps.scan.outputs.slack-details }} scanner-error: description: "`true` if the scan could not complete (timeout, registry failure, unparseable output). Distinct from finding CVEs — never fails the job regardless of `block-on-findings`. Also distinct from a *setup* error (missing CLI or credential), which fails the job and leaves this unset — config errors write no result outputs at all." value: ${{ steps.scan.outputs.scanner-error }} @@ -135,7 +141,7 @@ runs: NOTIFY_EVENTS: ${{ inputs.notify-events }} run: ${{ github.action_path }}/run.sh - - name: Send Slack notification + - name: Send Slack finding preview # Two gates, for two different reasons. # # notify-effective carries both the tolerant `notify` comparison and the @@ -146,43 +152,38 @@ runs: # 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 - # be provisioned) fails the job but sets neither `scanner-error` nor - # `has-vulnerabilities`, so without it that outcome reddened the run and - # told nobody — despite being the most actionable of the three. - # - # `outcome == 'cancelled'` is the fourth: a job-level timeout (or a - # manual cancel) kills the scan step outright, so no finish_with_* - # helper runs and neither result output gets set at all. Without this - # 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. + # Finding alerts are advisory, not failures. Error and cancellation + # alerts stay in the following notification so their failure semantics + # and top-of-message build link remain unchanged. + if: >- + always() && + steps.scan.outputs.notify-effective != 'false' && + steps.scan.outputs.has-vulnerabilities == 'true' + uses: loft-sh/github-actions/.github/actions/ci-test-notify@4255b2f7309cd1d35bedbe1f1c920ada0d4b0c16 # ci-test-notify/v1 + with: + test-name: "CVE scan · ${{ steps.scan.outputs.threshold-count }} ${{ inputs.severity-threshold }} findings" + status: warning + run-link-position: bottom + details: ${{ steps.scan.outputs.slack-details }} + webhook-url: ${{ inputs.slack-webhook-url }} + + - name: Send Slack error notification if: >- always() && steps.scan.outputs.notify-effective != 'false' && - (steps.scan.outputs.has-vulnerabilities == 'true' || - steps.scan.outputs.scanner-error == 'true' || + steps.scan.outputs.has-vulnerabilities != 'true' && + (steps.scan.outputs.scanner-error == 'true' || steps.scan.outcome == 'failure' || steps.scan.outcome == 'cancelled') - uses: loft-sh/github-actions/.github/actions/ci-test-notify@85d7023c5749421d369f59430c7849f2d00ad694 # ci-test-notify/v1 + uses: loft-sh/github-actions/.github/actions/ci-test-notify@4255b2f7309cd1d35bedbe1f1c920ada0d4b0c16 # ci-test-notify/v1 with: test-name: "cve-scan: ${{ inputs.image-ref }}" - # Every branch of the `if:` above is a failure, so the status is a + # 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 - # All four outcomes share that status, so the distinction has to live - # in the text: "the scanner broke", "we shipped critical CVEs", "the - # config is wrong" and "the step got killed" are very different calls - # to action for whoever is on call, and someone skimming Slack by - # colour can't tell them apart. Cancelled is checked first: on that - # outcome the step never set scanner-error or has-vulnerabilities at - # 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. + # Scanner, configuration, and cancellation errors all share failure + # status, so their distinction lives in the details. Cancelled is + # checked first because the scan step never set its result outputs. # # 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 diff --git a/.github/actions/cve-scan/run.sh b/.github/actions/cve-scan/run.sh index 4092c188..1701753e 100755 --- a/.github/actions/cve-scan/run.sh +++ b/.github/actions/cve-scan/run.sh @@ -274,6 +274,7 @@ fi PROCESS_EXIT=0 FINDINGS_JSON="$FINDINGS_JSON" \ REPORT_PATH="${WORKDIR}/report.md" \ + BLOCK_ON_FINDINGS="$BLOCK_ON_FINDINGS" \ "${ACTION_PATH}/src/process-findings.sh" || PROCESS_EXIT=$? if [ "$PROCESS_EXIT" -ne 0 ]; then diff --git a/.github/actions/cve-scan/src/process-findings.sh b/.github/actions/cve-scan/src/process-findings.sh index 35fb77cd..1c025fbf 100755 --- a/.github/actions/cve-scan/src/process-findings.sh +++ b/.github/actions/cve-scan/src/process-findings.sh @@ -23,6 +23,7 @@ set -euo pipefail : "${IMAGE_REF:?IMAGE_REF is required}" SEVERITY_THRESHOLD="${SEVERITY_THRESHOLD:-high}" +BLOCK_ON_FINDINGS="${BLOCK_ON_FINDINGS:-false}" # The event name, not a caller label: the image ref already says whether this # is a head sweep or a release scan, and a hand-typed label can contradict it. TRIGGER_CONTEXT="${TRIGGER_CONTEXT:-${GITHUB_EVENT_NAME:-unspecified trigger}}" @@ -73,6 +74,91 @@ COUNTS_TSV=$(jq -r --arg t "$SEVERITY_THRESHOLD" --argjson rank "$SEVERITY_RANK" read -r CRITICAL_COUNT HIGH_COUNT MEDIUM_COUNT LOW_COUNT \ FINDING_COUNT HAS_VULNERABILITIES <<<"$COUNTS_TSV" +# Keep the Slack preview in the adapter-neutral layer. Every scanner emits the +# canonical finding shape above, so changing scanner never changes what a +# notification consumer receives. +THRESHOLD_COUNT=$(jq -r --arg t "$SEVERITY_THRESHOLD" --argjson rank "$SEVERITY_RANK" \ + '[.findings[] | select(($rank[.severity] // 0) >= $rank[$t])] | length' "$FINDINGS_JSON") + +# 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" ' + $value + | gsub("[[:cntrl:]]"; " ") + | gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">") + | if length > 200 then .[0:197] + "..." else . end +') + +PREVIEW_GROUPS=$(jq -r --arg t "$SEVERITY_THRESHOLD" --argjson rank "$SEVERITY_RANK" ' + def clean: + tostring + | gsub("[[:cntrl:]]"; " ") + | gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">"); + def clip($limit): + if length > $limit then .[0:($limit - 3)] + "..." else . end; + [ .findings[] + | select(($rank[.severity] // 0) >= $rank[$t]) + | { + package: ((.package // "unknown package") | clean | clip(160)), + title: ((.title // .id // "unnamed vulnerability") | clean | clip(150)) + } + ] + | sort_by(.package) | group_by(.package) | .[:3] + | map( + . as $group + | ($group | map(.title) | sort | group_by(.) + # Most-repeated title first, so the .[:4] cap below keeps the titles + # that dominate the group rather than the alphabetically first ones. + | sort_by(-length, .[0]) + | map(if length == 1 then .[0] else "\(.[0]) (\(length))" end) + | .[:4] | join(", ")) as $titles + | "🔴 \($group[0].package) · \($group | length) \(if ($group | length) == 1 then "finding" else "findings" end)\n \($titles)" + ) + | join("\n\n") +' "$FINDINGS_JSON") + +BELOW_THRESHOLD=() +add_below_threshold() { + local count="$1" severity="$2" + if [ "$count" -gt 0 ]; then + if [ "$count" -eq 1 ]; then + BELOW_THRESHOLD+=("${count} ${severity} finding") + else + BELOW_THRESHOLD+=("${count} ${severity} findings") + fi + fi +} +case "$SEVERITY_THRESHOLD" in + critical) + add_below_threshold "$HIGH_COUNT" high + add_below_threshold "$MEDIUM_COUNT" medium + add_below_threshold "$LOW_COUNT" low + ;; + high) + add_below_threshold "$MEDIUM_COUNT" medium + add_below_threshold "$LOW_COUNT" low + ;; + medium) add_below_threshold "$LOW_COUNT" low ;; +esac + +if [ "$BLOCK_ON_FINDINGS" = "true" ]; then + BLOCKING_CONTEXT="Blocking enabled — these findings fail the job." +else + BLOCKING_CONTEXT="Advisory only — release was not blocked." +fi + +SLACK_DETAILS=$(printf 'Image: %s\nThreshold: %s · %s\n\nFindings at or above threshold\n\n%s' \ + "$SLACK_IMAGE" "$SEVERITY_THRESHOLD" "$BLOCKING_CONTEXT" "$PREVIEW_GROUPS") +if [ "${#BELOW_THRESHOLD[@]}" -gt 0 ]; then + SLACK_DETAILS="${SLACK_DETAILS}"$'\n\n'"Also detected: $(IFS=', '; echo "${BELOW_THRESHOLD[*]}") below the notification threshold." +fi + +write_multiline_output() { + local key="$1" value="$2" delimiter + delimiter="cve-scan-${key}-$(od -An -N16 -tx1 /dev/urandom | tr -d '[:space:]')" + printf '%s<<%s\n%s\n%s\n' "$key" "$delimiter" "$value" "$delimiter" >> "$GITHUB_OUTPUT" +} + # --- Markdown report --------------------------------------------------------- { echo "# CVE Scan — ${IMAGE_REF} — ${TODAY}" @@ -105,6 +191,8 @@ read -r CRITICAL_COUNT HIGH_COUNT MEDIUM_COUNT LOW_COUNT \ echo "high-count=${HIGH_COUNT}" echo "medium-count=${MEDIUM_COUNT}" echo "low-count=${LOW_COUNT}" + echo "threshold-count=${THRESHOLD_COUNT}" echo "report-path=${REPORT_PATH}" echo "summary=cve-scan — \`${IMAGE_REF}\` (${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/test/helpers.bash b/.github/actions/cve-scan/test/helpers.bash index dfcad7d5..6034990a 100644 --- a/.github/actions/cve-scan/test/helpers.bash +++ b/.github/actions/cve-scan/test/helpers.bash @@ -10,6 +10,24 @@ grab_output() { grep -E "^$1=" "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2- } +# grab_multiline_output — reads a GitHub Actions heredoc output. +grab_multiline_output() { + local key="$1" line delimiter="" found=false output="" + while IFS= read -r line; do + if [ "$found" = false ]; then + if [[ "$line" == "$key"'<<'* ]]; then + delimiter="${line#*<<}" + found=true + fi + elif [ "$line" = "$delimiter" ]; then + break + else + output+="${output:+$'\n'}${line}" + fi + done < "$GITHUB_OUTPUT" + printf '%s\n' "$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 diff --git a/.github/actions/cve-scan/test/process_findings.bats b/.github/actions/cve-scan/test/process_findings.bats index 3e87edf0..8f75c2f3 100644 --- a/.github/actions/cve-scan/test/process_findings.bats +++ b/.github/actions/cve-scan/test/process_findings.bats @@ -205,6 +205,31 @@ JSON [[ "$summary" == *"low=4"* ]] } +@test "writes a bounded, grouped Slack preview for threshold findings" { + write_findings <<'JSON' +{"findings":[ + {"id":"A","severity":"high","package":"github.com/example/pkg","title":"Out-of-bounds read"}, + {"id":"B","severity":"high","package":"github.com/example/pkg","title":"Out-of-bounds read"}, + {"id":"C","severity":"high","package":"github.com/example/pkg","title":"Integer <@U123> & underflow"}, + {"id":"D","severity":"high","package":"github.com/example/other","title":"Invalid array index"}, + {"id":"E","severity":"medium","package":"ignored","title":"Below threshold <@U123> & more"} +]} +JSON + SEVERITY_THRESHOLD=high run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(grab_output threshold-count)" = "4" ] + [ "$(grep -Ec '^slack-details<"* ]] +} + @test "the report names a digest ref verbatim rather than mangling it" { IMAGE_REF="ghcr.io/loft-sh/vcluster-pro@sha256:abcdef1234567890" run bash "$SCRIPT" [ "$status" -eq 0 ] From 8429749f423e8b6c9dc8c38c7be61e776c75bb06 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 16:06:27 -0600 Subject: [PATCH 3/9] refactor(cve-scan): use a neutral bullet in the Slack preview A red circle reads as "critical" in a CVE alert, so a preview of high findings looked more severe than it was. The header's warning icon already carries the overall severity and each group states its own finding count, so the per-group marker does not need to signal urgency. Presentation only: the grouping, ordering, escaping and bounds are unchanged. --- .github/actions/cve-scan/src/process-findings.sh | 2 +- .github/actions/cve-scan/test/process_findings.bats | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/cve-scan/src/process-findings.sh b/.github/actions/cve-scan/src/process-findings.sh index 1c025fbf..2ed14ff4 100755 --- a/.github/actions/cve-scan/src/process-findings.sh +++ b/.github/actions/cve-scan/src/process-findings.sh @@ -112,7 +112,7 @@ PREVIEW_GROUPS=$(jq -r --arg t "$SEVERITY_THRESHOLD" --argjson rank "$SEVERITY_R | sort_by(-length, .[0]) | map(if length == 1 then .[0] else "\(.[0]) (\(length))" end) | .[:4] | join(", ")) as $titles - | "🔴 \($group[0].package) · \($group | length) \(if ($group | length) == 1 then "finding" else "findings" end)\n \($titles)" + | "• \($group[0].package) · \($group | length) \(if ($group | length) == 1 then "finding" else "findings" end)\n \($titles)" ) | join("\n\n") ' "$FINDINGS_JSON") diff --git a/.github/actions/cve-scan/test/process_findings.bats b/.github/actions/cve-scan/test/process_findings.bats index 8f75c2f3..ebd4fa6d 100644 --- a/.github/actions/cve-scan/test/process_findings.bats +++ b/.github/actions/cve-scan/test/process_findings.bats @@ -223,9 +223,9 @@ JSON local preview preview=$(grab_multiline_output slack-details) [[ "$preview" == *"Findings at or above threshold"* ]] - [[ "$preview" == *"🔴 github.com/example/pkg · 3 findings"* ]] + [[ "$preview" == *"• github.com/example/pkg · 3 findings"* ]] [[ "$preview" == *"Out-of-bounds read (2), Integer <@U123> & underflow"* ]] - [[ "$preview" == *"🔴 github.com/example/other · 1 finding"* ]] + [[ "$preview" == *"• github.com/example/other · 1 finding"* ]] [[ "$preview" == *"Also detected: 1 medium finding below the notification threshold."* ]] [[ "$preview" != *"<@U123>"* ]] } From e49e81dfde06a77d58070548b53506a8737f2786 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 16:17:34 -0600 Subject: [PATCH 4/9] fix(cve-scan): separate below-threshold counts with a comma and a space `"${array[*]}"` joins on IFS's first character only, so `IFS=', '` dropped the space and rendered two counts as "1 medium finding,1 low finding". The existing preview test leaves exactly one severity below the threshold, which is the single case a broken separator still renders correctly, so add one that puts three severities below it. --- .../actions/cve-scan/src/process-findings.sh | 5 ++++- .../cve-scan/test/process_findings.bats | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/actions/cve-scan/src/process-findings.sh b/.github/actions/cve-scan/src/process-findings.sh index 2ed14ff4..4b365107 100755 --- a/.github/actions/cve-scan/src/process-findings.sh +++ b/.github/actions/cve-scan/src/process-findings.sh @@ -150,7 +150,10 @@ fi SLACK_DETAILS=$(printf 'Image: %s\nThreshold: %s · %s\n\nFindings at or above threshold\n\n%s' \ "$SLACK_IMAGE" "$SEVERITY_THRESHOLD" "$BLOCKING_CONTEXT" "$PREVIEW_GROUPS") if [ "${#BELOW_THRESHOLD[@]}" -gt 0 ]; then - SLACK_DETAILS="${SLACK_DETAILS}"$'\n\n'"Also detected: $(IFS=', '; echo "${BELOW_THRESHOLD[*]}") below the notification threshold." + # `${array[*]}` joins on IFS's *first character* only, so IFS=', ' drops the + # space and runs two counts together as "1 medium finding,1 low finding". + printf -v BELOW_THRESHOLD_TEXT '%s, ' "${BELOW_THRESHOLD[@]}" + SLACK_DETAILS="${SLACK_DETAILS}"$'\n\n'"Also detected: ${BELOW_THRESHOLD_TEXT%, } below the notification threshold." fi write_multiline_output() { diff --git a/.github/actions/cve-scan/test/process_findings.bats b/.github/actions/cve-scan/test/process_findings.bats index ebd4fa6d..d907cee1 100644 --- a/.github/actions/cve-scan/test/process_findings.bats +++ b/.github/actions/cve-scan/test/process_findings.bats @@ -230,6 +230,26 @@ JSON [[ "$preview" != *"<@U123>"* ]] } +# The existing preview test leaves exactly one severity below the threshold, +# which is the one case a broken separator still renders correctly. +@test "several below-threshold severities read as a list, not one run-on count" { + write_findings <<'JSON' +{"findings":[ + {"id":"A","severity":"critical","package":"stdlib","title":"Boom"}, + {"id":"B","severity":"high","package":"stdlib","title":"Bang"}, + {"id":"C","severity":"medium","package":"stdlib","title":"Meh"}, + {"id":"D","severity":"low","package":"stdlib","title":"Tiny"}, + {"id":"E","severity":"low","package":"stdlib","title":"Tiny again"} +]} +JSON + SEVERITY_THRESHOLD=critical run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local preview + preview=$(grab_multiline_output slack-details) + [[ "$preview" == *"Also detected: 1 high finding, 1 medium finding, 2 low findings below the notification threshold."* ]] +} + @test "the report names a digest ref verbatim rather than mangling it" { IMAGE_REF="ghcr.io/loft-sh/vcluster-pro@sha256:abcdef1234567890" run bash "$SCRIPT" [ "$status" -eq 0 ] From d34a29af0c4d031e9c64f894521c4dc413096158 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 16:40:28 -0600 Subject: [PATCH 5/9] fix(cve-scan): never let a Slack failure block the caller `slackapi/slack-github-action` runs with `errors: true`, and neither notify step was guarded, so a Slack outage, a rate limit or a rejected payload failed cve-scan and with it the caller's job. That contradicts the contract in the README: a scan that cannot complete is inconclusive and never fails the job, and a notification system has even less business gating a release. Mark both notify steps advisory, matching the registry login above. The Job Summary is written either way, so a dropped Slack message loses nothing that is not still recorded on the run. --- .github/actions/cve-scan/action.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/actions/cve-scan/action.yml b/.github/actions/cve-scan/action.yml index a026c908..72c1f35e 100644 --- a/.github/actions/cve-scan/action.yml +++ b/.github/actions/cve-scan/action.yml @@ -160,6 +160,11 @@ runs: steps.scan.outputs.notify-effective != 'false' && steps.scan.outputs.has-vulnerabilities == 'true' uses: loft-sh/github-actions/.github/actions/ci-test-notify@4255b2f7309cd1d35bedbe1f1c920ada0d4b0c16 # ci-test-notify/v1 + # Slack is not a gate. An outage, a rate limit or a rejected payload must + # not fail the caller, or a notification system becomes able to block a + # release — the same reason the registry login above is advisory. The Job + # Summary is written either way, so nothing is lost when this is skipped. + continue-on-error: true with: test-name: "CVE scan · ${{ steps.scan.outputs.threshold-count }} ${{ inputs.severity-threshold }} findings" status: warning @@ -176,6 +181,11 @@ runs: steps.scan.outcome == 'failure' || steps.scan.outcome == 'cancelled') uses: loft-sh/github-actions/.github/actions/ci-test-notify@4255b2f7309cd1d35bedbe1f1c920ada0d4b0c16 # ci-test-notify/v1 + # Slack is not a gate. An outage, a rate limit or a rejected payload must + # not fail the caller, or a notification system becomes able to block a + # release — the same reason the registry login above is advisory. The Job + # 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 }}" # Every branch of this `if:` is a failure, so the status is a From 0157c66f3034718081fd02ad01009e1ebc760129 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 16:40:29 -0600 Subject: [PATCH 6/9] fix(ci-test-notify): measure Slack block limits in characters, not bytes Slack counts characters. `${#var}` and `${var:0:n}` count characters under a UTF-8 locale but bytes under POSIX, and a runner is not guaranteed to set one. Under a byte locale a 2000-character preview was cut to 1036 characters, roughly half of it discarded, and the cut landed mid-sequence often enough to leave a U+FFFD in the message. The grouped CVE preview is full of multi-byte characters, so this went from theoretical to likely. Measure and cut in jq, which always counts codepoints, and floor the bottom-position budget: `3000 - len(run link) - 2` can go negative, and a negative slice length reads as "all but the last n", which would overshoot 3000 and lose the whole message to a Slack rejection. Payloads are byte-identical to before under a UTF-8 locale across every status, both link positions, and the header and section truncation paths. The only behavioural change is on a byte locale, where an over-truncated non-ASCII message now renders in full. --- .../actions/ci-test-notify/build-payload.sh | 40 ++++++++++--- .../ci-test-notify/test/build-payload.bats | 56 +++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/.github/actions/ci-test-notify/build-payload.sh b/.github/actions/ci-test-notify/build-payload.sh index db19ef4f..7584bae1 100755 --- a/.github/actions/ci-test-notify/build-payload.sh +++ b/.github/actions/ci-test-notify/build-payload.sh @@ -6,6 +6,17 @@ set -euo pipefail command -v jq >/dev/null || { echo "::error::jq is required but not found"; exit 1; } +# Slack's block limits are in characters. bash's ${#var} and ${var:0:n} follow +# the locale: characters under a UTF-8 locale, bytes under POSIX. Runners are +# not guaranteed to set one, so measuring in bash truncates roughly three times +# too early on non-ASCII text and can cut a UTF-8 sequence mid-character. jq +# always counts codepoints, so measure and cut there instead. +str_len() { printf '%s' "$1" | jq -Rs 'length'; } +clip_to() { + printf '%s' "$2" | jq -Rrs --argjson n "$1" \ + 'if length > $n then .[0:($n - 3)] + "..." else . end' +} + case "$STATUS" in success) EMOJI="✅"; STATUS_TEXT="Success" ;; failure) EMOJI="❌"; STATUS_TEXT="Failed" ;; @@ -18,9 +29,10 @@ esac HEADER="${EMOJI} ${TEST_NAME} ${STATUS_TEXT}" # Slack header blocks reject >150 chars -if [[ ${#HEADER} -gt 150 ]]; then - echo "::warning::Header exceeds 150-char Slack limit (${#HEADER} chars), truncating" - HEADER="${HEADER:0:147}..." +HEADER_LEN=$(str_len "$HEADER") +if [[ $HEADER_LEN -gt 150 ]]; then + echo "::warning::Header exceeds 150-char Slack limit (${HEADER_LEN} chars), truncating" + HEADER=$(clip_to 150 "$HEADER") fi RUN_LINK_POSITION="${RUN_LINK_POSITION:-top}" @@ -48,14 +60,24 @@ case "$RUN_LINK_POSITION" in esac # Slack section blocks reject >3000 chars -if [[ ${#SECTION} -gt 3000 ]]; then - echo "::warning::Section exceeds 3000-char Slack limit (${#SECTION} chars), truncating" +SECTION_LEN=$(str_len "$SECTION") +if [[ $SECTION_LEN -gt 3000 ]]; then + echo "::warning::Section exceeds 3000-char Slack limit (${SECTION_LEN} chars), truncating" if [[ "$RUN_LINK_POSITION" == "bottom" ]]; then - DETAILS_LIMIT=$((3000 - ${#RUN_LINK} - 2)) - SECTION="${SECTION:0:$((DETAILS_LIMIT - 3))}..." - SECTION="$(printf '%s\n\n%s' "$SECTION" "$RUN_LINK")" + # Reserve the run link and the blank line above it, so truncation never + # costs the one immutable piece of the message. + DETAILS_LIMIT=$((3000 - $(str_len "$RUN_LINK") - 2)) + if [[ $DETAILS_LIMIT -lt 4 ]]; then + # A run URL long enough to leave no room for details is not reachable from + # github.server_url/run_id, but an unfloored budget here would go negative + # and a negative slice reads as "all but the last n", overshooting 3000 + # and getting the whole message rejected. Keep the link, drop the details. + SECTION=$(clip_to 3000 "$RUN_LINK") + else + SECTION="$(printf '%s\n\n%s' "$(clip_to "$DETAILS_LIMIT" "$SECTION")" "$RUN_LINK")" + fi else - SECTION="${SECTION:0:2997}..." + SECTION=$(clip_to 3000 "$SECTION") fi fi diff --git a/.github/actions/ci-test-notify/test/build-payload.bats b/.github/actions/ci-test-notify/test/build-payload.bats index 3bb3aa58..1523cfd2 100644 --- a/.github/actions/ci-test-notify/test/build-payload.bats +++ b/.github/actions/ci-test-notify/test/build-payload.bats @@ -211,6 +211,62 @@ payload_field() { [[ "$section" == *"Workflow: " ]] } +# The limits Slack enforces are in characters, but bash measures in bytes under +# a POSIX locale, so an ASCII-only fixture cannot tell the two apart. These run +# the truncation path with multi-byte text, where a byte-based cut both fires +# far too early and can split a character in half. +# +# LC_ALL=C is pinned deliberately. Under a UTF-8 locale bash already counts +# characters, so these would pass whatever the script did and quietly stop +# testing anything — the same "green here, red there" trap that unsetting +# GITHUB_EVENT_NAME avoids in the cve-scan helpers. Pinning the byte locale +# reproduces the hazard wherever the suite runs. + +@test "a multi-byte section is measured in characters, not bytes" { + # 2000 three-byte bullets: 2000 characters, 6000 bytes. Under the limit by + # Slack's count, so nothing should be truncated. + LC_ALL=C DETAILS="$(printf '•%.0s' {1..2000})" run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local chars + chars=$(jq -r '.blocks[1].text.text | length' "$PAYLOAD_FILE") + [ "$chars" -le 3000 ] + [[ "$(jq -r '.blocks[1].text.text' "$PAYLOAD_FILE")" != *"..."* ]] +} + +@test "truncating a multi-byte section never splits a character" { + LC_ALL=C DETAILS="$(printf '•%.0s' {1..4000})" run bash "$SCRIPT" + [ "$status" -eq 0 ] + + # U+FFFD is what a half-written UTF-8 sequence decodes to, so its absence is + # the assertion: the cut landed on a character boundary. + local section + section=$(jq -r '.blocks[1].text.text' "$PAYLOAD_FILE") + [ "$(jq -r '.blocks[1].text.text | length' "$PAYLOAD_FILE")" -le 3000 ] + [[ "$section" != *'�'* ]] +} + +@test "a bottom-positioned run link survives truncation intact" { + LC_ALL=C RUN_LINK_POSITION="bottom" DETAILS="$(printf '•%.0s' {1..4000})" run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local section + section=$(jq -r '.blocks[1].text.text' "$PAYLOAD_FILE") + [ "$(jq -r '.blocks[1].text.text | length' "$PAYLOAD_FILE")" -le 3000 ] + [[ "$section" == *"Workflow: <${RUN_URL}|View workflow run>" ]] +} + +# A run URL this long is not reachable from github.server_url and github.run_id, +# but an unfloored budget would go negative here, and a negative slice length +# reads as "all but the last n" — overshooting 3000 and losing the whole message +# to a Slack rejection. +@test "an absurdly long run URL still yields a section within the limit" { + RUN_URL="https://github.com/org/repo/actions/runs/$(printf '9%.0s' {1..3200})" + RUN_LINK_POSITION="bottom" DETAILS="some findings" run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(jq -r '.blocks[1].text.text | length' "$PAYLOAD_FILE")" -le 3000 ] +} + @test "section is not truncated when under 3000 chars" { DETAILS="Short details" run bash "$SCRIPT" [ "$status" -eq 0 ] From b660176e7561b01ad090ed03b4ed3df959ff2f43 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 18:30:44 -0600 Subject: [PATCH 7/9] fix(cve-scan): address panel review on the Slack preview Three blocking findings, all reproduced before fixing: A blocking run announced itself as an amber advisory. `status` was the literal `warning`, and the error notification excludes findings, so a run that reddened the caller sent one alert and that alert said Warning. run.sh now publishes the resolved `block-effective` alongside `notify-effective`, and the alert derives failure from it rather than repeating the tolerant comparison in YAML. The three-package cap kept whichever packages sorted first by name, so a package with five findings was dropped in favour of three with one each. Order groups by size, tie-broken by name, matching what the title cap already did. The blocking context line was asserted nowhere: swapping its two strings left the suite green. It is now pinned from both directions, and derived from the same pair run.sh gates on, since blocking alone never fails a job. With blocking on and nothing at the threshold it said "these findings fail the job" about a job that was about to pass. Also from the review: gate the findings heading on the count, because `slack-details` is a declared output and a caller reading it outside the step's `if:` saw a heading with nothing under it; drop the `has-vulnerabilities` arm of the error `details` ternary, unreachable since that step requires the opposite; collapse `build-payload.sh`'s `*)` arm, which duplicated `top)` verbatim, by normalising the position up front; and correct the README, which still said only success and failure notify and documented only the top layout. `run-link-position` also relabels the link rather than only moving it. Changing `top` would alter the message for every existing call site, so the input now says so instead. Default-path payloads remain byte-identical to main across 14 scenarios. --- .github/actions/ci-test-notify/README.md | 46 ++++++++-- .github/actions/ci-test-notify/action.yml | 2 +- .../actions/ci-test-notify/build-payload.sh | 44 +++++----- .github/actions/cve-scan/action.yml | 8 +- .github/actions/cve-scan/run.sh | 5 ++ .../actions/cve-scan/src/process-findings.sh | 27 ++++-- .../cve-scan/test/process_findings.bats | 83 +++++++++++++++++++ 7 files changed, 178 insertions(+), 37 deletions(-) diff --git a/.github/actions/ci-test-notify/README.md b/.github/actions/ci-test-notify/README.md index 843015f3..f58f5c31 100644 --- a/.github/actions/ci-test-notify/README.md +++ b/.github/actions/ci-test-notify/README.md @@ -8,18 +8,20 @@ Replaces the nightly-specific `ci-notify-nightly-tests` action with a generic in -| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | -|-------------------|--------|----------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| details | string | false | | Markdown text appended after the build
URL (test results, versions, artifact links, etc.) | -| run-link-position | string | false | `"top"` | Where to render the immutable workflow-run
link: `top` (default) or `bottom`. Invalid
values fall back to `top`. | -| status | string | true | | Run status, typically `needs..result` or `job.status`.
`success`, `failure`, and `warning` notify; `cancelled`
and `skipped` are treated as no-ops
and send nothing. | -| test-name | string | true | | Test suite name for the header
(e.g. "E2E Ginkgo Nightly Tests"). Keep under ~130 chars —
Slack header blocks have a 150-char
limit and the status suffix takes
~15 chars. | -| webhook-url | string | true | | Slack incoming webhook URL | +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|-------------------|--------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| details | string | false | | Markdown text appended after the build
URL (test results, versions, artifact links, etc.) | +| run-link-position | string | false | `"top"` | Where the immutable workflow-run link goes,
and how it reads. `top` (default)
puts a bare `Build URL: ` line above
`details`, unchanged from before this input
existed. `bottom` puts a linked `Workflow: View workflow run`
line below `details`, so the content
leads and the link trails. Invalid
values fall back to `top`. | +| status | string | true | | Run status, typically `needs..result` or `job.status`.
`success`, `failure`, and `warning` notify; `cancelled`
and `skipped` are treated as no-ops
and send nothing. | +| test-name | string | true | | Test suite name for the header
(e.g. "E2E Ginkgo Nightly Tests"). Keep under ~130 chars —
Slack header blocks have a 150-char
limit and the status suffix takes
~15 chars. | +| webhook-url | string | true | | Slack incoming webhook URL | ## Message format +With `run-link-position: top` (the default), unchanged from before that input existed: + ``` [emoji] [test-name] [status] ───────────────────────────── @@ -30,6 +32,28 @@ Build URL: · Run # ``` +With `run-link-position: bottom`, for messages whose `details` are the point and +should be read first: + +``` +[emoji] [test-name] [status] +───────────────────────────── +
+ +Workflow: View workflow run +───────────────────────────── + · Run # +``` + +The link is not merely moved: `top` prints the bare URL after `Build URL:`, while +`bottom` renders a linked label. `top` is left exactly as it was so that switching +position is opt-in for the roughly thirty existing call sites. + +The section is capped at Slack's 3000-character limit and the header at 150. Both +are measured in characters rather than bytes, so multi-byte text is not truncated +early or cut mid-character; with `bottom`, the run link is always preserved and the +`details` are what give way. + ## Usage ### Nightly E2E tests @@ -80,7 +104,13 @@ The action only notifies on actionable outcomes. A `status` of `cancelled` or `skipped` is treated as a no-op: the action logs a notice and sends nothing. This means callers can pass `needs..result` or `job.status` straight through without a guard. A cancelled run (aborted by a human or superseded) or a -skipped job never produces a Slack alert; only `success` and `failure` do. +skipped job never produces a Slack alert. + +Everything else notifies. `success` and `failure` are the usual pair; `warning` +is for an advisory result that is worth reporting but is not a failure, such as a +CVE scan running on the default non-blocking posture. An unrecognised status also +notifies, under a `❓ Unknown ()` header, on the grounds that a status +nobody anticipated is more useful surfaced than swallowed. An empty `webhook-url` (fork PRs, where secrets are unavailable) also suppresses the notification. diff --git a/.github/actions/ci-test-notify/action.yml b/.github/actions/ci-test-notify/action.yml index bdc63a5e..48a845d3 100644 --- a/.github/actions/ci-test-notify/action.yml +++ b/.github/actions/ci-test-notify/action.yml @@ -18,7 +18,7 @@ inputs: description: 'Slack incoming webhook URL' required: true run-link-position: - description: 'Where to render the immutable workflow-run link: `top` (default) or `bottom`. Invalid values fall back to `top`.' + description: 'Where the immutable workflow-run link goes, and how it reads. `top` (default) puts a bare `Build URL: ` line above `details`, unchanged from before this input existed. `bottom` puts a linked `Workflow: View workflow run` line below `details`, so the content leads and the link trails. Invalid values fall back to `top`.' required: false default: 'top' diff --git a/.github/actions/ci-test-notify/build-payload.sh b/.github/actions/ci-test-notify/build-payload.sh index 7584bae1..eb40140c 100755 --- a/.github/actions/ci-test-notify/build-payload.sh +++ b/.github/actions/ci-test-notify/build-payload.sh @@ -35,29 +35,31 @@ if [[ $HEADER_LEN -gt 150 ]]; then HEADER=$(clip_to 150 "$HEADER") fi +# Normalise first, so the two positions are each written once and every later +# reader (the truncation branch below included) sees a value it can trust. RUN_LINK_POSITION="${RUN_LINK_POSITION:-top}" +if [[ "$RUN_LINK_POSITION" != "top" && "$RUN_LINK_POSITION" != "bottom" ]]; then + echo "::warning::invalid RUN_LINK_POSITION '$RUN_LINK_POSITION', defaulting to top" + RUN_LINK_POSITION="top" +fi + +# The two positions render the link differently, not just in a different place: +# `top` keeps the bare `Build URL:` line every existing caller already gets, and +# `bottom` uses a linked label that reads better as a footer. Changing `top` +# would alter the message for ~30 call sites, so the difference is documented in +# the input rather than smoothed over here. RUN_LINK="Workflow: <${RUN_URL}|View workflow run>" -case "$RUN_LINK_POSITION" in - top) - SECTION="Build URL: ${RUN_URL}" - if [[ "$DETAILS" =~ [^[:space:]] ]]; then - SECTION="$(printf '%s\n\n%s' "$SECTION" "$DETAILS")" - fi - ;; - bottom) - SECTION="$RUN_LINK" - if [[ "$DETAILS" =~ [^[:space:]] ]]; then - SECTION="$(printf '%s\n\n%s' "$DETAILS" "$SECTION")" - fi - ;; - *) - echo "::warning::invalid RUN_LINK_POSITION '$RUN_LINK_POSITION', defaulting to top" - SECTION="Build URL: ${RUN_URL}" - if [[ "$DETAILS" =~ [^[:space:]] ]]; then - SECTION="$(printf '%s\n\n%s' "$SECTION" "$DETAILS")" - fi - ;; -esac +if [[ "$RUN_LINK_POSITION" == "bottom" ]]; then + SECTION="$RUN_LINK" + if [[ "$DETAILS" =~ [^[:space:]] ]]; then + SECTION="$(printf '%s\n\n%s' "$DETAILS" "$SECTION")" + fi +else + SECTION="Build URL: ${RUN_URL}" + if [[ "$DETAILS" =~ [^[:space:]] ]]; then + SECTION="$(printf '%s\n\n%s' "$SECTION" "$DETAILS")" + fi +fi # Slack section blocks reject >3000 chars SECTION_LEN=$(str_len "$SECTION") diff --git a/.github/actions/cve-scan/action.yml b/.github/actions/cve-scan/action.yml index 72c1f35e..20bd957e 100644 --- a/.github/actions/cve-scan/action.yml +++ b/.github/actions/cve-scan/action.yml @@ -167,7 +167,11 @@ runs: continue-on-error: true with: test-name: "CVE scan · ${{ steps.scan.outputs.threshold-count }} ${{ inputs.severity-threshold }} findings" - status: warning + # block-on-findings turns these findings into the job's failure, so the + # alert has to say failure too. Without this a blocking run that reds the + # caller announced itself as an amber advisory and, because the error + # notification below excludes findings, that was the only alert sent. + status: ${{ steps.scan.outputs.block-effective == 'true' && 'failure' || 'warning' }} run-link-position: bottom details: ${{ steps.scan.outputs.slack-details }} webhook-url: ${{ inputs.slack-webhook-url }} @@ -200,7 +204,7 @@ runs: # 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.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' || 'CONFIGURATION ERROR — cve-scan could not run. An authoring or provisioning mistake, not a scan result; see the job log.') }} ``` ${{ steps.scan.outputs.summary }} ``` diff --git a/.github/actions/cve-scan/run.sh b/.github/actions/cve-scan/run.sh index 1701753e..fd17ca4d 100755 --- a/.github/actions/cve-scan/run.sh +++ b/.github/actions/cve-scan/run.sh @@ -99,6 +99,11 @@ write_output() { # caller's if: is one comparison rather than two. write_output notify-effective "$NOTIFY" +# Same reasoning, for the notification's status rather than its gate: the +# tolerant spelling is resolved here, so action.yml does not have to repeat +# the comparison in YAML to tell an amber advisory from a red failure. +write_output block-effective "$BLOCK_ON_FINDINGS" + # Every outcome leaves a Job Summary entry, including the ones with no report. # A skipped or inconclusive run is the outcome most in need of a human # noticing, and it is otherwise visible only in Annotations. diff --git a/.github/actions/cve-scan/src/process-findings.sh b/.github/actions/cve-scan/src/process-findings.sh index 4b365107..781a88ce 100755 --- a/.github/actions/cve-scan/src/process-findings.sh +++ b/.github/actions/cve-scan/src/process-findings.sh @@ -103,7 +103,12 @@ PREVIEW_GROUPS=$(jq -r --arg t "$SEVERITY_THRESHOLD" --argjson rank "$SEVERITY_R title: ((.title // .id // "unnamed vulnerability") | clean | clip(150)) } ] - | sort_by(.package) | group_by(.package) | .[:3] + | sort_by(.package) | group_by(.package) + # Most-affected package first. Sorting by name and then capping at three + # drops the worst package whenever two others happen to sort earlier, which + # is the opposite of what a bounded preview is for. Name breaks the tie so + # the order stays stable between runs. + | sort_by(-length, .[0].package) | .[:3] | map( . as $group | ($group | map(.title) | sort | group_by(.) @@ -141,14 +146,26 @@ case "$SEVERITY_THRESHOLD" in medium) add_below_threshold "$LOW_COUNT" low ;; esac -if [ "$BLOCK_ON_FINDINGS" = "true" ]; then +# The same pair run.sh gates on. Blocking alone does not fail a job: it takes +# blocking *and* something at or above the threshold, so keying the line on the +# input by itself would promise a failure that never came. +if [ "$BLOCK_ON_FINDINGS" != "true" ]; then + BLOCKING_CONTEXT="Advisory only — release was not blocked." +elif [ "$HAS_VULNERABILITIES" = "true" ]; then BLOCKING_CONTEXT="Blocking enabled — these findings fail the job." else - BLOCKING_CONTEXT="Advisory only — release was not blocked." + BLOCKING_CONTEXT="Blocking enabled — nothing at or above the threshold, so the job is not blocked." fi -SLACK_DETAILS=$(printf 'Image: %s\nThreshold: %s · %s\n\nFindings at or above threshold\n\n%s' \ - "$SLACK_IMAGE" "$SEVERITY_THRESHOLD" "$BLOCKING_CONTEXT" "$PREVIEW_GROUPS") +# The heading is gated on the count rather than on action.yml's step `if:`. +# 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") +if [ "$THRESHOLD_COUNT" -gt 0 ]; then + SLACK_DETAILS="${SLACK_DETAILS}"$'\n\n'"Findings at or above threshold"$'\n\n'"${PREVIEW_GROUPS}" +fi if [ "${#BELOW_THRESHOLD[@]}" -gt 0 ]; then # `${array[*]}` joins on IFS's *first character* only, so IFS=', ' drops the # space and runs two counts together as "1 medium finding,1 low finding". diff --git a/.github/actions/cve-scan/test/process_findings.bats b/.github/actions/cve-scan/test/process_findings.bats index d907cee1..60db349f 100644 --- a/.github/actions/cve-scan/test/process_findings.bats +++ b/.github/actions/cve-scan/test/process_findings.bats @@ -250,6 +250,89 @@ JSON [[ "$preview" == *"Also detected: 1 high finding, 1 medium finding, 2 low findings below the notification threshold."* ]] } +# The blocking line is the alert's only statement of whether these findings +# actually fail the job, and nothing asserted it: swapping the two strings kept +# the whole suite green. + +@test "an advisory scan says the release was not blocked" { + write_findings <<'JSON' +{"findings":[{"id":"A","severity":"high","package":"stdlib","title":"Boom"}]} +JSON + SEVERITY_THRESHOLD=high BLOCK_ON_FINDINGS=false run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local preview + preview=$(grab_multiline_output slack-details) + [[ "$preview" == *"Advisory only — release was not blocked."* ]] +} + +@test "a blocking scan with findings says they fail the job" { + write_findings <<'JSON' +{"findings":[{"id":"A","severity":"high","package":"stdlib","title":"Boom"}]} +JSON + SEVERITY_THRESHOLD=high BLOCK_ON_FINDINGS=true run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local preview + preview=$(grab_multiline_output slack-details) + [[ "$preview" == *"Blocking enabled — these findings fail the job."* ]] +} + +# Blocking alone does not fail a job; it takes blocking and something at or above +# the threshold. Keyed on the input by itself, this promised a failure that the +# gate in run.sh was never going to deliver. +@test "a blocking scan with nothing at the threshold does not promise a failure" { + write_findings <<'JSON' +{"findings":[{"id":"A","severity":"medium","package":"stdlib","title":"Meh"}]} +JSON + SEVERITY_THRESHOLD=high BLOCK_ON_FINDINGS=true run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local preview + preview=$(grab_multiline_output slack-details) + [[ "$preview" == *"Blocking enabled — nothing at or above the threshold, so the job is not blocked."* ]] + [[ "$preview" != *"these findings fail the job"* ]] +} + +# The cap exists to bound the message, not to pick winners by name. Three +# single-finding packages sort ahead of the one that actually matters here. +@test "the package cap keeps the most-affected package, not the alphabetically first" { + write_findings <<'JSON' +{"findings":[ + {"id":"1","severity":"high","package":"aaa","title":"One"}, + {"id":"2","severity":"high","package":"bbb","title":"Two"}, + {"id":"3","severity":"high","package":"ccc","title":"Three"}, + {"id":"4","severity":"high","package":"zzz","title":"Worst A"}, + {"id":"5","severity":"high","package":"zzz","title":"Worst B"}, + {"id":"6","severity":"high","package":"zzz","title":"Worst C"}, + {"id":"7","severity":"high","package":"zzz","title":"Worst D"}, + {"id":"8","severity":"high","package":"zzz","title":"Worst E"} +]} +JSON + SEVERITY_THRESHOLD=high run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local preview + preview=$(grab_multiline_output slack-details) + [[ "$preview" == *"zzz · 5 findings"* ]] +} + +# slack-details is a declared output, so a caller can read it on a scan that has +# nothing at or above the threshold. The heading used to appear there regardless, +# announcing a list and then showing none. +@test "a scan with nothing at or above the threshold omits the findings heading" { + write_findings <<'JSON' +{"findings":[{"id":"A","severity":"medium","package":"stdlib","title":"Meh"}]} +JSON + SEVERITY_THRESHOLD=high run bash "$SCRIPT" + [ "$status" -eq 0 ] + + local preview + preview=$(grab_multiline_output slack-details) + [[ "$preview" != *"Findings at or above threshold"* ]] + [[ "$preview" == *"Also detected: 1 medium finding below the notification threshold."* ]] +} + @test "the report names a digest ref verbatim rather than mangling it" { IMAGE_REF="ghcr.io/loft-sh/vcluster-pro@sha256:abcdef1234567890" run bash "$SCRIPT" [ "$status" -eq 0 ] From f7a68b593c2a2f01c6aeb40591971911cbd6aff0 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Wed, 26 Aug 2026 18:37:12 -0600 Subject: [PATCH 8/9] test(cve-scan): pin the block-effective output action.yml reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit action.yml selects the alert's status off `block-effective`. An absent output makes `== 'true'` quietly false, reverting a blocking run to an amber advisory with nothing going red — the exact bug the output was added to fix, silently restored. notify-effective already carries this coverage; block-effective now matches it, across both tolerant spellings, the default, and a scanner error. --- .github/actions/cve-scan/test/run.bats | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/actions/cve-scan/test/run.bats b/.github/actions/cve-scan/test/run.bats index 8c0d286a..50283acf 100644 --- a/.github/actions/cve-scan/test/run.bats +++ b/.github/actions/cve-scan/test/run.bats @@ -547,6 +547,44 @@ EOF # whole point of the fix is that a caller's Slack if: can trust this output # even when the scan itself errored, since that's exactly when a page matters. +# action.yml picks the alert's status off block-effective. An absent output makes +# `== 'true'` quietly false, which reverts a blocking run to an amber advisory +# with nothing going red, so the output's existence is asserted rather than +# assumed — the same coverage notify-effective already carries. + +@test "block-effective carries every recognised truthy spelling" { + for v in true TRUE True " true " yes YES 1 on ON; do + BLOCK_ON_FINDINGS="$v" run bash "$SCRIPT" + [ "$(grab_output block-effective)" = "true" ] || { + echo "block-on-findings='$v' did not resolve to block-effective=true" + return 1 + } + done +} + +@test "block-effective carries every recognised falsey spelling" { + for v in false FALSE False " false " no NO 0 off OFF; do + BLOCK_ON_FINDINGS="$v" run bash "$SCRIPT" + [ "$(grab_output block-effective)" = "false" ] || { + echo "block-on-findings='$v' did not resolve to block-effective=false" + return 1 + } + done +} + +@test "block-effective defaults to false when the caller says nothing" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(grab_output block-effective)" = "false" ] +} + +@test "block-effective is set on a scanner-error outcome" { + echo "1" > "$ADAPTER_EXIT_FILE" + BLOCK_ON_FINDINGS=true run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(grab_output block-effective)" = "true" ] +} + @test "notify-effective is set on a scanner-error outcome" { echo "1" > "$ADAPTER_EXIT_FILE" NOTIFY=true run bash "$SCRIPT" From a4c4d1372c725a69dfa154f4e87e5f259c3323b9 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Fri, 28 Aug 2026 10:04:45 -0600 Subject: [PATCH 9/9] fix(cve-scan): pin character-safe notifier --- .github/actions/cve-scan/action.yml | 4 ++-- .github/actions/cve-scan/test/notification_pins.bats | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .github/actions/cve-scan/test/notification_pins.bats diff --git a/.github/actions/cve-scan/action.yml b/.github/actions/cve-scan/action.yml index 20bd957e..dee55b76 100644 --- a/.github/actions/cve-scan/action.yml +++ b/.github/actions/cve-scan/action.yml @@ -159,7 +159,7 @@ runs: always() && steps.scan.outputs.notify-effective != 'false' && steps.scan.outputs.has-vulnerabilities == 'true' - uses: loft-sh/github-actions/.github/actions/ci-test-notify@4255b2f7309cd1d35bedbe1f1c920ada0d4b0c16 # ci-test-notify/v1 + uses: loft-sh/github-actions/.github/actions/ci-test-notify@0157c66f3034718081fd02ad01009e1ebc760129 # ci-test-notify/v1 # Slack is not a gate. An outage, a rate limit or a rejected payload must # not fail the caller, or a notification system becomes able to block a # release — the same reason the registry login above is advisory. The Job @@ -184,7 +184,7 @@ runs: (steps.scan.outputs.scanner-error == 'true' || steps.scan.outcome == 'failure' || steps.scan.outcome == 'cancelled') - uses: loft-sh/github-actions/.github/actions/ci-test-notify@4255b2f7309cd1d35bedbe1f1c920ada0d4b0c16 # ci-test-notify/v1 + uses: loft-sh/github-actions/.github/actions/ci-test-notify@0157c66f3034718081fd02ad01009e1ebc760129 # ci-test-notify/v1 # Slack is not a gate. An outage, a rate limit or a rejected payload must # not fail the caller, or a notification system becomes able to block a # release — the same reason the registry login above is advisory. The Job diff --git a/.github/actions/cve-scan/test/notification_pins.bats b/.github/actions/cve-scan/test/notification_pins.bats new file mode 100644 index 00000000..bb88121f --- /dev/null +++ b/.github/actions/cve-scan/test/notification_pins.bats @@ -0,0 +1,11 @@ +#!/usr/bin/env bats + +MANIFEST="$BATS_TEST_DIRNAME/../action.yml" +CHARACTER_SAFE_NOTIFIER="0157c66f3034718081fd02ad01009e1ebc760129" + +@test "both Slack notifications use the character-safe notifier revision" { + expected="uses: loft-sh/github-actions/.github/actions/ci-test-notify@${CHARACTER_SAFE_NOTIFIER} # ci-test-notify/v1" + count="$(grep -Fc "$expected" "$MANIFEST" || true)" + + [ "$count" -eq 2 ] +}