Skip to content

Make the CVE scan runnable unattended, reporting only what changed - #2143

Open
NickJosevski wants to merge 5 commits into
mainfrom
nj/cve-scan-runbook-support
Open

Make the CVE scan runnable unattended, reporting only what changed#2143
NickJosevski wants to merge 5 commits into
mainfrom
nj/cve-scan-runbook-support

Conversation

@NickJosevski

@NickJosevski NickJosevski commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Companion to Modern-Deployments#4,
which adds the Scan Calamari for CVEs runbook. That runbook cannot run until these changes
land — it invokes this script straight from main.

The runbook has now had real runs against it — results below.

Why

scan.sh reproduced a customer scan on demand, but only helped someone who thought to run it.
Two independent things change the answer:

  1. The artifact changes — a new build bundles a new .NET runtime.
  2. The world changes — a CVE is published against an artifact that has not moved.
    Nothing in this repo changes.

Only a schedule catches the second, and it is the one that bites: CVE-2026-44788 was absent
from the audit source at 10:45 and present by 15:00 on the same day.

The part that makes it an alert rather than a report

Running nightly and posting the output is useless — the same 1 medium CVE every morning is
something everyone mutes by week two. So the script now takes --previous-state, compares
against it, and exits 3 only when the set actually differs. Without it, exit 0 as before.

compare.py holds that logic; summarise.py collapses each version's raw scanner output
(the 43-matches-to-1-distinct fold this repo already documents).

Other changes

  • Several versions per run. Customers run old versions; the supported release tips are
    what show up in reports, not main.
  • --runner=docker|native. Containers by default so a local run needs no install. Inside
    an Octopus execution container the step is already in a container, so docker-in-docker is
    unavailable and the binaries are used from PATH.
  • Colour only to a real terminal. Octopus captures stdout into a task log, where escape
    codes appeared verbatim.
  • --octopus wires the above to output variables and run artifacts.
  • Runtime drift against Microsoft's releases index, flagged independently of any CVE.

Drift is folded into the stored state, deliberately

A runtime that permanently trails the current patch would otherwise fire an identical alert
every night — the exact failure this design exists to avoid. It is stored, so it alerts when
it appears, then goes quiet.

Verified

The alerting contract was tested in both directions with synthetic scanner output, since a
real scan takes ~10 minutes:

Case Expected Result
First run, no previous state exit 3, baseline reported
Same input, previous state supplied exit 0, silent
A new CVE appears exit 3, NEW: CVE-…
CVE fixed + runtime patched exit 3, gone: + runtime: 8.0.29 -> 8.0.30

Case 2 is the important one: it stayed silent while a real drift condition was present,
which is what proves the folding works. The drift check is live — it fetched Microsoft's
index and correctly identified 8.0.29 as trailing 8.0.30.

Known gap

The native scanner installs are unpinned. For a scheduled job that is weaker than it
should be: a scanner upgrade and a genuine new CVE look identical in the diff. TRIVY_VERSION
/ GRYPE_VERSION pin them; baking both into a pinned execution container image is the real
fix and is worth doing before this runs unattended for long.


Verified on the real instance

Two consecutive runs against 2026.3.508, using the runbook in Modern-Deployments:

Run A (no prior state) Run B (state populated)
Scan
Notify Slack ✅ posted ⏭️ skipped
Save state
HasNewFindings true false

Run B logged no change: same distinct CVE set, same bundled runtime. Both directions of
the alerting contract now hold against real scanner output, not just fixtures.

Real result for 2026.3.508: 6 distinct identifiers and 8.0.29, which trails the current
8.0.30. The README recorded this same artifact as 1 medium CVE on 2026-08-01 — nothing
in Calamari changed in between. That is the "world changes" driver, observed rather than
argued.

Trap worth recording

An Octopus API key is created as read or write scoped. A read-scoped key
authenticates, passes every GET, and fails writes with
Missing permission: LibraryVariableSetEdit — indistinguishable from the account lacking
the role. It cost an afternoon of chasing teams and role scoping. If a write 403s while the
permissions API insists the permission is granted, check the key's scope first.

NickJosevski and others added 4 commits August 18, 2026 12:54
The script reproduced a customer scan on demand, but only helped someone who thought
to run it. Two independent things change the answer: a new build bundling a new
runtime, and a CVE being published against an artifact that has not moved. The second
changes nothing in this repo, so only a schedule catches it.

Running it nightly is useless without state - the same finding every morning is an
alert everyone mutes by week two. So the script now takes a previous result, compares
against it, and exits 3 only when the set actually differs.

Scans several versions in one run, because customers run old versions and the tips of
the supported release branches are what show up in reports, not main.

The two scanners run as containers by default so a local run needs no install. Inside
an Octopus execution container the step is already in a container, so docker-in-docker
is unavailable and the binaries are used from PATH instead.

Runtime drift against Microsoft's releases index is folded into the stored state rather
than reported standalone. A runtime that permanently trails would otherwise fire an
identical alert every night.

Colour is now emitted only to a real terminal; Octopus captures stdout into a task log
where escape codes show up verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
__pycache__ was picked up from a local syntax check; it should never have been tracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Artifacts were registered from /tmp, which inside an execution container is gone by the
time Octopus collects them from the host. Under --octopus the work directory now defaults
to the step's working directory.

Exiting 3 to mean 'the set changed' failed the Octopus step, which skipped the notify and
save-state steps - exactly the case that most needs them. Under --octopus the script now
exits 0 and signals change through HasNewFindings. Outside Octopus exit 3 is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each identifier is now its own bullet and links to the advisory. GHSA ids go to GitHub
and CVE ids to NVD - both schemes appear because the two scanners name the same
underlying advisory differently.

The report is rendered twice: Slack gets bullets and links, the task log gets bare URLs,
which stay clickable in a terminal and readable in Octopus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run only scans the versions it was asked for. Writing just those back dropped every
other version's baseline, so an ad-hoc 'scan the version this customer reported' run
silently wiped the scheduled baseline and the next scheduled run re-alerted from scratch
as a first scan.

Found by a real run: scanning 2025.3.417 removed the stored 2026.3.508 entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NickJosevski
NickJosevski marked this pull request as ready for review August 19, 2026 04:03
@NickJosevski
NickJosevski requested a review from zentron August 19, 2026 04:17
@NickJosevski
NickJosevski enabled auto-merge (rebase) August 19, 2026 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant