How to Run the Patch Simulator

Intermediate 20 minutes Engineering Managers / Developers Remediation & Patching

Preview the impact of a candidate upgrade — which CVEs clear, how many transitive dependencies unblock, what other packages might need to move with it — before you open a PR.

What the Simulator Does

A patch simulator answers: “if I bump package X from version A to version B, what changes?” The answer is three numbers:

OutputMeaning
cleared_cves[]CVEs in the current closure of X that are not present in the closure of X@B
unblocked_transitive_countTransitive dependencies currently pinned at vulnerable versions because the new range was incompatible with X@A — bumping X to B lets them move
related_pins[]Packages whose constraints will need to change to accommodate the upgrade (peer-dep conflicts, version-range overlaps)

The simulator does not run npm install or pip install — it walks the registry’s dependency metadata directly, so it’s fast (sub-second for most upgrades) and side-effect-free.

Prerequisites

Step 1: Open the Simulator

Three entry points:

  • Click any row on the Patch Priority leaderboard.
  • Open Reports → Patch Simulator and search for the package by name.
  • Hit the API: POST /api/patches/simulate.
Patch simulator UI
Inputs are package, current version, target version. Outputs are the three impact numbers, plus a per-CVE table.

Step 2: Run a Simulation from the UI

  1. Package: e.g. lodash.
  2. Current version: 4.17.20 (defaults to your most-installed version if blank).
  3. Target version: 4.17.21 (defaults to the leaderboard’s recommended upgrade if invoked from there).
  4. Click Simulate.

The result page shows:

  • Cleared CVEs: a table with CVE ID, severity, KEV/EPSS flags, and the path through the dependency tree where the CVE was reachable.
  • Unblocked transitives: list of dependencies that can move once X is upgraded, with their current → unblocked-target versions.
  • Related pins: any peer-dep / version-range conflicts the upgrade would create. This is the column to read carefully — if related_pins is empty, the upgrade is safe to apply directly. If it contains entries, those entries will need PRs of their own.

Step 3: Run a Simulation from the API

For automation:

curl -X POST https://chain305.com/chainproxy/api/patches/simulate \
  -H "Authorization: Bearer $CHAINSAW_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "package": "lodash",
    "ecosystem": "npm",
    "from_version": "4.17.20",
    "to_version": "4.17.21",
    "scope": {
      "client_id": "ci-frontend"
    }
  }'

The scope object is optional — without it, the simulation runs across your entire inventory. With it, the simulation is restricted to the closures of packages installed by the named client.

Response shape:

{
  "cleared_cves": [
    {"id": "CVE-2026-2345", "severity": "CRITICAL", "kev": true, "epss": 0.74,
     "paths": ["frontend-build > webpack > lodash"]}
  ],
  "unblocked_transitive_count": 3,
  "unblocked_transitives": [
    {"package": "webpack", "from": "5.88.0", "to": "5.92.1"},
    ...
  ],
  "related_pins": [],
  "score_delta": 12.4
}

Step 4: Compare Multiple Targets

The most useful question is rarely “what does upgrading to X do?” — it’s “which of these N candidate targets is best?” Pass targets[] instead of to_version:

curl -X POST https://chain305.com/chainproxy/api/patches/simulate \
  -H "Authorization: Bearer $CHAINSAW_TOKEN" \
  -d '{
    "package": "lodash",
    "ecosystem": "npm",
    "from_version": "4.17.20",
    "targets": ["4.17.21", "4.17.22", "5.0.0"]
  }'

Response is keyed by target version:

{
  "results": {
    "4.17.21": {"cleared_cves": [...], "unblocked_transitive_count": 0, ...},
    "4.17.22": {"cleared_cves": [...], "unblocked_transitive_count": 1, ...},
    "5.0.0":  {"cleared_cves": [...], "unblocked_transitive_count": 14, "related_pins": [12 entries]}
  }
}

The shape makes the trade-off explicit: 5.0.0 clears more CVEs and unblocks more transitives but requires 12 dependent PRs.

Step 5: Use the Simulator in CI

The simulator is fast enough to run on every Renovate / Dependabot PR. The pattern:

  1. PR opens with a dependency bump.
  2. CI step calls /api/patches/simulate with from_version from the lockfile and to_version from the PR.
  3. CI annotates the PR with the simulation result: cleared_cves: [...], unblocked_transitive_count: N.
  4. If related_pins is non-empty, CI adds a needs-coordinated-merge label and links the dependent PRs.
# .github/workflows/chainsaw-simulate.yml
name: Chainsaw simulate
on:
  pull_request:
    paths: ['package.json', 'package-lock.json', 'requirements.txt', 'Pipfile.lock']
jobs:
  simulate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # pr-scan diffs the PR against its merge base. The default
          # shallow clone does not contain the base commit, so the scan
          # would silently find nothing.
          fetch-depth: 0
      - uses: Chain305/chainsaw-core/enforcement/github-actions@action-v1
        with:
          mode: warn
          comment-on-pr: "true"
        env:
          CHAINSAW_TOKEN: ${{ secrets.CHAINSAW_TOKEN }}
          CHAINSAW_SERVER: ${{ secrets.CHAINSAW_SERVER }}

The Action runs chainsaw pr-scan over the PR’s changed manifests and posts a single summary comment (comment-on-pr is an Action input, not a CLI flag).

To drive the patch simulator itself, call /api/patches/simulate directly as shown in Step 3 — chainsaw pr-scan has no --simulate flag. Its full flag set is --base, --head, --repo-path, --strict, --json and --output-file.

Step 6: When the Simulator Disagrees with Your Intuition

A common case: you bump a package to fix CVE-X, the simulator reports cleared_cves: []. That means CVE-X is not reachable through the path you’re upgrading. Likely causes:

  • The CVE was disclosed in a different version range than you thought — confirm against the GHSA / CVE record’s affected versions.
  • Your inventory has multiple installed versions of the same package — the upgrade only clears the CVE for the path you’re bumping, not other paths. Check Inventory → by-package for the same package and look at the version distribution.
  • The CVE applies to a sub-dependency, not the package you’re bumping. The simulator surfaces this in unblocked_transitives rather than cleared_cves.

Verification

  1. From the leaderboard, click a row → simulator opens with the package preselected → impact numbers match what the leaderboard advertised.
  2. Run a simulation for a known-no-op upgrade (e.g., 1.0.01.0.0) → response is empty cleared list, zero transitives unblocked.
  3. The CI annotation pattern in Step 5 posts a comment on a test PR.