# How to write a CVE response runbook for an SDK

> Take a private vulnerability report to a fixed release in every language you ship, then check the advisory against the OSV schema and your SDK list.

Source: https://voxgig.com/howto/write-a-cve-response-runbook-for-an-sdk

- Audience: library-maintainer
- Level: advanced
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Write the runbook as a checklist from private report to published advisory. Take reports through SECURITY.md and private vulnerability reporting, and score them with a full CVSS vector. Ask the CNA that covers you for a CVE id, and fix in a private fork with a backport to every supported major. Before publishing, check the advisory against the OSV schema and against the list of every SDK generated from the same spec.

---
## You will need

A published SDK with a supported-versions policy, because the policy decides which majors get a
backport. Node 22 or later for the checker, with Ajv and `ajv-formats` from the sample's lockfile, and
version 1.9.2 of the [OSV schema](https://ossf.github.io/osv-schema/), vendored beside it. The ids,
package names, and versions in the sample belong to a fictional Acme SDK.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [GitHub Security Advisories](https://docs.github.com/en/code-security/concepts/vulnerability-reporting-and-management/repository-security-advisories) | The SDK repositories live on GitHub and you want intake, a CVE id, a private fork, and Dependabot alerts in one place | Your disclosure process lives on one platform, and the private fork runs no status checks | The code is not on GitHub, or another CNA already covers your project |
| [HackerOne](https://docs.hackerone.com/en/articles/8505210-cve-requests) | A company SDK with enough reports to need triage help and a published disclosure policy | Reports live on another company's platform, and CVE requests are an opt-in feature not in every edition | A small open source SDK that receives a handful of reports a year |
| [MITRE CVE request form](https://mitre.github.io/mitre-cve-roles/) | A security mailing list you run yourself, and no CNA that covers the project | You write the CVE record, the advisory, and every ecosystem's range yourself | GitHub or another CNA can assign the id from the advisory you already wrote |
| [OSV database](https://google.github.io/osv.dev/data/) | You want scanners in every ecosystem to match the advisory by package and version | It aggregates other databases, so a record reaches it through GHSA, a language database, or converted CVE records | Never instead of the others; it is where their advisories end up |

GitHub's flow is the fast one. Private reporting, a CVE request usually reviewed within 72 hours,
and Dependabot alerts arrive together. The price is that disclosure depends on one platform. A
self-run process, a mailing list plus the MITRE form, keeps the process yours. It costs you the
advisory format, the CVE record, and the OSV data, all written by hand. HackerOne sits between the
two: triage help and a CNA, on another company's platform.

## Take the report privately

Publish where a report goes before one arrives. GitHub reads
[a security policy](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/add-security-policy)
from `SECURITY.md`, and the sample's version names the supported majors and says one report covers
all six languages. Turn on
[private vulnerability reporting](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configure-for-a-repository)
in the repository that holds the spec or the generator, not in one language's repository. The
report then lands where the fix for all six will be made.

## Score it, then ask for an id

Record the CVSS vector before the conversation about severity starts, so the argument is about
metrics rather than adjectives. The
[CVSS 3.1 specification](https://www.first.org/cvss/v3.1/specification-document) requires all
eight base metrics in a vector string. The OSV schema's pattern does not enforce that, so
`CVSS:3.1/AV:N/AC:H` is valid OSV but an unusable score, and the checker below counts the metrics
itself.

GitHub is a CNA, so a repository advisory can request a CVE id, and GitHub says it usually
reviews the request within 72 hours. It cannot assign one if another CNA covers your project. With
no CNA, a self-run process asks MITRE through the CVE Program's request page.[^1] HackerOne is a CNA
too, for programs on an edition that includes CVE requests.[^2]

## Fix every language in private

GitHub's
[temporary private fork](https://docs.github.com/en/code-security/tutorials/fix-reported-vulnerabilities/collaborate-in-a-fork)
keeps the fix out of public view, and it changes the rules you are used to. Status checks do not
run on its pull requests, branch protection is not enforced, and every open pull request merges at
once. For an SDK family that means running each language's test suite by hand and pasting the
result into the advisory. Then make the backport. Every major in your supported-versions policy
needs its own fixed release, or its users get told to jump a major to be safe.

## Check the advisory covers every SDK

The advisory is the artifact scanners read, and it is where the forgotten language shows. The
checker validates against the OSV schema first, then walks `sdks.json`, the list of every package
generated from the spec, and asks each one for a range.

```js title="check-advisory.mjs"
  for (const major of majors) {
    const lo = `${major}.0.0`
    const hi = `${major + 1}.0.0`
    const reachesMajor = (s) =>
      s.fixed ? cmp(s.fixed, lo) > 0 : s.lastAffected ? cmp(s.lastAffected, lo) >= 0 : true
    const hits = spans.filter((s) => cmp(s.introduced, hi) < 0 && reachesMajor(s))
    for (const s of hits) {
      if (s.lastAffected) problems.push(`${label}: ${major}.x ends at last_affected ${s.lastAffected}; give the fixed version`)
      else if (!s.fixed) problems.push(`${label}: ${major}.x is affected from ${s.introduced} with no fixed version`)
      else if (cmp(s.fixed, hi) >= 0) problems.push(`${label}: ${major}.x has no fix of its own; the first fixed version is ${s.fixed}`)
    }
  }
```

Each supported major is a window. A span that reaches into the window must close inside it, with a
`fixed` event, which the
[OSV specification](https://ossf.github.io/osv-schema/) prefers over `last_affected` because it
names the version that contains the fix. Run it on the complete advisory and on the one from the
problem statement:

```bash
node check-advisory.mjs advisory.json advisory-npm-only.json
```

```text output
advisory.json: GHSA-xxxx-xxxx-xxxx, 7 affected packages
  OSV schema 1.9.2: valid
  ok    TypeScript @acme/sdk                  fixed in 2.8.4, 3.2.1
  ok    Python     acme-sdk                   fixed in 2.8.4, 3.2.1
  ok    Go         github.com/acme/sdk-go/v2  fixed in 2.8.5
  ok    Go         github.com/acme/sdk-go/v3  fixed in 3.2.1
  ok    Java       com.acme:acme-sdk          fixed in 2.8.4, 3.2.2
  ok    PHP        acme/sdk                   fixed in 2.8.4, 3.2.1
  ok    Ruby       acme-sdk                   fixed in 2.8.4, 3.2.1
advisory-npm-only.json: GHSA-xxxx-xxxx-xxxx, 1 affected packages
  OSV schema 1.9.2: valid
  ok    TypeScript @acme/sdk                  fixed in 2.8.4, 3.2.1
  FAIL  Python (PyPI acme-sdk): no affected entry
  FAIL  Go (Go github.com/acme/sdk-go/v2): no affected entry
  FAIL  Go (Go github.com/acme/sdk-go/v3): no affected entry
  FAIL  Java (Maven com.acme:acme-sdk): no affected entry
  FAIL  PHP (Packagist acme/sdk): no affected entry
  FAIL  Ruby (RubyGems acme-sdk): no affected entry
```

Both files are valid OSV. The schema describes the shape of a record and has no way to know that
six packages share one transport. Go appears twice because Go puts a
[major version suffix](https://go.dev/ref/mod#major-version-suffixes) on the module path from v2
on, so each major is a separate package to a scanner. Java's 3.x fix is 3.2.2 and
the rest are 3.2.1; the languages release on their own numbers, and the advisory has to say so.

## Turn the runbook into a checklist

Copy the checklist into the draft advisory as its first comment, and tick it as you go. A struck
line with a reason is better than a deleted one, because the checklist is also the record of what
was done.

```markdown title="RUNBOOK.md"
## Fix (private)

- [ ] Fix made in the temporary private fork; status checks do not run there, so each SDK's
      test suite was run by hand and the output pasted into the advisory
- [ ] Backport made to every supported major in sdks.json
- [ ] Fixed version number reserved for each package and each major

## Release (disclosure day)

- [ ] All fixed packages published, in every ecosystem, before the advisory goes public
- [ ] `node check-advisory.mjs advisory.json` passes: OSV schema valid, every package covered
```

The full file adds intake, triage, and a section for the week after, which checks that the record
reached the GitHub Advisory Database and [osv.dev](https://osv.dev/) for each package.

## Check it worked

Run the tests. Fourteen cases cover the missing language, the missing backport, an entry with no
ranges, a range with no fix, and `last_affected`. Others cover a version the checker refuses to
guess at, a misspelled ecosystem, and the incomplete CVSS vector the schema lets through. The last
adds an entry that lists versions without ranges, beside a `GIT` range. The checker reads both
without crashing or printing a commit as a fixed version.

```bash
node --test
```

```text output
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 314.656703
```

Then run the checker on your real draft. The literal to look for is `valid` on the schema line
and no `FAIL` lines under it.

## When it goes wrong

The fix shipped in 3.x only, so 2.x users are told to upgrade across a major in the middle of an
incident:

```bash
node check-advisory.mjs advisory-no-backport.json
```

```text output
  FAIL  TypeScript (npm @acme/sdk): 2.x has no fix of its own; the first fixed version is 3.2.1
```

Dependabot alerts users with no version to move to.
[GitHub's documentation](https://docs.github.com/en/code-security/concepts/vulnerability-reporting-and-management/repository-security-advisories)
says an advisory published without a fix version alerts users anyway, without offering a safe
version. Publish the
fixed packages first and the advisory second.

The checker refuses `2.8.4.post1`. Python and Java version strings are not always three numbers,
and a guessed comparison produces a wrong range. Teach `parseVersion` your ecosystem's scheme, or
release fixes with plain numbers.

## When not to do this

Do not write a separate runbook per language. The vulnerability lives in the generator or the spec,
so the response has to start there, and six runbooks produce six advisories with six different
ranges.

Do not publish the advisory before every ecosystem has its fixed package. The advisory is a map to
the vulnerable code for anyone reading it, and a language without a release is the one they will
look at.

Do not run HackerOne for an SDK that gets three reports a year. A `SECURITY.md`, private
vulnerability reporting, and this checklist cover that volume, and the platform's launch rules
assume a program with traffic.[^3]

Do not treat the OSV schema check as the review. It confirms the record is well formed, which says
nothing about whether the ranges are true.

## Related how-tos

- [Publish security.txt for an API](/howto/publish-security-txt-for-an-api)

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

- [Run a changelog channel developers read in time](/howto/run-a-changelog-channel-developers-read)

## Last verified

Verified 2026-10-15 against Node 22.22.0, `ajv` 8.20.0, `ajv-formats` 3.0.1 and version 1.9.2 of
the OSV schema. Every output block is what the command preceding it printed. The advisories are
fixtures for a fictional SDK, checked as files rather than submitted to GitHub, MITRE, HackerOne,
or OSV.

[^1]: The address most guides give for that form, `cveform.mitre.org`, redirects to a page titled
[Contacting the CVE Program](https://mitre.github.io/mitre-cve-roles/). The page calls itself an
upgrade to the legacy form and says the two run in parallel for now, with the legacy one due to be
retired in the coming months. ID
requests are filed under MITRE CNA-LR, the CNA of Last Resort. The name describes the order in
which a supplier is expected to try things.

[^2]: The [CVE requests article](https://docs.hackerone.com/en/articles/8505210-cve-requests) says
HackerOne assigns the id when the advisory is posted publicly, and publishes it to MITRE. An
organization opts in by talking to its Customer Success Manager. The request for an identifier
begins with a request for a conversation.

[^3]: HackerOne's
[program setup guide](https://docs.hackerone.com/en/articles/8509950-response-program-setup) keeps
a disclosure program in a controlled launch until it has received at least 10 reports and
invited 100 hackers. Only then does the public launch button appear. A program for a quiet SDK can
wait a long time for its tenth report.