# Prisma Taurus

> 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.

- Skill: `blazemeter/prisma-taurus` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add blazemeter/prisma-taurus`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blazemeter/prisma-taurus/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: Blazemeter (https://skillmd.com/u/blazemeter)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blazemeter/prisma-taurus

---


## 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.

**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. `gem update` on a Ruby *default gem* (e.g. `net-imap`, `erb`) 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:

```bash
JENKINS_USERNAME   # BlazeMeter Jenkins username
JENKINS_TOKEN      # Jenkins API token
GITHUB_TOKEN       # GitHub personal access token
```

If any of the **three above** is missing, stop and tell the user which one is absent.

**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 17.

**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/...` | distro Python (apt) | Dockerfile `apt-get install --only-upgrade`/`apt-get purge` (NOT `requirements.txt`) |

> **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.

```bash
# (a) build-start time of the build that produced the scanned image
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]]"
# (b) when the fixed version hit the Ubuntu pocket — OS packages only.
#     <pkg> must be the BINARY package name (Prisma reports the SOURCE name:
#     source `libheif` ships binary `libheif1`; `binary_name=libheif` returns NOTHING).
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

```bash
: "${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:**
```bash
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:**
```bash
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:**
```bash
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)

```bash
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:
```bash
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):
```bash
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

```bash
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`:

1. **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
   ```
2. **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.
3. **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).
4. **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.
5. **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:

```python
class Mocha(NPMPackage):
    PACKAGE_NAME = "mocha@11.7.5"   # versioned — bump after @

class Newman(NPMPackage):
    PACKAGE_NAME = "newman"          # UNVERSIONED — tracks npm latest; not bumped here
```

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:

```dockerfile
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.**

1. **Bump npm.** Check what the latest npm bundles (it's pinned in npm's own lockfile):
   ```bash
   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.
2. **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:

1. Fetch the release metadata to find the latest 8.x SDK with security fixes:
   ```bash
   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])
   "
   ```

2. Update the `DOTNET_URL` and `DOTNET_SHA512` values in the Dockerfile `FROM system-deps AS runtimes` stage.

3. 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/`):

0. **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:
   ```bash
   # confirm you are inspecting THE SCANNED image first — compare against the CSV's `Image ID`
   # column; `unstable` is a mutable tag and may have been republished since the baseline scan.
   docker image inspect --format '{{.Id}}' blazemeter/taurus:unstable
   # which apt invocation installed it, at what version, and was it "automatic"?
   docker run --rm --entrypoint sh blazemeter/taurus:unstable -c \
     "grep -B4 '<pkg>:amd64' /var/log/apt/history.log | tail -8"
   # and is it in the base image at all?  (also a mutable tag — reason about tod

…(truncated)
