feat(cve-scan): preview findings in Slack - #240
Conversation
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.
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
31e46d7 to
b4c69bc
Compare
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.
`"${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.
`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.
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.
|
/vcluster-review |
|
🔍 The agent swarm is reviewing PR #240. I'll post the results here when it's done. Admins can watch the run live |
loft-bot
left a comment
There was a problem hiding this comment.
Panel review: 3 blocking, 6 quality notes, 10 lanes.
Blocking concerns
.github/actions/cve-scan/action.yml:170— ablock-on-findings: truerun that fails the job alerts as an amber warning and gets no failure notification at all..github/actions/cve-scan/src/process-findings.sh:106— the three-package preview cap keeps the alphabetically first packages, silently dropping the most-affected one. Reproduced..github/actions/cve-scan/test/process_findings.bats:229— theBLOCK_ON_FINDINGSbranch is never exercised; swapping its two strings keeps the suite green.
The escaping work holds up: the security lane confirmed control-char stripping plus &/</> encoding closes both the mrkdwn mention-injection and the GITHUB_OUTPUT newline-forgery vectors, and write_multiline_output's random delimiter is the right shape for this repo.
What was checked
Checked: correctness, security, test-quality, operability, reuse, architecture, dead-code, typos, infra, PR metadata. Skipped: the four e2e lanes, gap analysis and entry-point fidelity (this repo has no e2e suite, and DEVOPS-1414 is an internal request rather than a customer bug), and the layout-coherence pass (not a restructure-shaped diff).
Adjacent / out-of-diff
- Open PR #160 adds an
infostatus to the samecase "$STATUS"block inbuild-payload.shand rewrites the sameHEADER=line to suppress an empty status suffix. Whichever merges second will conflict, and the two statuses are solving adjacent problems — worth coordinating before either lands.
Quality notes (non-blocking)
- consider —
.github/actions/cve-scan/src/process-findings.sh:144—slack-detailsis composed on every completed scan, but only reads correctly when a threshold finding exists, and that condition lives inaction.yml's stepif:, not here. With one low finding atseverity-threshold: highthe output is "Findings at or above threshold" followed by nothing. Gate the heading onTHRESHOLD_COUNT > 0and deriveBLOCKING_CONTEXTfrom the same pair run.sh gates on. This becomes blocking if a future caller readsslack-detailsoutside thatif:. - consider —
.github/actions/ci-test-notify/README.md— the prose is now stale against the action's own behaviour: "Notification gating" still says onlysuccessandfailurenotify (the diff addswarning), and the "Message format" block only shows the top layout, sorun-link-position: bottomis documented in the generated table and nowhere else. - consider —
.github/actions/cve-scan/action.yml:203— thehas-vulnerabilities == 'true' && 'FINDINGS…'arm of thedetails:ternary is unreachable now that this step requireshas-vulnerabilities != 'true'. Dropping it would make the ternary match the three cases the comment above it describes. - consider —
.github/actions/ci-test-notify/build-payload.sh:39—bottomdoes not only move the link, it re-renders it:topemitsBuild URL: <raw url>whilebottomemitsWorkflow: <url|View workflow run>. The input is documented as choosing placement only, so a caller opting into bottom silently gets different wording and markup. - nit —
.github/actions/ci-test-notify/build-payload.sh:53— the*)fallback duplicates thetop)arm verbatim;top|*)with the warning emitted before the shared body would remove the copy.
(+1 more: a nit on process-findings.sh:89, where SLACK_IMAGE's inline if length > 200 … re-derives the clip($limit) helper defined a few lines below.)
|
@vcauesantos ✅ Review finished for #240 |
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.
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.
Piotr1215
left a comment
There was a problem hiding this comment.
LGTM overall. I left one comment.
Summary
A CVE notification that carried only per-severity counts made every alert look alike, so triage always started by opening the workflow run. This puts the findings themselves in the Slack message.
cve-scanemits aslack-detailspreview of the findings at or aboveseverity-threshold, grouped by package, plus a one-line tally of what fell below it.ci-test-notifygains an advisory layout (warningstatus, andrun-link-position: bottomso the findings lead and the run link trails). Scanner, configuration and cancellation alerts keep the failure layout, since those really are failures.What a release scan looks like in Slack:
Things worth a reviewer's attention
Scanner strings reach a webhook. Package names and titles are influenced by an outside source, and Slack renders the section as mrkdwn, so each field has its control characters stripped, its
&,<and>encoded, and its length bounded before it can be sent. A literal channel mention in a CVE title cannot become a real ping, and there is a test asserting exactly that.Caps are ordered by weight, not by name. Three package groups, four titles per group. Both are sorted so the cap keeps what dominates the group. Otherwise a package with forty findings could be dropped in favour of one that happens to sort earlier alphabetically.
A marker that does not overstate severity. Groups are prefixed with a plain bullet rather than a red circle, which reads as "critical" in a CVE alert. The header icon carries the overall severity and each group states its own count.
The default posture is unchanged. Still advisory,
notify-eventsstill defaults toschedule,release, and no new inputs are required of existing callers.Slack is not a gate. Both
cve-scannotify steps arecontinue-on-error, so an outage, a rate limit or a rejected payload cannot fail the caller.slackapi/slack-github-actionruns witherrors: true, so without this a notification system could block a release, which contradicts the README's "a flaky scanner must never block a release".Backwards compatibility
ci-test-notifyhas roughly 30 call sites across nine repositories, so the default path had to stay exactly as it was. Verified by differential testing the old and new scripts against each other rather than by inspection:build-payload.sh, 11 scenarios per locale (every status including empty and unrecognised, empty / multiline / UTF-8 details, the section and header truncation paths): byte-identical payloads under a UTF-8 locale, which is what the runners use.should-notify.sh, 12 scenarios (every status crossed with set and empty webhook): gate decision unchanged.warningalready notified before this PR, because the gate only ever silencedcancelledandskipped. The old comment claimed otherwise, so this corrects the comment rather than the behaviour.The one deliberate difference is under a POSIX locale, where the old code measured Slack's character limits in bytes: a 2000-character preview was cut to 1036 characters with a U+FFFD left at the seam. That case now renders in full. It only affects messages that were already being truncated.
run-link-positionhas a default andwarningis additive, and nothing changes the caller's job environment, so this is a tag advance rather than a new major.Test plan
make test-cve-scanmake test-ci-test-notifymake lint(actionlint + zizmor)make check-docsprocess-findings.shintobuild-payload.shand inspected the resulting Slack blocks, which is how the output above was producedTwo defects were found by that last check rather than by the suite. Both are fixed, each with a regression test:
"${array[*]}"joins on the first character ofIFSonly, soIFS=', 'rendered the below-threshold tally as1 medium finding,1 low finding. The original test left exactly one severity below the threshold, which is the one case a broken separator still renders correctly.3000 - len(run link) - 2, could go negative, and a negative slice length in bash reads as "all but the last n". That would have overshot Slack's limit and lost the whole message to a rejection.Two notes for anyone re-running this locally:
errexitfor a bare[[ ]], so a mid-test assertion that fails is still reported asokand only the Ubuntu runner catches it. Trust CI over a local green here.make testis red on an unrelated, pre-existingcleanup-head-chartsassertion that is already failing onmain.Closes DEVOPS-1414