Remediation Plan
Name the fixed version to upgrade to for each package the given CVEs affect, with an estimate of the effort and breaking-change risk of each upgrade.
The plan names a version. It does not generate patches, diffs or package overrides, and it does not check that the named version builds or runs in your environment.
Endpoint
POST /api/v1/remediation/planThis is POST only. A GET returns 405 naming the method, because the endpoint exists and saying "not found" about it would send you looking for a typo.
Request
| Field | Type | Required | Description |
|---|---|---|---|
cves | string[] | Yes | CVE identifiers to plan around, at most 100. If any entry is not of the form CVE-YYYY-NNNN, the whole request answers 400 naming the invalid entries, rather than silently planning around the rest. |
packages | object | No | Map of package name to the version you currently run. Supplies currentVersion and lets the effort and breaking-change analysis work. |
curl -X POST https://api.vulnpatch.dev/api/v1/remediation/plan \
-H 'Content-Type: application/json' \
-d '{
"cves": ["CVE-2021-44228", "CVE-2022-0847"],
"packages": { "org.apache.logging.log4j:log4j-core": "2.14.1" }
}'The two CVEs show the two main outcomes. Log4Shell (CVE-2021-44228) has recorded fixed versions on real packages, so it produces upgrades. Dirty Pipe (CVE-2022-0847) is recorded only against kernel, with no fixed version, so it is reported as unfixable.
Package names must match the advisory's own naming. Log4Shell's fix for org.apache.logging.log4j:log4j-core is recorded under that exact name, not log4j-core. A name that matches nothing is not an error and does not reduce the plan: every upgrade is still returned, with currentVersion null.
Not every name NVD or a CNA states for a CVE is a package. Log4Shell's own CVE List entry names "Apache Software Foundation / Apache Log4j2" -- a vendor and a product, not a Maven coordinate -- and NVD's CPE data for the same CVE names several Siemens products that bundled a vulnerable log4j release. Neither is something a package manager can install, so neither appears in upgrades; both appear in vendorProductMatches instead (below).
Response
The plan is returned under data, beside success and timestamp. Log4Shell is recorded against several real packages with a fixed version and against several Siemens products through NVD's CPE data; the request's packages inventory names only org.apache.logging.log4j:log4j-core, so that is the only upgrade produced (see "Package names must match the advisory's own naming" above). The example is illustrative, abridged to one of several vendorProductMatches rows -- NVD's own CPE coverage for a widely-deployed flaw like this one keeps growing, so the live count of Siemens (and other vendor) rows for CVE-2021-44228 is higher than shown here and changes over time.
{
"success": true,
"data": {
"upgrades": [
{
"packageName": "org.apache.logging.log4j:log4j-core",
"currentVersion": "2.14.1",
"targetVersion": "2.15.0",
"fixesCves": ["CVE-2021-44228"],
"cvssImpact": 10,
"effort": "moderate",
"breakingChange": { "isBreaking": false, "reason": null }
}
],
"stats": {
"totalCves": 2, "fixableCves": 1, "unfixableCves": 1,
"upgradeCount": 1, "totalCvssImpact": 10, "nixpkgsFixedCves": 0,
"vendorProductMatchCount": 4, "productRemediationCves": 0
},
"unfixable": [{ "cveId": "CVE-2022-0847", "reason": "No known fix version" }],
"vendorProductMatches": [
{
"cveId": "CVE-2021-44228",
"evidenceType": "vendor_statement",
"source": "cve_list_v5",
"vendor": "Apache Software Foundation",
"product": "Apache Log4j2",
"bestAvailable": false,
"versionEvidence": [{ "version": "2.15.0", "basis": "range_bound" }]
},
{
"cveId": "CVE-2021-44228",
"evidenceType": "cpe_applicability",
"source": "nvd",
"vendor": "siemens",
"product": "comos",
"bestAvailable": false,
"applicability": [
{ "cpe": "cpe:2.3:a:siemens:comos:*:*:*:*:*:*:*:*", "versionStart": null, "versionEnd": "10.4.2", "versionEndType": "excluding" }
]
}
],
"packagesWithoutFix": [
{ "cveId": "CVE-2021-44228", "packageName": "com.guicedee.services:log4j-core" },
{ "cveId": "CVE-2021-44228", "packageName": "org.xbib.elasticsearch:log4j" }
],
"nixpkgsFixes": []
},
"timestamp": "2026-10-15T19:56:43.557Z"
}A CVE nixpkgs has already fixed through a merged pull request, with no corresponding upstream release, adds an entry to nixpkgsFixes instead of unfixable:
{
"cveId": "CVE-2026-103923",
"packageName": "katex",
"attr": "katex",
"channel": "nixos-26.05",
"kind": "merged_pr",
"indexVersion": "0.16.27",
"pr": {
"number": 569533,
"url": "https://github.com/NixOS/nixpkgs/pull/569533",
"title": "katex: 0.16.27 -> 0.18.11",
"mergedAt": "2026-10-07T04:00:00.000Z",
"baseBranch": "release-26.05",
"mergeCommit": "cafef00d..."
},
"channelConfirmedCarries": true,
"channelRevision": "deadbeef...",
"patchGrade": null,
"definitionPath": null,
"note": "nixpkgs pull request #569533 fixing this CVE merged into release-26.05 and the channel's current revision carries it."
}Upgrade fields
targetVersion: the highest fixed version recorded for that package across the CVEs in the request.cvssImpact: the summed CVSS score of the CVEs this upgrade resolves. Upgrades are ranked by it, highest first.effort:trivialfor a patch-level move,moderatefor a minor-version move,significantfor a major-version move. It ismoderatewhenever either version is unknown or not semantic, which includes every upgrade without acurrentVersion.breakingChange:isBreakingis true only for a major-version bump. It is a version-number heuristic, not a reading of the changelog.
Telling the three outcomes apart
The distinction that matters most here is between a CVE for which we hold no fixed version at all and a package we happen to hold no fixed version for.
upgrades: one per real package coordinate, ranked by the CVSS it resolves.unfixable: a CVE for which the planner cannot emit a defensible upgrade. Usually no affected package has a recorded fixed version. It also includes a planning gap when one package has several fixed versions whose version scheme cannot be ordered safely, and a CVE whose only evidence is vendor/product rows (reason: "No package coordinate; vendor/product evidence only (see vendorProductMatches)."). One entry per CVE. This does not mean no fix exists upstream; thereasonstates which evidence is missing or incomparable.packagesWithoutFix: a package with no recorded fixed version, for a CVE that is fixable through some other package. It does not mean no fix exists. It means we hold no fixed version for that particular package.vendorProductMatches: evidence that names a vendor and a product, or an unclassified name, rather than an installable package coordinate. See below.
stats.totalCvssImpact counts each CVE once at its highest reported score, however many packages it affects. Summing the per-upgrade figure instead would multiply Log4Shell's score of 10 by every package it is recorded against. A nixpkgs-only fix (see below) is never counted here: it produces no upgrade action, so there is no upgrade whose CVSS this figure could describe. A vendor-product match adds nothing to this figure either, whatever its CVE's CVSS score: Log4Shell's Siemens CPE rows do not add to the 10 its real package upgrade already contributes.
vendorProductMatches
Not every name a source states for a CVE is an installable package. An NVD CPE applicability match names a vendor and a product, not a package-manager coordinate; a CNA's own affected[] entry can itself be a bare vendor/product assertion (Log4Shell's own CVE List record names "Apache Software Foundation / Apache Log4j2," not a Maven coordinate); and a package name resolved only through Vulnpatch's dependency graph, with no cached dossier to corroborate it, carries no ecosystem information at all. None of these become an upgrades entry. They are classified and returned here instead, as a discriminated union on evidenceType:
evidenceType | Fields | Meaning |
|---|---|---|
cpe_applicability | source: "nvd", vendor, product, applicability | An NVD CPE applicability match. applicability is every CPE/version-range row NVD gave for that vendor+product; none are discarded. |
vendor_statement | source: "cve_list_v5", vendor, product, versionEvidence | A CNA's own bare vendor/product pair, with whatever version evidence ({version, basis}) the record stated directly. |
unclassified_package_name | source, packageName, reason | A packageName the record stated with no corroborating ecosystem-aware source (source: "cve_list_v5"); a nixpkgs security issue's title-guessed package name with no structured nixpkgsAttrs to confirm it (source: "nix"); or a name resolved only through the dependency graph with no cached dossier to corroborate it (source: "graph"). |
Every entry carries cveId and bestAvailable. bestAvailable: true means this CVE's own affected entities include zero real package coordinates, so this is the strongest identity/remediation evidence available for it -- not that the CVE lacks evidence generally. Heartbleed (CVE-2014-0160) is recorded only against OpenSSL's CPE data: it has no upgrades entry, it appears in unfixable (reason: vendor/product evidence only), its one vendorProductMatches entry carries bestAvailable: true, and it counts in stats.productRemediationCves. Log4Shell has both a real upgrade and several Siemens CPE rows: its Siemens rows carry bestAvailable: false, since the same CVE has a real package coordinate elsewhere, and Log4Shell does not count in stats.productRemediationCves.
stats.vendorProductMatchCount is vendorProductMatches.length, always present. stats.productRemediationCves is the count of distinct CVEs whose only remediation guidance is product-level (every vendorProductMatches row for that CVE carries bestAvailable: true, and it has no nixpkgs-only fix either), also always present. Mutually exclusive with fixableCves: a CVE counts in exactly one of them, never both.
nixpkgsFixes and stats.nixpkgsFixedCves
nixpkgs sometimes fixes a CVE through a backport before the project itself cuts a release carrying the fix: a merged nixpkgs pull request, or a patch applied directly in the package definition (fetchpatch), with the upstream version left unchanged. That is a real fix with no fixedVersion to report, so it is never folded into unfixable or upgrades; it gets its own array.
A CVE with a nixpkgs fix and no upstream fixed version counts toward stats.fixableCves (it leaves unfixable) and toward stats.nixpkgsFixedCves, but produces no upgrade action -- there is no target version to upgrade to, and the plan does not invent one.
When a row has both an upstream fixedVersion and a nixpkgs fix, the normal upgrade action is still produced, and the row also appears in nixpkgsFixes, carrying an extra upstreamFixedVersion field. A reader whose nixpkgs channel may already carry the patch is not told "upgrade to X" with no mention that their channel could already be fine.
Each nixpkgsFixes entry:
| Field | Description |
|---|---|
cveId, packageName | The CVE and the nixpkgs attribute the fix applies to (attr repeats the same value). |
channel | The nixpkgs channel this evidence is about, e.g. nixos-26 or nixpkgs-unstable. |
kind | merged_pr or applied_patch. |
indexVersion | The package index's version at the time the fix was read, kept so a reader can see it sat inside the affected range for a reason unrelated to the fix (the fix is a patch, or a backport the index had not caught up with). |
pr | Present for kind: "merged_pr": number, url, title, mergedAt, baseBranch, mergeCommit. null for applied_patch. |
channelConfirmedCarries | For kind: "merged_pr": true when the channel's current revision has been shown to carry the merge; false when containment is merely unknown (no channel observation was held, or the compare failed) -- this is not "not yet carried" (a merged PR shown not to be carried yet is excluded from nixpkgsFixes entirely, see below). Unknown containment is still reported here, since it is still a merged fix on the channel's release branch. Always true for kind: "applied_patch", which is read directly from the package definition at an observed revision. |
channelRevision | The channel revision the evidence was read against, when known. |
patchGrade, definitionPath | Present for kind: "applied_patch": the grade (fix_patch_content_match or fix_commit_referenced) and the package definition's path at that revision. null for merged_pr. |
upstreamFixedVersion | Present only when the same row also has a normal upstream fixed version. |
note | A human-readable statement of the evidence, carried over from the underlying version verdict. |
A channel absent from nixpkgsFixes is not proof that channel is unfixed -- the same "absence of evidence" framing nix-channels.js uses for fixed: null applies here. A merged pull request nixpkgs has not yet carried on a given channel (carriedByChannel: false) is real, and will reach a release, but is not something a reader can act on today, so it does not appear in nixpkgsFixes until the channel catches up.
unresolved, staleUnchecked, nixpkgsUnchecked and partial
unresolved is present when a CVE could not be looked up at all. It is kept separate from unfixable deliberately: one says we looked and found no fixed version, the other says we could not look. Treat unresolved as a reason to retry, not as a finding about the CVE.
staleUnchecked is present when a CVE's archived record names no actionable package coordinate with a fixed version -- which includes every bare vendor/product CNA record, not only one with literally no data -- but the live re-check that would confirm or correct that never ran because the request's 10-read budget (see below) was already spent on earlier CVEs in the same plan. An id here still has a vulnerabilities entry with no fixedVersion, from the archive's stale answer -- staleUnchecked is what says that answer was never actually re-verified for this request. The archive's own vendor-product evidence for such an id, if any, still appears in vendorProductMatches, but with bestAvailable: false: it is not settled as "the strongest evidence available," since whether a real coordinate exists elsewhere was never actually checked. nixpkgs fix evidence rides along inside the same live lookup, so a staleUnchecked id also had its nixpkgs evidence not consulted, not only its upstream record; note says so. Treat staleUnchecked the same as unresolved: a reason to ask again, not a finding.
nixpkgsUnchecked is the nixpkgs-specific counterpart: present when a CVE may touch nixpkgs and its own fix evidence (a merged pull request, or an applied patch) could not be fully read, and no fix, upstream or nixpkgs, was found for that CVE regardless. The same conditions are checked whichever path resolves the CVE: the nixpkgs check (cached dossier, or the live lookup's own fix-status read) was itself partial or never evaluated, the fix-status read errored outright, or a candidate the plan was eligible to check had patch evidence explicitly marked not checked. "No known fix" is not a checked answer for an id in this list; treat it the same as unresolved. A CVE only lands here once it has actually been resolved from some source with no fix found -- a dossier whose own nixpkgs check was incomplete but which falls through to an archive or live lookup that does find a real fix never appears here, and its gap note is folded into the decision once that resolution has happened, not before.
partial is true whenever a CVE was unresolved, left staleUnchecked or nixpkgsUnchecked, or a source could not be read. failedSources then names the sources that failed, and note says why the plan may be incomplete. A genuine error reading a CVE's nixpkgs fix evidence (not merely "no candidate was eligible to check") adds nixpkgs_evidence:<cveId> to failedSources. A plan without partial read every source it tried.
How a fix is resolved
The plan reads each CVE's merged record, the same one the dossier serves, when that record is already stored. When it is not, the plan resolves the fix from the archived CVE record using the same package-resolution logic the dossier uses (changes/affected-version data, not a nonexistent "fixed" status value). If the archived record itself states no fix, the plan reads the current CVE List record live, bounded to 10 such reads per request; a failed read of that fallback marks the plan partial with failedSources carrying cve_list_v5 or lookup:<id>, and a CVE for which the budget ran out before its turn lands in staleUnchecked instead. A CVE genuinely has no recorded fix only when none of these three sources name one and the budget did not run out first -- that is the real meaning of unfixable with "No known fix version".
A cold or live plan request consults the same nixpkgs fix evidence a CVE dossier does: merged pull requests, identity-checked against the CVE's candidate packages per the most recent fix to that code (a PR naming a CVE in passing is not accepted as fix evidence unless it also touches one of the known attributes), and, for a small bounded number of live reads per request (MAX_NIXPKGS_PATCH_COMPUTES, currently 3), patches applied directly in the package definition. Once that budget is spent, later CVEs in the same plan still get the merged-PR check (it rides along in every live lookup for free) but read patch evidence only from cache, never fetching it live.
A cold plan (no dossier stored yet) and a warm one classify the same way: only a GitHub/OSV ecosystem package, a tracker-listed nixpkgs attribute, or a CNA packageName corroborated by one of those for the same CVE becomes a real package coordinate. The CVE record's own bare vendor/product wording (Log4Shell's "Apache Log4j2") is never promoted to one, cold or warm, for lack of anything better -- it lands in vendorProductMatches instead. A cold plan can still differ from a warm one in which real coordinates it finds: the archived record alone carries no GitHub/OSV/nixpkgs data to corroborate a CNA packageName with, so a CVE whose only affected-entity evidence is a bare vendor/product pair produces no upgrade from the archive alone, while a live re-check (which reads those sources) or an already-warm dossier can find the real coordinate GitHub or OSV names for the same CVE.
What a target version does and does not promise
targetVersion is the fixed version the advisory names for the CVEs you asked about. It is not a statement that the version is free of every other vulnerability.
Log4Shell demonstrates this. For CVE-2021-44228 alone the plan returns 2.15.0 for org.apache.logging.log4j:log4j-core, which is what that advisory names. Three later Log4j CVEs carry later fixes in their own records: CVE-2021-45046 (2.16.0), CVE-2021-45105 (2.17.0) and CVE-2021-44832 (2.17.1). The version you actually want is 2.17.1.
So pass every CVE you know about for a package in one request rather than one at a time: targetVersion is the highest fix across the CVEs given for that package. A single-CVE plan answers a single-CVE question only.
Errors
| Status | Cause |
|---|---|
400 | Missing or empty cves array (the response carries an example body), more than 100 CVEs, or no identifier of the form CVE-YYYY-NNNN. |
405 | A method other than POST. The Allow header names POST. |
429 | Rate limited. |
500 | The plan could not be built. |