| name | prisma-taurus |
| description | Use when the user wants to check, fix, or verify Prisma Cloud vulnerability scan results for the Taurus Docker image (blazemeter/taurus:unstable). Run this skill on Opus 5 (1M context) or newer — choosing and applying the correct CVE fix requires strong reasoning. |
Model requirement — Opus 5 (1M context) or newer
Before doing anything else, confirm the active model is Opus 5 (1M context) (claude-opus-5[1m]) or a later, more capable model. If it is not, STOP and tell the user to switch with /model and re-invoke /prisma-taurus — do not proceed on a weaker model.
This is a floor, not a pin. A newer or more capable Opus-class model than the one named here satisfies the requirement — say so and continue rather than stopping. Only an older or smaller model (Sonnet, Haiku, or a prior Opus generation) is grounds to stop. The 1M-context variant is part of the floor, not optional — this run reads a ~1000-line SKILL.md, a ~450-line history file and a 300+ row scan CSV, so a 200k-context Opus does not qualify.
If a newer model has made the named floor stale, say so in your first message and proceed — do not edit this file mid-run to bump the version; propose that change to the user as its own commit.
Why: this skill makes real, hard-to-verify decisions — classifying each CVE by Path, choosing the right fix mechanism, recognizing traps like the Ruby default-gem problem or scanner appeasement, and reasoning about whether a fix will actually land in the image before opening a PR. Weaker models miss these and produce fixes that silently don't work. Skills run on the session's active model (there is no frontmatter field that forces a model), so the model must be set by the user before running.
Overview
End-to-end vulnerability management for the Taurus Docker image. Fetches the latest Prisma Cloud scan, classifies findings into auto-fixable and manual categories, applies all safe fixes, runs unit tests, gets an independent local code review (if a review skill is available) and amends accepted fixes before pushing, then builds the fixed branch into a Docker image and re-scans it to confirm the fixes actually reduce vulnerabilities in the real image. A PR is opened only after that branch scan shows fewer vulnerabilities than the baseline — never at an early stage; the PR then requests a GitHub Copilot review and triages it once, and finally posts a "ready for review" summary to Slack (best-effort).
Why verify before the PR: unit tests do not exercise the Dockerfile (gem/npm/apt changes) and a fix that "looks" applied can be a no-op against the scanner (e.g. gem update installs a patched gem but leaves the vulnerable Ruby default-gem version on disk, which Prisma still reports). The taurus-branch-builder Jenkins job can build any branch into an image and scan it with Prisma — so the fixes are proven in the image before a PR is created, not assumed.
When to Use
- User asks about Taurus CVEs, vulnerabilities, or security scan results
- User wants to fix vulnerabilities found in
blazemeter/taurus
- User wants to verify that a previous fix resolved a CVE
- User asks to run or check the Prisma scan for Taurus
Common Mistakes
- Triggering
taurus-community-master (the publish job) without explicit approval — it overwrites the public unstable tag and notifies the team channel. "I want the CVE gone" is NOT approval to publish; ask, state the side effects, and wait for a yes
- Trying to publish with
taurus-branch-builder — it only pushes to internal GCR, never Docker Hub, so its image can't be consumed by taurus-cloud (FROM blazemeter/taurus). Nothing can promote a branch image; only taurus-community-master publishes
- Reporting "nothing to do" when the real answer is "rebuild" — a finding already covered by an unpinned Dockerfile line, whose patch was published after the image was built, needs no code change but is not "nothing". Classify it as R (category 4) and say so
- Triggering a new scan when a valid one already exists (one already ran today (UTC) and after the last
unstable push) — always check first
- Fixing JMeter/Gatling-bundled CVEs — any finding whose
Path is under /root/.bzt/jmeter-taurus/ or /root/.bzt/gatling-taurus/ is out of scope. Do not repin jars and do not bump JMeter.VERSION / Gatling.VERSION (a version bump is a JMeter/Gatling fix). List them only.
- Auto-fixing scanner appeasement CVEs (
.deps.json / Roslyn paths) — these must always be flagged as manual. Note /var/lib/dpkg/status is NOT appeasement — it's the normal path for every OS/apt package; see category 2
- Assuming a package is uncovered because it's absent from the
--only-upgrade list — it may be installed unpinned elsewhere in the Dockerfile, including as an automatic dependency of something else, which makes the finding R. Prove provenance from /var/log/apt/history.log before adding an entry (see "Prove provenance" under OS packages)
- Removing the worktree after a test failure — leave it in place for the user to investigate
- Opening the PR before the branch scan confirms a reduction — push the branch, build+re-scan it via
taurus-branch-builder, compare against the baseline, and create the PR ONLY if vulnerabilities went down. The PR comes only after that verification — never early.
- Trusting that a Dockerfile fix landed just because the build succeeded — always confirm in the branch scan that each fixed package's vulnerable version is actually gone. on a Ruby (e.g. , ) installs the patched version but leaves the old default-gem version on disk, which the scanner still reports.
Image target
| Image | Registry | Tag | Artifact |
|---|
blazemeter/taurus | Docker Hub (public) | unstable | taurus.csv (CSV) |
unstable is always the latest master build — pushed by Jenkins on every merge to master. This is the image to scan before a release.
Credentials
Read from environment variables — never prompt, never hardcode:
JENKINS_USERNAME
JENKINS_TOKEN
GITHUB_TOKEN
If any of the three above is missing, stop and tell the user which one is absent.
Optional — Slack notification (step 17 only):
SLACK_BOT_TOKEN
SLACK_SPARTA_CHANNEL_ID
These two are optional and best-effort: if either is unset, the skill skips the Slack post (step 17) and continues — it never blocks the run and never prompts for them. (The bot must be a member of the channel, or the post returns not_in_channel.)
Jira (used by the ticket-creation step before the PR) is accessed through the Atlassian MCP, not an env var — it authenticates as the developer running the skill (that's how the ticket gets assigned to them). No token to set. Note: interactively-authenticated MCP servers may be absent in headless/cron runs; if the Atlassian tools aren't available, create the PR without a Jira ticket and flag that the ticket must be created manually — don't block the PR.
Fix classification rules
Before applying any fix, classify each CVE — first through the category-0 pre-filter, which keys on Fix Status rather than Path, then by its Path (for everything else the path is the definitive identifier of what the vulnerable component belongs to). Apply the categories in this order — the first match wins, with one exception: a finding that matches category 3 (auto-fixable) must ALSO be tested against category 4 (rebuild-clearable) before any fix is applied. Category 4 is not reached by first-match-wins — it is a mandatory gate on category 3, because an R finding looks exactly like an auto-fixable one and "fixing" it produces a brittle pin for something already handled.
0. Pre-filter — not fixable in a standard build → straight to Z, before any path matching. These are recognised by Fix Status or by the kind of component rather than by directory, so a path-based category would never catch them. Screening them out first is what keeps the OS row of category 3 from being handed something apt can't install:
- Ubuntu Pro ESM — the only fixed version(s) offered end in
+esmN or ~esmN (e.g. 7:6.1.1-3ubuntu5+esm5). If a non-ESM fixed version is also listed, use that one and do not pre-filter. Not installable without an Ubuntu Pro subscription; plain apt-get fails and --only-upgrade cannot reach it. 55 of the 165 OS findings in scan #216 were this (ffmpeg ×39, gnuplot ×7, python-pip ×3, openexr ×3, gst-plugins-bad1.0, qtbase-opensource-src, fonttools) — i.e. the majority of OS findings with a fixed in version. Do not add these to the upgrade list.
- Go libraries compiled into a binary —
Path is /usr/bin/k6 (net/http, crypto/tls, encoding/xml, …). Only an upgrade of the k6 apt package can change them, never a library-level fix.
- JDK CVEs whose only fix is a later Java major —
Path under /usr/lib/jvm/… with Fix Status like fixed in 22.0.1, 18, 12 (no 11.x fix exists). Unreachable by apt --only-upgrade; moving JMeter off OpenJDK 11 is a runtime-compatibility decision, not a CVE-run change.
- The distro (apt)
pip — Path /usr/lib/python3/dist-packages/pip-*.dist-info. Noble's pip fixes are +esmN or a pip version apt doesn't carry.
Note the distinction from category 4: these have no reachable fix at all (monitor), whereas an R finding's fix exists and lands by itself on a rebuild. Both mean "no code change", for opposite reasons. Full rationale in vulnerability_history.md → "Findings that are NOT fixable in a standard build".
1. JMeter / Gatling bundled → DO NOT FIX, monitor only:
Path contains /root/.bzt/jmeter-taurus/ (e.g. /root/.bzt/jmeter-taurus/5.5/lib/tika-core-1.28.3.jar)
Path contains /root/.bzt/gatling-taurus/ (e.g. /root/.bzt/gatling-taurus/3.9.5/lib/netty-codec-http-4.1.92.Final.jar)
These jars (netty, log4j, tika, batik, xstream, jackson, logback, pebble, dnsjava, json-smart, json-path, commons-*, etc.) ship inside the JMeter/Gatling distributions. Taurus does not pin them individually, and we do not remediate them — not by replacing jars, and not by bumping the JMeter or Gatling version (a version bump is itself a JMeter/Gatling fix, only partially clears the CVEs, and carries runtime/behavioral risk). Display them in the report under "JMeter/Gatling — not fixed (out of scope)", never patch them.
2. Scanner appeasement → flag as manual, never auto-fix:
Path contains /usr/share/dotnet/sdk/ (Roslyn / .deps.json metadata patches)
⚠️ /var/lib/dpkg/status is NOT in this category — it is the normal path for every OS/apt package. This scanner reports all OS findings at /var/lib/dpkg/status (verified: scans #204, #215, #216 contain zero empty-path rows and 135/153/165 dpkg-path rows respectively). Treating that path as appeasement writes off every OS finding in the scan — in #216 that would have been 165 of 316 rows, including 16 with a real, non-ESM fixed in apt version (71 before removing Ubuntu Pro ESM). Route these to the OS package row of category 3 (then the category-4 gate), exactly like the libheif worked example in category 4 — whose finding also sat at /var/lib/dpkg/status.
What genuinely belongs here is the action, not the path: do not sed-patch /var/lib/dpkg/status to change a version string and silence the scanner (see the Firefox patch at Dockerfile:118-121 — the pattern not to repeat; vulnerability_history.md commit 5 argues it should not have been done). A Prisma Cloud suppression is the preferred route for a genuine false positive; the sed is a gated last resort, reachable only through step 18.
3. Auto-fixable → apply automatically (remaining CVEs where Fix Status starts with fixed in):
⚠️ Gate first: before applying anything from this table, run the category-4 (rebuild-clearable) check below. If the finding is R, it needs no change at all — stop here for that finding.
| Path pattern | Fix type | Where to fix |
|---|
/root/.bzt/newman/node_modules/ | npm override | Dockerfile newman package.json printf block |
/root/.bzt/selenium-taurus/mocha/node_modules/ | npm override | Dockerfile mocha package.json printf block |
/root/.bzt/selenium-taurus/*/node_modules/ | npm direct package | bzt/modules/javascript.py PACKAGE_NAME constant |
/usr/lib/node_modules/npm/node_modules/ | npm-internal bundled dep | Check the latest npm's lockfile: if it bundles the fixed version → R (the existing npm i -g npm@11 picks it up on rebuild); if it still bundles the vulnerable version → monitor (no upstream fix reachable). There is no clean in-place override (see "npm-internal bundled deps" section) |
/usr/local/rbenv/ or /usr/local/lib/ruby/ | Ruby gem | Dockerfile gem install <gem> -v <fixed> + default-gem/cache cleanup (see Ruby gems section) |
/var/lib/dpkg/status (the usual case), OS path (/usr/lib/, /lib/), or empty path — excluding /usr/lib/node_modules/npm/node_modules/ (npm-internal, see row above) | OS package | Apply the category-0 pre-filter, then the category-4 (R) gate — provenance before date (see step 0 of the OS packages section). Most OS findings are R. If not R → add the package to the Dockerfile's existing unpinned --only-upgrade list (see OS packages section) |
Python pkg, Path under /usr/local/lib/python3.x/... | Python package | requirements.txt (feeds the wheel's install_requires) or a dedicated Dockerfile pip install step — classify into 5 cases, see Python section |
Python pkg, Path under /usr/lib/python3/dist-packages/... |
Note: the /root/.bzt/jmeter-taurus/* and /root/.bzt/gatling-taurus/* paths are intentionally absent from this auto-fixable table — they belong to category 1 (do not fix). Do not reintroduce JMeter/Gatling jar fixes or version bumps.
CVEs where Fix Status is not fixed in ... (e.g. needed, deferred, or empty) → display only, do not attempt to fix.
4. Rebuild-clearable (R) → NO code change; a republish of current master clears it.
A finding can be fixed in <v> and fully handled by the repo already, yet still show up — because the image is older than the patch. The Dockerfile resolves several things at build time rather than pinning them (every unpinned apt-get install — whose automatic dependencies are resolved fresh too — plus apt-get install --only-upgrade <pkgs> with no version pins, npm i -g npm@11, bzt -install-tools, FROM ubuntu:24.04), and Jenkinsfile builds with --no-cache. So identical code produces a different image at a different time. Nothing to fix — the fix is already in the Dockerfile; the published image just predates it.
Classify a finding as R when ALL of these hold:
Fix Status starts with fixed in, and
- the package is already covered by an unpinned line — it's in the
--only-upgrade list, or is installed unpinned by any earlier apt-get install in the Dockerfile, including as an automatic dependency that is never named there (prove this per step 0(a) of the OS packages section; this is the source most often missed), or comes from npm i -g, bzt -install-tools, or the base image — and
- a release containing the fix exists upstream now and the unpinned line would pick it up on a rebuild, and
- the fixed version was published after the scanned image's build started.
R vs monitor — the distinguishing test. Both mean "no code change", so keep them apart: the fix exists now and an existing unpinned line would install it → R (a rebuild clears it). The fix does not exist in any release that line would pick up → monitor (nothing clears it yet). Example: undici was monitor while npm bundled the old version, and became R once an npm release bundled the fix. In scan #203 the npm-internal findings (ip-address, brace-expansion, undici, tar) were monitor, because npm 11.19.0 and 12.0.2 both still bundled the vulnerable versions.
Compare against the BUILD START, not the push. The publishing pipeline runs the Prisma scan and the full integration suite between docker build and docker image push — 30–60 min. A patch published 20 minutes before the push is still absent from the image, so using the push time misclassifies it as "not R" and sends you down the pin path.
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/taurus-community-master/api/json?tree=builds%5Bnumber%2Ctimestamp%2Cresult%5D" \
| python3 -c "import json,sys,datetime; d=json.load(sys.stdin); [print(b['number'], datetime.datetime.utcfromtimestamp(b['timestamp']/1000)) for b in d['builds'][:5]]"
curl -s "https://api.launchpad.net/1.0/ubuntu/+archive/primary?ws.op=getPublishedBinaries&binary_name=<binary-pkg>&exact_match=true&version=<fixed-version>&status=Published" \
| python3 -c "import json,sys; [print(e['pocket'], e['date_published']) for e in json.load(sys.stdin)['entries']]"
Expect several rows (one per architecture, and per Security/Updates pocket) — that's normal; read the earliest date_published. Get the binary name from the Dockerfile's --only-upgrade list (that's the name apt installs).
Evidence required per source — an R claim without evidence is not allowed:
| Source | Evidence that the fix exists and post-dates the build |
|---|
| OS package (apt) | Launchpad date_published (above) |
npm-internal (npm i -g npm@11) | the npm/cli lockfile check in the "npm-internal bundled deps" section — confirm the latest npm bundles the fixed version |
bzt -install-tools npm packages | the registry's published version + its release date |
base image — only after dpkg-query confirms the base image actually ships the package (step 0(a) of the OS packages section); otherwise use the "OS package (apt)" row | the ubuntu:24.04 tag's own push time on Docker Hub |
Decide — and bias every ambiguity toward changing nothing:
- fix published after build start → R. Report it; do not add a version pin (brittle — the build breaks when Ubuntu supersedes that exact version and drops it from the pocket).
- fix published before build start and still flagged → NOT R. The fix isn't landing (stale layer, or the package isn't in the unpinned list). Treat it as auto-fixable and act — add the package to the existing unpinned list.
- evidence missing, empty (
entries: []), or the date falls inside the build→push window → INCONCLUSIVE. Classify as monitor, change nothing, and say it was inconclusive. Never resolve ambiguity by adding a pin.
Worked example (2026-08-20, scan #203): libheif 1.17.6-1ubuntu4.6 flagged, fixed in 1ubuntu4.7. libheif1 was already in the unpinned upgrade list, and all three timestamps line up in this order on 2026-08-19: the image's build started 12:06:22Z (taurus-community-master #14894), it was pushed 12:30:07Z, and only then did noble-security publish the fix at 12:36:56Z — i.e. the patch did not yet exist while the image was being built, so no version of that image could contain it. Correct action: zero code changes, republish. Confirmed by scan #204 on the republished image: 295 → 294, libheif gone, nothing new.
Steps
1. Verify credentials
: "${JENKINS_USERNAME:?JENKINS_USERNAME is not set}" && \
: "${JENKINS_TOKEN:?JENKINS_TOKEN is not set}" && \
: "${GITHUB_TOKEN:?GITHUB_TOKEN is not set}" && \
echo "credentials OK"
2. Check if last scan is still valid
curl gotcha (-g): Jenkins tree= queries contain [ and ], which curl treats as a glob range — an un-escaped one fails with bad range in URL. The commands below URL-encode the brackets (%5B/%5D) and so are safe as written. If you ever write the brackets literally (as the reference table does for readability), add -g (globoff): curl -sS -g -u "$JENKINS_USERNAME:$JENKINS_TOKEN" ".../api/json?tree=foo[bar]".
Get the timestamp when unstable was last pushed to Docker Hub:
curl -s "https://hub.docker.com/v2/repositories/blazemeter/taurus/tags/unstable" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d['last_updated'])"
Find the most recent successful Jenkins scan for taurus:unstable:
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/prisma-cloud-ondemand-scan/api/json?tree=builds%5Bnumber%2Ctimestamp%2Cresult%2Cactions%5Bparameters%5Bname%2Cvalue%5D%5D%5D"
Look for the most recent build where:
result is SUCCESS
- The
IMAGE_URL parameter value is blazemeter/taurus:unstable
The scan is valid only if BOTH are true:
- Scan timestamp is after the
unstable image push timestamp (correctness — the scan must reflect the current master image)
- Scan ran today (same UTC calendar day as now) — freshness, since CVE feeds update daily and a re-scan of an unchanged image surfaces newly-disclosed CVEs
If valid → skip to step 4 using that build number.
Otherwise (no scan today, even if the image is unchanged) → continue to step 3 and trigger a fresh scan.
First check if a scan is already running:
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/prisma-cloud-ondemand-scan/api/json?tree=builds%5Bnumber%2Ctimestamp%2Cresult%2Cbuilding%2Cactions%5Bparameters%5Bname%2Cvalue%5D%5D%5D"
If the most recent build has building: true and IMAGE_URL is blazemeter/taurus:unstable → tell the user a scan is already running, wait for it (poll every 20 seconds), then use that build's result. If it finishes with FAILURE → stop and give the user the console URL.
3. Trigger a new scan (one time only — no retries)
curl -s -o /dev/null -D - -X POST \
-u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/prisma-cloud-ondemand-scan/buildWithParameters?IMAGE_URL=blazemeter/taurus:unstable"
Capture the Location: header — it contains the queue item URL (e.g. .../queue/item/XXXXX/).
Wait for build number by polling the queue item every 10 seconds until executable.number appears:
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/queue/item/<ID>/api/json"
Wait for the build to finish by polling every 20 seconds. Tell the user the build number and that you are waiting (typically 10–20 minutes):
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/prisma-cloud-ondemand-scan/<BUILD>/api/json?tree=building,result"
If result is FAILURE → stop and give the console URL:
https://blazect-jenkins.blazemeter.com/job/prisma-cloud-ondemand-scan/<BUILD>/console
4. Fetch the scan artifact
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/prisma-cloud-ondemand-scan/<BUILD>/artifact/taurus.csv"
5. Parse, classify, and display
Order by severity: critical → high → medium → low/unassigned, then by CVSS descending within each severity.
Display a table with columns:
- CVE ID
- Severity
- CVSS
- Package + Version
- Fix Status
- Fix Type (
auto-fix / rebuild-clearable / jmeter-gatling (out of scope) / manual / no fix available)
Reporting rule — lead with "fixable detected → fixed", never the raw total
The raw scan total (e.g. 290) is misleading as a headline: the large majority of findings are things this repo can never fix (JMeter/Gatling bundled jars incl. every critical, k6 binary internals, Ubuntu ESM, no-released-patch), so "fixed 20 of 290" makes a complete run look like it did almost nothing. Every report and PR must lead with what taurus could actually act on — not the raw total. (The Jira ticket is the exception: it is a task, not a report — see step 15's field table. It states the problem and what to do, never a <X> detected → <Y> fixed summary.)
Headline (use in the run report and the PR body — not the Jira ticket):
Fixable in taurus, this run: <X> detected → <Y> fixed.
Rebuild-clearable: <R> — no code change needed; a republish of current master clears these (omit the line when R = 0).
Counting basis — X and Y are distinct CVEs:
- X = distinct in-scope fixable-in-taurus CVEs at baseline:
Fix Status starts with fixed in, AND NOT JMeter/Gatling, AND NOT Ubuntu Pro ESM (only fixed version(s) ending +esmN/~esmN), AND NOT a binary-internal lib (k6 Go libs under /usr/bin/k6), AND NOT distro pip apt can't reach, AND NOT a JDK finding whose only fix is a later Java major (/usr/lib/jvm/…, fixed in 22.0.1, 18, 12 — no 11.x fix exists), AND NOT scanner-appeasement (.deps.json/Roslyn — those are manual, classification category 2, and belong in Z). A /var/lib/dpkg/status path does NOT exclude a finding from X — that is the normal OS/apt path, so such findings are in scope and usually resolve as R (see category 2). (Includes the items we then defer/skip for cause — setuptools-vendored, breaking npm major jumps, low-value — so the denominator is honest about what was considered.)
- Y = the subset fixed by a change made this run to this repo's own
Dockerfile / requirements.txt, whose old version is confirmed gone in the branch re-scan. A finding that cleared without any change this run (an existing unpinned line or an npm@11 bump picking the fix up) is R, not Y — even though the branch scan shows it gone, which it will either way. If Y < X, list which didn't land and why.
Every finding lands in exactly one bucket so nothing is silently omitted:
- Fixed (Y) — verified gone in the branch image.
- Fixable but did NOT land (X − Y) — deferred/skipped for cause, or attempted-but-failed. R (rebuild-clearable) is a labeled subset of this bucket, not a fourth bucket — an R finding is in X (it is fixed-in and in scope) and not in Y (no code change landed it this run), but it is NOT "deferred for cause": label it "clears on rebuild, no code change". So
X, Y and Z still partition every finding; R only tags some of X − Y, and must never be added to X, Y or Z as an extra count.
- Not actionable in this repo (Z) — collapse into ONE categorized count, never a per-CVE scoreboard: monitor-only JMeter/Gatling bundled jars (incl. criticals), k6 binary internals, JDK-later-major-only, distro pip, no-released-patch (open/needed/deferred), Ubuntu ESM, scanner-appeasement/manual (
.deps.json/Roslyn). (In-scope OS/apt findings at /var/lib/dpkg/status belong in X, not here.)
taurus IS the base image — there is no "base image tag bump" and no "base-owned (cleared by a base tag)" bucket; those exist only in taurus-cloud (which builds FROM blazemeter/taurus). Omit them here.
Show the raw all-severity total only as a parenthetical for reconciliation, with at least crit/high (e.g. "raw scan: baseline 290 → branch 269 [crit 4→4, high 44→44]"). X/Y are distinct CVEs; the raw total counts per-occurrence and is much larger — do NOT assert an arithmetic identity between them. Treat the raw total as context, not a checksum.
Presentation only. This framing changes reporting, not the decision gate: step 15's gate still keys on the all-severity branch-scan total going down, not on X/Y.
You may still display the full per-CVE table (ordered critical → high → medium → low, CVSS desc) for completeness, but the summary sentence above the table must use the fixable-detected/fixed framing.
6. Collect auto-fixable CVEs
Gather all CVEs where:
Path is NOT under /root/.bzt/jmeter-taurus/ or /root/.bzt/gatling-taurus/ (those are out of scope — category 1)
Fix Status starts with fixed in
- Path does NOT match scanner appeasement patterns (
/usr/share/dotnet/sdk/ only — not /var/lib/dpkg/status, which is the normal OS/apt path; see category 2)
- The only fixed version(s) do NOT end in
+esmN/~esmN (Ubuntu Pro ESM — apt-get cannot install it), and the finding is not k6-binary-internal, JDK-later-major-only or distro pip (see category 0)
If there are no auto-fixable CVEs → report that and list any manual items. ("No auto-fixable CVEs" means after the category-4 gate has run: R findings start out looking auto-fixable and are removed from that set by the gate, so a run can legitimately reach this branch with R > 0.)
Before stopping, report any R (rebuild-clearable) findings — see classification category 4. "Nothing to fix in the repo" and "a republish removes N CVEs" are both true at the same time, and stopping without mentioning R leaves a real, zero-code win on the table (and leaves taurus-cloud consuming a stale image). If R > 0, report it and follow "Republishing to clear R findings" below instead of just stopping.
7. Apply all auto-fixes
Work in priority order: critical → high → medium → low. For each CVE apply the fix according to the classification table above.
Before adding new fixes — prune stale ones
Past runs left temporary fixes in the Dockerfile and requirements.txt: npm overrides blocks, forced gem install + default-gem purges, apt-get install --only-upgrade pins, .deps.json/dpkg sed patches, and any pip install step outside the normal requirements install. Before adding anything new, re-evaluate each one:
- Does the CVE it was added for still appear in the current baseline scan (step 5)? If the parent package or base image now ships the fixed version (the CVE is gone from the baseline), remove that line on the fix branch. The branch re-scan (step 14) is the safety net — if a removal regresses, the scan catches it before the PR.
- Is the entry inert? An
--only-upgrade entry for a package that is also installed unpinned earlier in the same stage can never fire in the --no-cache master build (see step 7's OS-packages step 0). Confirm with the provenance check, and note that the branch re-scan cannot validate this one — an inert entry and a working one look identical in the scan. Verify by re-reading /var/log/apt/history.log on the rebuilt image instead.
- If the CVE is still present → leave the fix in place.
Check vulnerability_history.md for any recorded removal condition before deciding.
Convention — mark every temporary fix with its removal condition. Each temporary fix this skill adds must carry a # remove when <upstream condition> comment (e.g. # remove when nodejs ships npm with glob >= 11.1.0) so a future run can find and re-evaluate it. Where the comment goes depends on the fix:
- Dockerfile
RUN steps (gem installs, apt upgrades, sed patches, extra pip install) → put the # remove when … line directly above the RUN.
- JSON-based fixes (npm
overrides seeded via a printf'd package.json) → JSON has no comment syntax, so put the marker on the surrounding Dockerfile RUN/echo line that writes the file, and record the per-package removal conditions in vulnerability_history.md.
A self-pruning runtime guard — like the Newman NEWMAN_VERSION = 6.2.2 check that skips the override once Newman moves — satisfies this too. When the condition is non-obvious, also record it under the relevant recipe in vulnerability_history.md.
Python packages — classify by Path (not every Python fix is requirements.txt)
Decide the mechanism from the finding's Path:
- Pinned in
requirements.txt — Path under /usr/local/lib/python3.x/... and the package name is in requirements.txt → bump the existing pin. Extract the fixed version from Fix Status (fixed in 2.7.0 → 2.7.0):
urllib3==2.6.3 → urllib3==2.7.0
- Transitive dep not in
requirements.txt — Path under /usr/local/lib/python3.x/... but the package name is absent from requirements.txt → add an explicit pin to requirements.txt. It feeds the wheel's install_requires (see setup.py), so the Dockerfile's pip install /tmp/bzt*.whl step resolves the fixed version into the image. If the requirements resolver can't accommodate the pin, use a dedicated Dockerfile pip install step instead (case 3). Do not rely on PipInstaller.pip_constraints (bzt/modules/services.py) — that constraints file only governs packages bzt installs at runtime (pip install -t <target>), not the image site-packages the scanner reads.
- Resolver constraint blocks the pin — another dependency caps the version, so re-resolving
requirements.txt fails → add a dedicated pip install <pkg>==<fixed> step in the Dockerfile right after the BZT-install pip step, with a # remove when <condition> marker (see the pruning convention above).
- Vendored inside setuptools —
Path contains /setuptools/_vendor/... → cannot be pinned directly; only bumping the pinned setuptools== in requirements.txt clears it. Bump setuptools if a patched, compatible version exists; otherwise skip a low-value vendored finding and list it.
- System / distro Python —
Path under /usr/lib/python3/dist-packages/... → not a requirements.txt fix; the package came from apt, not pip. Upgrade or remove it in the Dockerfile (apt-get install --only-upgrade <pkg> or apt-get purge <pkg>), exactly like an OS package.
JMeter / Gatling jars — DO NOT FIX
Any CVE whose Path is under /root/.bzt/jmeter-taurus/ or /root/.bzt/gatling-taurus/ is out of scope (classification category 1). Do not:
- replace or repin individual bundled jars, and
- bump
JMeter.VERSION / Gatling.VERSION in bzt/modules/jmeter.py / bzt/modules/gatling.py to refresh them.
A version bump is itself a JMeter/Gatling change: it only partially clears the jar CVEs (e.g. netty would still be below the fixed 4.1.135), carries runtime/behavioral risk that unit tests don't catch, and can trip the coverage gate. Simply list these findings in the report under "JMeter/Gatling — not fixed (out of scope)" and move on. (The JarTool constants in bzt/modules/java/tools.py — TestNG, JUnit, etc. — are also JMeter-side tooling; leave them alone too.)
npm direct packages (bzt/modules/javascript.py)
Each npm tool has a PACKAGE_NAME constant:
class Mocha(NPMPackage):
PACKAGE_NAME = "mocha@11.7.5"
class Newman(NPMPackage):
PACKAGE_NAME = "newman"
For a versioned constant (e.g. Mocha), bump the version after @ to the fixed version from Fix Status. Newman's PACKAGE_NAME has no version ("newman"), so it tracks npm's latest and can't be pinned here — its transitive-dep CVEs are handled by the Dockerfile overrides block (guarded on Newman 6.2.2), see the npm overrides section below. (Confirm the actual constant values in bzt/modules/javascript.py before editing — they change as packages are bumped.)
npm transitive dependency overrides (Dockerfile)
When a CVE is in a transitive dependency of Newman or Mocha that cannot be fixed by bumping the parent package, use npm overrides.
Newman (/root/.bzt/newman/node_modules/):
Find the existing printf block in the Dockerfile that seeds /root/.bzt/newman/package.json. It looks like:
RUN npm install newman --prefix /tmp/newman-check --silent \
&& NEWMAN_VERSION=$(node -p "require('/tmp/newman-check/node_modules/newman/package.json').version") \
&& rm -rf /tmp/newman-check \
&& if [ "$NEWMAN_VERSION" = "6.2.2" ] && [ ! -f /root/.bzt/newman/package.json ]; then \
echo "Applying Newman dependency overrides for CVE fix (...)" \
&& mkdir -p /root/.bzt/newman \
&& printf '{\n "overrides": {\n "existing-pkg": "^X.Y.Z",\n ...\n }\n}\n' > /root/.bzt/newman/package.json; \
...
fi
Add the new package to the overrides object inside the printf string. Update the echo message to mention the new package.
Mocha (/root/.bzt/selenium-taurus/mocha/node_modules/):
Find the printf block that seeds /root/.bzt/selenium-taurus/mocha/package.json and add the override there.
npm-internal bundled deps (Dockerfile) — /usr/lib/node_modules/npm/node_modules/
Deps bundled inside the global npm CLI itself (undici, glob, cross-spawn, …), installed by npm i -g npm@N. The only safe fix is to bump npm; otherwise monitor.
- Bump npm. Check what the latest npm bundles (it's pinned in npm's own lockfile):
curl -s https://raw.githubusercontent.com/npm/cli/v<NPM_VERSION>/package-lock.json \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['packages'].get('node_modules/<dep>',{}).get('version'))"
If the latest npm bundles the fixed version → the existing npm i -g npm@11 clears it on rebuild.
- If the latest npm still bundles the vulnerable version → MONITOR. Do not try to patch it in place:
cd /usr/lib/node_modules/npm && npm install <dep>@<fixed> does not work — it makes npm re-resolve its own package.json, which references unpublished workspace deps (@npmcli/docs), and the build fails with a registry 404. Verified failing in taurus build #571 and taurus-cloud MOB-51270 build #1.
- The only mechanism that would replace the dep is copying a temp-installed folder over npm's bundled one — that's the brittle "manual unpack" the cross-spawn lesson (commit #4) warns against. Don't.
- So the correct action is monitor: re-check each run; the fix lands automatically once an npm release bundles the patched dep and
npm i -g npm@11 picks it up.
.NET SDK bump (Dockerfile)
When a CVE is in a .NET SDK component:
-
Fetch the release metadata to find the latest 8.x SDK with security fixes:
curl -s "https://dotnetcli.azureedge.net/dotnet/release-metadata/8.0/releases.json" \
| python3 -c "
import json,sys
data = json.load(sys.stdin)
latest = data['releases'][0]['sdk']
print('version:', latest['version'])
print('url linux-x64:', [f['url'] for f in latest['files'] if f['rid']=='linux-x64' and f['name'].endswith('.tar.gz')][0])
print('hash:', [f['hash'] for f in latest['files'] if f['rid']=='linux-x64' and f['name'].endswith('.tar.gz')][0])
"
-
Update the DOTNET_URL and DOTNET_SHA512 values in the Dockerfile FROM system-deps AS runtimes stage.
-
Update ALL hardcoded SDK version strings in the sed commands below (the .deps.json metadata patch lines) to match the new SDK version number.
OS packages (Dockerfile)
When a CVE is in a system package (path is /var/lib/dpkg/status, empty, or in /usr/, /lib/, /bin/):
-
Run the category-4 (R) gate first — in this order, because (a) selects which evidence row (b) uses. Only continue past this step if the finding is NOT R.
(a) Prove provenance — where is this package actually installed from? Do NOT reason from the Dockerfile text alone. R requires the package to be "already covered by an unpinned line", and absence from the --only-upgrade list does not mean absence of coverage: a package can be installed unpinned by any earlier apt-get install, including as an automatic dependency that is never named in the Dockerfile. Ask the image, not the source:
docker image inspect --format '{{.Id}}' blazemeter/taurus:unstable
docker run --rm --entrypoint sh blazemeter/taurus:unstable -c \
"grep -B4 '<pkg>:amd64' /var/log/apt/history.log | tail -8"
docker run --rm --platform linux/amd64 ubuntu:24.04 \
sh -c "dpkg-query -W -f='\${Package} \${Version}\n' <pkg>"
Provenance decides which evidence row of category 4 applies, and nothing more on its own:
- installed by an unpinned
apt-get install (even as an automatic dependency) → covered; use the "OS package (apt)" row, i.e. Launchpad date_published vs image build start.
- genuinely shipped by the base image and never reinstalled → use the "base image" row.
- not covered by any unpinned line → the coverage condition fails, so it is not R; go to step 1.
(b) Then apply the date test from category 4 with that row:
- published after build start → R, no change at all.
- published before build start and still flagged → split on what (a) found. If (a) said not covered, the fix genuinely is not landing → act, go to step 1. If (a) said , something other than staleness is holding the version back (a dependency pin, a version copied in from an earlier stage) → — for a covered package that entry is inert (see below) and step 7's pruning pass would tell the next run to delete it.
Ruby gems (Dockerfile)
When a CVE is in a Ruby gem (path contains /usr/local/rbenv/):
Install the explicit fixed version in the Dockerfile after the rbenv setup block — pin the version with gem install <gem> -v <fixed-version>, never gem update (which jumps to latest and is not reproducible/auditable). Add --no-document to skip rdoc/ri generation (smaller image, faster build):
# Fix <CVE-ID>: upgrade <gem> to <fixed-version>
RUN eval "$(${RBENV_ROOT}/bin/rbenv init -)" && gem install <gem> -v <fixed-version> --no-document && rbenv rehash
⚠️ Default-gem / cached-archive caveat (verified the hard way — see vulnerability_history.md): gems that ship with Ruby (net-imap, erb, json, psych, uri, etc.) are default/bundled gems, and Prisma reads the OLD version from three places that gem install/gem update leave behind — even though gem list shows only the patched version:
- gemspec —
…/specifications/<gem>-<old>.gemspec or …/specifications/default/<gem>-<old>.gemspec
- lib dir —
…/gems/<gem>-<old>
- cached archive —
…/cache/<gem>-<old>.gem ← easily missed; this is what kept net-imap 0.5.8 flagged even after the gemspec + lib dir were removed (build #567). gem uninstall refuses to remove default gems.
Remove all three for the OLD version only, purge the gem cache, then assert the old version is gone so the build fails loudly instead of silently shipping it. Use exact old-version strings — never broad globs like net-imap-0.* or erb-4.*, which also match the patched version (e.g. 0.5.15) and would delete the fix:
RUN eval "$(${RBENV_ROOT}/bin/rbenv init -)" && \
gem install net-imap -v 0.5.15 --no-document && gem install erb -v 4.0.4.1 --no-document && \
GEMS_DIR="$(ruby -e 'print Gem.dir')" && \
for OLD in net-imap-0.5.8 erb-4.0.4; do \
rm -rf "$GEMS_DIR/gems/$OLD" "$GEMS_DIR/specifications/$OLD.gemspec" \
"$GEMS_DIR/specifications/default/$OLD.gemspec"; done && \
rm -f "$GEMS_DIR"/cache/*.gem && rbenv rehash && \
if find "${RBENV_ROOT}" -name 'net-imap-0.5.8.gem*' -o -name 'erb-4.0.4.gem' -o -name 'erb-4.0.4.gemspec' | grep -q .; then \
echo 'ERROR: vulnerable Ruby gem still on disk' >&2; exit 1; fi
(substitute the actual gem/version pairs). Purging cache/*.gem wholesale is safe — cache archives aren't needed at runtime. Always confirm in the branch scan (step 14) that the old version is no longer flagged; Successfully installed in the build log is NOT proof.
8. Branch and worktree setup
Print a status line before each step.
[1/6] Determine branch name
Check if the branch already exists:
git branch --list "CVE-fixes_$(date +%Y-%m-%d)*"
- If not → use
CVE-fixes_YYYY-MM-DD
- If exists → append index:
CVE-fixes_YYYY-MM-DD-2, -3, etc.
[2/6] Verify .worktrees/ is in .gitignore
grep -q "^\.worktrees" .gitignore || echo ".worktrees" >> .gitignore
[3/6] Create git worktree
git fetch origin master && \
git worktree add .worktrees/<branch-name> -b <branch-name> origin/master
All subsequent steps (fixes, tests, commit, push) run from inside .worktrees/<branch-name>/.
9. Apply all auto-fixes inside the worktree
Repeat all the fix steps from step 7 — but now operating on files inside .worktrees/<branch-name>/ instead of the main working directory.
10. Run unit tests
After applying all fixes inside the worktree, run the full unit test suite. This takes 10–20 minutes — tell the user and that this is expected.
cd .worktrees/<branch-name> && python -m nose2 -s tests/unit -v
If tests fail → stop. Do NOT remove the worktree. Do NOT commit.
Report:
- Which tests failed and the error output
- The worktree path:
.worktrees/<branch-name>
- The branch name so the user can investigate
Tell the user:
"Tests failed. The worktree has been left at .worktrees/<branch-name> on branch <branch-name> for you to investigate. Once you've resolved the failures, you can commit and push manually from that directory."
If tests pass → continue to the coverage gate below.
Coverage gate (predict the codecov/project check before pushing)
The PR's codecov/project check uses codecov defaults (target: auto, threshold: 0%) — any net coverage decrease over bzt/ (excluding bzt/resources, per .codecov.yml) fails it and blocks merge. Detect this locally before committing/pushing.
If branch coverage < base coverage → the PR will be blocked. Take action before pushing:
- Prefer adding/extending a unit test that covers the lines/branches the change left uncovered.
- If the drop comes from a change that collapses a previously-tested branch (e.g. making two constants equal), reconsider whether that change is even in scope — JMeter/Gatling version bumps are not (category 1), and that was the original cause.
- Only push once branch coverage ≥ base coverage.
11. Commit the fixes (do NOT push yet)
Commit so the local review (step 12) has a concrete diff, but do not push — the review may amend this commit, and the branch should be reviewed before the expensive build + re-scan (step 14) runs on it.
Stage and commit (from inside the worktree). Stage only files that actually changed — the in-scope fix targets are requirements.txt, the Dockerfile, and bzt/modules/javascript.py (npm direct PACKAGE_NAME):
cd .worktrees/<branch-name> && \
git add requirements.txt Dockerfile bzt/modules/javascript.py
(Do not stage bzt/modules/jmeter.py or bzt/modules/gatling.py — bumping JMeter.VERSION/Gatling.VERSION is category 1, out of scope. bzt/modules/java/tools.py is a permitted-but-discouraged target: stage it only if you deliberately bumped a JarTool version constant (TestNG, JUnit, org.json, etc.) for a specific reason — recipe in vulnerability_history.md ("When a Java jar has a CVE"). bzt/modules/services.py only holds the runtime PipInstaller.pip_constraints, which doesn't affect the scanned image, so it's normally not part of a CVE fix.)
Commit message:
Fix CVE vulnerabilities - <comma-separated list of packages bumped>
Auto-fixed by prisma-taurus skill.
CVEs fixed: <comma-separated CVE IDs>
Do not push yet — the push is step 13, after the local review.
12. Local code review — before the push (amend into the commit)
Review the committed-but-unpushed diff (Dockerfile, requirements.txt, and any bzt/**/*.py) with an independent reviewer before pushing, so any accepted fix is amended into the commit and the branch is pushed once, clean — and the build + re-scan (step 14) then validates the already-reviewed image (reviewing after the ~30-min scan would invalidate it). It catches judgment mistakes a self-check misses (a broad gem-purge glob that also deletes the patched version, an apt pin so exact it breaks on the next security-pocket bump, a fix that won't actually land, a || true that masks a failure).
Pick the reviewer by what's available — this step must never block or fail the run because a review tool is missing (a "not available"/"unknown skill" error is not a failure; fall through to the next option):
- If the
superpowers:requesting-code-review skill is available, use it (preferred — an independent agent).
- Else, if the
/code-review skill is available, use it (diff-scoped review).
- Else (neither installed), skip the review and go straight to the push (step 13). Note in the PR body that no automated review ran. Do not error out.
This order is MECHANICAL, not advisory. If superpowers:requesting-code-review is available, you MUST use it — always. It is option 1 and it is mandatory whenever present; /code-review is a fallback used only when superpowers:requesting-code-review is genuinely absent or errors on invocation. Do not choose /code-review (or skip) while superpowers:requesting-code-review is available. There is no exception for a "small" or "Dockerfile-only" diff, no "right-sized / lighter tool" judgment — diff size is irrelevant to this choice and must never move you off superpowers:requesting-code-review. If you ever believe the fallback is better for the situation, do not silently substitute it: state out loud "the skill mandates superpowers:requesting-code-review; I'm considering /code-review because <reason>" and get confirmation before deviating. Substituting your own optimization for this explicit order is a process error even when the outcome would be the same.
Then, whichever ran (if any):
- Invoke the chosen review skill on the committed fix diff.
- Triage every comment yourself — do not auto-apply. Decide change vs keep as-is with technical rigor (as in
superpowers:receiving-code-review), and justify each call:
- Accept when it improves correctness, robustness, or matches an in-repo convention (e.g. use
<gem>-<old>.gem* like the net-imap line instead of an exact .gem in the default-gem purge assertion; keep an apt --only-upgrade unpinned so a security-pocket bump doesn't later break the build).
- Reject/keep when the current change is already correct, or the comment conflicts with the skill's classification rules (e.g. a suggestion to fix a JMeter/Gatling-bundled, ESM, or scanner-appeasement finding — out of scope here; keep it as-is).
- If you accept changes, apply them,
git add the changed files, then git commit --amend --no-edit into the fix commit — safe, because nothing is pushed yet (never force-push). (Without the git add, --amend re-uses the previous tree and your accepted edit is silently dropped — never pushed, never in the PR.)
- Record the outcome for the PR body: which comments were applied, which were consciously kept and why.
13. Push the branch (NO PR yet)
Only after the review-and-amend pass. The branch must exist on origin so taurus-branch-builder can build it. Do not create a PR here — the PR is created in step 15, only after the branch scan confirms a reduction.
cd .worktrees/<branch-name> && git push -u origin <branch-name>
The worktree can be removed now (the branch is on origin). Run this from the repo root:
git worktree remove .worktrees/<branch-name>
14. Build the branch into an image and re-scan it
Trigger taurus-branch-builder to build the pushed branch into a Docker image, run integration tests, and run a Prisma scan on the resulting image. This is the verification gate before any PR.
curl -s -o /dev/null -D - -X POST -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/taurus-branch-builder/buildWithParameters?branch_name=<branch-name>&run_integration=true&push_docker=true&public_docker=false&publish_internal_python=false&PERFORM_PRISMA_SCAN=true"
Parameter rationale:
run_integration=true — catches breakage unit tests can't (the Dockerfile gem/npm/apt steps and the JMeter/Gatling version bumps only run for real here).
push_docker=true — pushes to the internal GCR registry (us.gcr.io/verdant-bulwark-278/taurus:<branch>-<build>); twistcli scans that image. Required for the scan.
public_docker=false — never publish a branch image to the public registry.
PERFORM_PRISMA_SCAN=true — runs twistcli images scan --details and prints the full vulnerability table inline in the build console.
Capture the Location: header (queue item URL), poll the queue item for executable.number to get the build number, then poll the build until building=false (typically ~30–40 min with integration; ~14 min without). Tell the user the build number and that you are waiting.
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/taurus-branch-builder/<BUILD>/api/json?tree=building,result"
If result is FAILURE → integration or the build broke. Stop, do NOT create a PR, and give the console URL:
https://blazect-jenkins.blazemeter.com/job/taurus-branch-builder/<BUILD>/console
15. Compare scan results and decide whether to PR
Fetch the build console and parse the twistcli table (it starts at the line Scan results for: image us.gcr.io/...:<branch>-<build>):
curl -sL -u "$JENKINS_USERNAME:$JENKINS_TOKEN" \
"https://blazect-jenkins.blazemeter.com/job/taurus-branch-builder/<BUILD>/console"
Strip ANSI codes and count rows by severity (critical/high/medium/low). Parse the whole table — it is large; do not truncate the byte range.
Method-mismatch caveat (reconciles with the "compare CSV to CSV" rule in the republish section). This step compares a twistcli console-table parse against the baseline taurus.csv — two different extraction methods, which can differ by a row or two on identical content. That does not invalidate this gate, but it does set which signal is authoritative:
- Check 1 (per-package verification) is authoritative — "is
<pkg> <old-version> still listed?" is a presence test, immune to counting differences. Never override it with an aggregate.
- The aggregate is corroboration. A double-digit reduction is real. If the total moves by only 1–3 in either direction, treat it as inconclusive, not a result: re-run
prisma-cloud-ondemand-scan on the branch image (or compare only per-package presence) before letting the gate decide.
- The strict "CSV to CSV" rule applies to judging small deltas and to verifying a republish — not to this step's coarse pass/fail.
Then do two checks:
- Per-fix verification — for every package you fixed, confirm its vulnerable version is no longer flagged in the branch scan. If a package is still flagged at the old version (e.g.
net-imap 0.5.8), that fix did NOT land — diagnose it (see the default-gem caveat under "Ruby gems") before proceeding.
- Aggregate comparison — compare the branch counts (total + per severity) against the baseline scan from steps 4–5.
Decision gate (keyed on the all-severity branch-scan total, not on the X→Y reporting framing from step 5):
- Vulnerabilities went down AND no fixed package is still flagged at its old version → proceed to create the PR (below).
- Vulnerabilities did not improve, OR a fix silently failed to land → do NOT create a PR. Report the comparison, the failed fixes and why, and stop. Tell the user the branch is pushed and the build number so they can decide.
Before creating the PR — create a Jira Story for the fixes and reference its key in the PR.
Do this only after the decision gate passes (never for a run that didn't reduce vulnerabilities — otherwise you leave an orphan ticket). Load the deferred Atlassian (Jira) MCP tools first, e.g. ToolSearch with select:mcp__claude_ai_Atlassian_Rovo__atlassianUserInfo,mcp__claude_ai_Atlassian_Rovo__searchJiraIssuesUsingJql,mcp__claude_ai_Atlassian_Rovo__createJiraIssue,mcp__claude_ai_Atlassian_Rovo__editJiraIssue,mcp__claude_ai_Atlassian_Rovo__getTransitionsForJiraIssue,mcp__claude_ai_Atlassian_Rovo__transitionJiraIssue (add getAccessibleAtlassianResources if you need the cloudId).
Site / cloudId: perforce.atlassian.net = 2accdbdb-9d65-4c22-b174-5d4a9d437c59. Project MOB (name "R&D"), issue type Story.
| Field | Value |
|---|
| Project | MOB |
| Issue type | Story |
| Summary | short and imperative — the task, not the outcome. e.g. Fix CVE vulnerabilities in blazemeter/taurus Docker image (YYYY-MM-DD). Never past tense (Fixed …), never a count in the title. |
| Description | SHORT, and written as work to be done — never a report of work already finished. A Jira Story states a task; the changelog belongs in the PR, not here. Use exactly three headings, a few lines each: Problem (what is wrong with the image, e.g. <X> fixable CVEs in blazemeter/taurus:unstable, worst severity, and — when R > 0 — that <R> of them need only a republish); To do (imperative bullets: bump/upgrade <package> to <version>, one per package, not per CVE); Acceptance criteria (verifiable end states: the old version no longer appears in a Prisma scan of the image; unit tests and integration pass). Keep CVE IDs out of the body except where one identifies the work — the PR carries the full table. No commit SHAs, no "fixed X → Y" past-tense summary, no per-CVE scoreboard. |
| Assignee | the developer running the skill — atlassianUserInfo accountId; never hardcode a person |
| Labels | ["ai_assisted"] |
Scrum Team (customfield_10067) | required on create — hardcoded to Sparta, option id 21405 |
Product (customfield_10350) | required on create — hardcoded to Blazemeter, option id 21409 |
Sprint (customfield_10020) | the team's active sprint — resolve dynamically (step 2) and set it (step 4); it is not auto-assigned |
| Status | transition to In Progress after creation |
⚠️ The MOB Story create screen rejects a create that omits customfield_10067 (Scrum Team) or customfield_10350 (Product) — both are mandatory and have no default. For now they are hardcoded to this team's values (Scrum Team = Sparta 21405, Product = Blazemeter 21409). If another team starts running this skill, switch these to per-run values derived from the runner's own recent MOB issue instead.
atlassianUserInfo → the runner's accountId (assignee).
- Resolve the active sprint — read it off the runner's most recent issue currently in an open sprint (the Atlassian MCP has no board/sprint tool and only
read/write:jira-work scope, so the Agile API for "active sprint" is unavailable — reading an existing issue's Sprint field is the scope-free path):
searchJiraIssuesUsingJql
jql: project = MOB AND assignee = currentUser() AND sprint IN openSprints() ORDER BY updated DESC
fields: ["customfield_10020"]
The active sprint = the entry in the top result's customfield_10020 array whose state == "active" → its numeric id (e.g. 27845 = 26-Q2-S6). (The "resolve dynamically / set it" pointers above are to sub-steps 2 and 4 of this numbered list, not to top-level skill steps 2/4.)
- Fallback: if the runner has no open-sprint issue, resolve via
project = MOB AND "Scrum Team" = "Sparta" AND sprint IN openSprints() ORDER BY updated DESC. If still none, create without a sprint and flag it for manual assignment.
createJiraIssue — projectKey=MOB, issueTypeName=Story, summary, description, assignee_account_id=<accountId>, additional_fields: {"labels":["ai_assisted"], "customfield_10067":{"id":"21405"}, "customfield_10350":{"id":"21409"}}. Capture the returned key, e.g. MOB-XXXXX. (The Sprint field is usually not on the create screen — set it in the next step.)
- Set the sprint:
editJiraIssue on the new key with fields: {"customfield_10020": <activeSprintId>} (numeric id). If the write is rejected (some boards restrict sprint assignment via API), flag the sprint for manual assignment — don't block the PR.
getTransitionsForJiraIssue → find the In Progress transition id → transitionJiraIssue.
- Read the ticket back (
customfield_10020, status) to confirm the sprint landed and status is In Progress before moving on.
Reference the key in the PR, not the commit. The fix commit was already made and pushed at step 13 (before this ticket exists), so the key can't be in it — and that's fine. Put the key in the PR title and body only (next step). Do not amend or force-push the fix commit to backfill the key: leaving the verified commit untouched keeps it matching exactly the image already built and scanned by taurus-branch-builder.
Before creating the PR — reconcile vulnerability_history.md on the SAME fix branch (so it ships in this PR, no separate PR):
vulnerability_history.md is a problem-solving reference, not a log of what happened. Its job is to help a future run (a) fix similar vulnerabilities faster and (b) know when a temporary fix can be removed because a newer upstream version resolved the original CVE. Update it in place — improve the matching recipe or pattern; do not append a blow-by-blow "what happened today" entry.
At the end of every run, reconcile it for two things:
- A durable lesson — a fix that silently didn't land and its root cause, a new not-buildable category, a corrected recipe, or a non-obvious decision worth preserving. The most valuable of these are only known after the branch scan in this step. Fold it into the matching "Patterns" recipe (or add a new recipe), not into a dated timeline.
- A removal condition — if this run pruned a now-unneeded fix (step 7) or added a new temporary fix, record under that recipe what the fix is for and the upstream condition that makes it removable, so a future run can prune it confidently.
If the run applied known recipes cleanly and there is no new lesson and nothing to prune, change nothing and go straight to the PR — don't pad the file. When there is something to record, commit it on the fix branch alongside the code fixes and push, so the doc update is part of this same PR rather than a separate one:
cd .worktrees/<branch-name>
git add .claude/skills/prisma-taurus/vulnerability_history.md
git commit -m "Document <lesson> in prisma-taurus history"
git push origin <branch-name>
Caveat — the history file must exist on the base branch (master) for this to work. The fix branch is cut from origin/master; vulnerability_history.md is only present there once the prisma-taurus skill itself has been merged to master. If the skill is not yet on master (e.g. still on a feature branch), the file won't be in the fix branch's worktree — in that case commit the history update on whatever branch the skill lives on instead, and note in the PR that the history doc lives elsewhere. Once the skill is merged, this caveat no longer applies and the history update rides along in the same PR every run.
Create the PR (only when the gate passes). Do this in two independent steps so creating the PR never depends on Copilot being available.
15a — create the PR (must succeed on its own; do NOT put --reviewer here): include the Jira key in the title so Jira ↔ GitHub link automatically.
gh pr create --repo Blazemeter/taurus --base master \
--title "<MOB-XXXXX>: CVE fixes - $(date +%Y-%m-%d)" --body-file <body.md>
If gh is not on PATH, create via the GitHub API using $GITHUB_TOKEN (POST /repos/Blazemeter/taurus/pulls).
15b — request GitHub Copilot's review (best-effort; must NEVER fail the run):
if ! command -v gh >/dev/null 2>&1; then
echo "gh not installed — skipping Copilot reviewer request (PR already created; request it manually if wanted)"
else
gh pr edit <pr-number-or-url> --repo Blazemeter/taurus --add-reviewer @copilot 2>/dev/null \
|| gh api repos/Blazemeter/taurus/pulls/<number>/requested_reviewers \
-f 'reviewers[]=copilot-pull-request-reviewer[bot]' 2>/dev/null \
|| echo "Copilot reviewer not added (needs gh >= 2.88.0 and Copilot code review enabled) — PR created regardless; request it manually if wanted"
fi
Keeping 15b separate is deliberate: a bad --reviewer on gh pr create can fail the whole create call and leave no PR. By creating first and requesting Copilot after, the PR is guaranteed and the Copilot request is a harmless add-on. (This is GitHub Copilot's own PR review — separate from and in addition to the step-12 local pre-push review.) Never block or error the run because Copilot couldn't be added.
PR body should include (apply the step-5 reporting rule — lead with fixable-detected/fixed, not the raw total):
- Jira:
<MOB-XXXXX> (the ticket created above).
- Headline first — Fixable in taurus, this run:
<X> detected → <Y> fixed. Distinct CVEs; count only this repo's own Dockerfile / requirements.txt fixes confirmed gone in the branch scan. This is the opening line — do not open with the raw total or any JMeter/Gatling count. If Y < X, list which didn't land and why. Add **Rebuild-clearable: <R>** when R > 0, noting these need no code change and clear on the next master build (labeled subset of X − Y, never an extra count).
- Table of the fixed CVEs: CVE ID, severity, package, old version → new version.
- Note: "Verified against the
taurus-branch-builder image scan (build #) before opening — integration passed."
- Local code-review outcome (from step 12): which review comments were applied vs kept, and why.
- Not actionable in this repo — one categorized COUNT only (never per-CVE):
<Z> = monitor-only JMeter/Gatling bundled jars (incl. criticals), k6 binary internals, JDK-later-major-only, distro pip, no-released-patch (open/needed/deferred), Ubuntu ESM, scanner-appeasement/manual (.deps.json/Roslyn). State this run's criticals explicitly and computed (e.g. "all N criticals are monitor-only JMeter/Gatling" — verify each run, don't assume).
- If you show the raw baseline→branch totals at all, put them in a single parenthetical with at least crit/high — never as the headline. (X/Y are distinct CVEs; the raw total counts per-occurrence, so it won't sum arithmetically with X — context, not a checksum.)
- Note: "The following CVEs require manual intervention (see below)."
- Decision gate is unchanged by this framing — it keys on the all-severity branch-scan total going down (see the gate above), not on X/Y.
16. Wait for and triage Copilot's review (one round only)
After 15b, wait for Copilot to finish reviewing, then triage its comments — but do this exactly once. This is best-effort: if Copilot never runs, don't block.
[16a] Detect completion (bounded poll). If gh is not on PATH, skip step 16 entirely (note it in the summary) — don't burn the poll. Otherwise poll until Copilot posts its review — a review by copilot-pull-request-reviewer[bot] with a non-null submitted_at is the signal (Copilot also drops out of requested_reviewers when done, but the submitted review is sufficient):
PR=<number>
for i in $(seq 1 20); do
finished=$(gh api repos/Blazemeter/taurus/pulls/$PR/reviews \
--jq '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]" and .submitted_at!=null)] | length' 2>/dev/null)
[ "$finished" -ge 1 ] 2>/dev/null && break
sleep 15
done
If the cap elapses with no Copilot review, note "Copilot review did not complete in time" and go to step 17 — do not block.
[16b] Triage the comments. Read Copilot's inline comments (gh api repos/Blazemeter/taurus/pulls/$PR/comments) and the review body. Triage each with the same judgment as step 12 (accept if it improves correctness/robustness/convention; keep-as-is if it conflicts with the classification rules — e.g. never patch a JMeter/Gatling-bundled, ESM, or scanner-appeasement finding). Justify each call.
[16c] Apply accepted changes — then STOP (do not re-request Copilot). If you accept any comments, first re-add the worktree on the fix branch if it was removed at step 13 (git worktree add .worktrees/<branch-name> <branch-name>), apply them to the Dockerfile / requirements.txt / bzt/**/*.py, git add the changed files, and commit + push one follow-up commit on the same branch. This is a single round: after this push, do NOT request Copilot again and do NOT wait for another Copilot review — even though the push may auto-trigger one. Leave any resulting new Copilot review for the human reviewer; record in the summary which comments were applied and which were kept.
⚠️ Prisma-specific: re-verify a material Dockerfile change. Copilot's comments are usually robustness nits (e.g. a .gem* glob vs an exact .gem, an exact-apt-pin brittleness, a || true scope) that do not change which CVEs the image clears — a single push without re-scan is fine. But if an accepted Copilot fix materially changes the Dockerfile (a different package/version, a removed layer), the pushed image no longer matches the one verified at steps 14–15. In that case, re-trigger the branch build + re-scan (step 14) and re-confirm the decision gate (step 15) before considering the PR verified — the "verified before merge" guarantee is the whole point of this skill. If that re-confirmed gate does NOT pass, do not leave the PR looking verified: convert it to a draft (gh pr ready <pr> --undo) or post a blocking comment, stop, and report — restore it only once a passing re-scan exists, and update the PR body's build-number note to that new build.
17. Post the "ready for review" notification to Slack (best-effort; must NEVER fail the run)
After step 16 — the PR exists, Copilot's single triage round is done (or timed out), and any accepted follow-up is pushed — post one summary to the team Slack channel so a human knows the PR is basically ready to approve. Best-effort, exactly like the Copilot request (15b): it must never fail, block, or error the run.
- Only when there is a PR. If the decision gate (step 15) failed and no PR was created, skip this step entirely.
- Skip cleanly if unconfigured. If
SLACK_BOT_TOKEN or SLACK_SPARTA_CHANNEL_ID is unset, skip the post, note "Slack notification skipped (SLACK_* not set)" in the summary, and continue. Never prompt.
- Content = only the CVEs that landed (the ✅ "Fixed" set,
Y). No pendings, no not-landed, no out-of-scope, no notes — just the fixed list.
Message format (locked). Header line + fixed CVEs grouped by package, each package's CVEs listed vertically. unfurl_links:false. The count is computed from the fixed list (= Y), never hand-typed. Build/send in Python (avoids shell-quoting pain), reading both values from the environment:
import json, os, subprocess
token = os.environ.get("SLACK_BOT_TOKEN")
channel = os.environ.get("SLACK_SPARTA_CHANNEL_ID")
if not (token and channel):
print("Slack notification skipped (SLACK_BOT_TOKEN / SLACK_SPARTA_CHANNEL_ID not set)")
else:
def esc(s):
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
pr_num = <PR number>
pr_url = f"https://github.com/Blazemeter/taurus/pull/{pr_num}"
jira = "<MOB-XXXXX>"
groups = [
]
total = sum(len(c) for _, _, c in groups)
lines = []
for pkg, bump, cves in groups:
lines.append(f"• *{esc(pkg)}* {esc(bump)}")
for c in cves:
lines.append(f" ◦ {esc(c)}")
jira_seg = jira jira !=
header = (
)
payload = {
: channel, : ,
: ,
: [{: ,
: {: , : header + + .join(lines)}}],
}
:
r = subprocess.run(
[,,,,,
, ,
, ,
, json.dumps(payload)],
capture_output=, text=, timeout=)
resp = json.loads(r.stdout )
( resp.get()
)
Exception e:
()
Non-fatal errors to note but never block on: not_in_channel (invite the bot to the channel), channel_not_found (wrong id), invalid_auth (bad/rotated token). Record the outcome (posted / skipped / failed) in the final summary; the PR stands regardless.
Format is intentionally minimal and shared. The same bot ("Sparta Scan") and the same format are reused by the taurus-cloud skills (prisma-taurus-cloud and mend-taurus-cloud, with a Mend tag instead of Prisma) and across repos (the link text is <repo> #<PR>). Keep the header/emoji/grouping identical so the channel reads consistently — only the scan-type tag and repo name differ.
Republishing to clear R findings (no code change) — RECOMMEND, never auto-run