Fetch the finding: GET {api_base}/findings/{finding_id} with Authorization: Bearer {token}. You get title, severity, cwe (comma-joined), location, affected, cvss_vector, cve_id, fix_version, fix_commit, and the six-step prose (trace, boundary, validation, prior_art, reach, rating). Also fetch:
GET {api_base}/repositories/{repository_id} for the upstream URL and default branch
GET {api_base}/repositories/{repository_id}/packages for the list of published packages; you need this to fill GHSA's affected-package block
GET {api_base}/findings/{finding_id}/notes for relation markers written by finding-dedup
Scan the notes for a body whose first line starts with finding-dedup: subsumed by finding #. If one exists, this finding is only reachable through the parent named after the #, and any correct fix for the parent closes it. Write {"error": "finding {id} is subsumed by finding #{parent}; disclose the parent instead"} and exit 0.
Scan the notes for a body whose first line starts with finding-dedup: chains with finding #. If one exists, extract every #N on that line and fetch each with GET {api_base}/findings/{N}. These are the chain members whose traces the Composed section below pulls in.
Compose the GHSA fields below. Every field names the GHSA REST key (summary, description, vulnerabilities, etc.) so the mapping is explicit. Keep each one factual and derived from the finding — do not invent details the audit did not establish.
summary (title). A single sentence, under 80 characters. Start with the impact verb ("Arbitrary file write in …", "Prototype pollution in …"), not the package name. Reuse the finding's title if it already fits that shape.
description (markdown body). This is the main document a maintainer reads. Structure as below. Each section is required unless marked optional.
## Summary
Two or three sentences describing the vulnerability in the maintainer's own domain terms. Repeat the one-line summary then expand. When the finding's `prior_art` field opens with `Discovered via issue-tracker.`, `Discovered via advisory.`, or `Discovered via documentation.`, lead with a sentence that acknowledges the maintainer already has a record of this ("This confirms and extends issue #N", "This is a bypass of GHSA-xxxx", "Your FAQ at docs/security.md describes this class"); when it opens with `Discovered via source.` or has no such prefix, lead with the finding itself.
## Impact
What an attacker can do. Stay tight — reuse the Rating prose if it already covers this. Name the attacker model (unauthenticated remote, local, authenticated user) in the first sentence.
## Affected versions
A line per affected range, matching the `vulnerabilities[].vulnerable_version_range` values. Example:
- `>= 1.0, < 2.3.1` (all pre-2.3.1 releases)
## Patched versions
If a fix has shipped, list the first patched version. Otherwise write "Not yet patched" and state whether the `fix_commit` on the finding is on the default branch.
## Proof of concept
Reuse the Validation prose, formatted as a short runnable recipe. Include the minimum needed to trigger the bug. A fenced code block when a script exists.
## Composed with
Only when step 2 found chain members. One short paragraph per chained finding: its title, its location, and one sentence from its Trace naming the sink. Then one paragraph explaining how the chain works and why the combined severity is higher than any member alone (reuse the reason from the `finding-dedup: chains with` note). Close with "Each chained issue is tracked as scrutineer finding #{N}." so the maintainer knows the others exist as separate records but are being reported together here. Omit the whole section when there are no chain members.
## Fix suggestion
One or two sentences on where the guard belongs (sanitise here, validate there, remove the sink). Do not claim a specific patch unless the Trace identifies the exact line. When the finding chains, name which link the fix breaks.
## References
- `{repo.html_url}/blob/{default_branch}/{location}` — the vulnerable code
- `https://cwe.mitre.org/data/definitions/{n}.html` — one line per CWE
- any URL that appeared verbatim in the prior_art field of the finding
GHSA's REST endpoint has no structured references field — all URLs live inside the description markdown. You will still post them as scrutineer references (step 4) so the UI surfaces them as links, but the maintainer-facing copy is the markdown list.
vulnerabilities[] (affected products). One entry per published package. Build from the repository's packages list. Each entry has:
{
"package": { "ecosystem": "<ghsa-ecosystem>", "name": "<package-name>" },
"vulnerable_version_range": ">= 1.0, < 2.3.1",
"patched_versions": "2.3.1",
"vulnerable_functions": ["pkg.Parse", "pkg.ParseFile"]
}
Normalise the ecosystem string to the exact GHSA enum — all lowercase, with these specific spellings: rubygems (not RubyGems), npm, pip (not pypi or PyPI), maven, nuget, composer, go, rust, erlang, actions, pub, swift, other. If a scrutineer package has ecosystem: "Packagist", emit "composer"; if "Cargo", emit "rust". Map anything unrecognised to "other".
If the repository has no packages, emit a single placeholder entry [{"package": {"ecosystem": "other", "name": "{owner}/{repo}"}}] and note in the notes field of report.json that this advisory is source-only. GitHub's REST endpoint rejects a body with no vulnerabilities entry, and the ghsa block in report.json is meant to be POSTable as-is.
vulnerable_functions is optional; fill it only when the Trace field names specific exported symbols (e.g. pkg.Foo, Class#method). Leave empty otherwise.
severity / cvss_vector_string. GHSA accepts exactly one of the two; prefer the CVSS vector when you can derive one confidently, fall back to the severity label otherwise.
Derive a 3.1 vector for the GHSA body (the GHSA form's CVSS picker accepts a 3.1 vector string). Derive each metric from the finding prose: AV from the attack surface described in Boundary, AC from how contrived the trigger is in Validation, PR/UI from whether the trigger needs authentication or human interaction, S from whether the impact crosses a trust boundary, and C/I/A from the dangerous behaviour in Rating. Write the full vector string (e.g. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). If any single metric cannot be derived from the prose, do not guess a value for it; omit cvss_vector_string entirely and emit the severity label instead.
Also derive a CVSS 4.0 vector and store it in cvss_v4_vector (the OSS-SIRT brief and downstream OSV consumers prefer v4; v3.1 stays for legacy pipelines). The metric set is wider: same base metrics, plus VC/VI/VA (impact on the vulnerable system) and SC/SI/SA (impact on subsequent systems). When the finding's blast radius stops at the vulnerable component, SC=N SI=N SA=N. Same rule: omit cvss_v4_vector rather than guess. The 4.0 form is CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N.
For the severity label fallback, map scrutineer's severity field (Critical/High/Medium/Low) to GHSA's lowercase critical/high/medium/low. If the finding has a pre-existing cvss_vector or cvss_v4_vector, leave it alone and reuse it here — do not overwrite analyst edits.
cwe_ids[]. Split the finding's comma-joined cwe field into an array of CWE-N strings (GHSA accepts multiple). Do not invent CWEs not in the finding.
cve_id. Pass through whatever the finding carries; leave blank (omit the key) if unset. CVE IDs are assigned by a CNA, not drafted — do not fabricate one.
credits[]. Omit unless the finding prose explicitly attributes the discovery (e.g. a prior_art reference to a named researcher). Leave empty by default.
Write the composed pieces back via the scrutineer API.
PATCH the finding — PATCH {api_base}/findings/{finding_id} with Authorization: Bearer {token} and JSON body:
{
"fields": {
"title": "<summary>",
"cvss_vector": "CVSS:3.1/...",
"cvss_v4_vector": "CVSS:4.0/...",
"affected": ">=1.0, <2.3.1",
"fix_version": "2.3.1",
"disclosure_draft": "<description markdown>"
},
"by": "disclose"
}
Only include fields you want to change. If the finding already had a non-empty cvss_vector, cvss_v4_vector, affected, fix_version, or title, leave those keys out of the body so the analyst's value is preserved. disclosure_draft may be overwritten — a re-run is allowed to produce a fresh draft.
POST each reference — for every URL cited in the description, POST {api_base}/findings/{finding_id}/references with:
{ "url": "https://...", "tags": "upstream|cwe|prior-art", "summary": "short label" }
Before posting, GET {api_base}/findings/{finding_id}/references and skip URLs that already exist — re-runs should not create duplicates.
Write ./report.json. The top-level ghsa block is the drop-in body for POST /repos/{owner}/{repo}/security-advisories; an operator or downstream skill can submit it as-is.
{
"ghsa": {
"summary": "...",
"description": "...",
"vulnerabilities": [
{
"package": { "ecosystem": "go", "name": "example.com/pkg" },
"vulnerable_version_range": ">= 1.0, < 2.3.1",
"patched_versions": "2.3.1",
"vulnerable_functions": ["pkg.Parse"]
}
],
"cwe_ids": ["CWE-22"],
"cvss_vector_string": "CVSS:3.1/...",
"cve_id": null,
"credits": []
},
"patched": ["cvss_vector", "affected", "fix_version", "disclosure_draft"],
"preserved": ["title"],
"references_added": 3,
"references_skipped": 1,
"notes": "short prose about anything non-obvious: no published packages (source-only advisory), an ambiguous tag range, a missing prior-art link, etc."
}
ghsa mirrors the GHSA REST body: every key is drawn from GitHub's repository-advisories schema, so downstream code can POST it without a translation step. patched lists fields you actually sent in the PATCH /findings body. preserved lists fields you chose not to touch because the analyst had already set them.