blob: 8c0239d53c1202b7a04a4c9ff473030692b7776f [file] [view]
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
# security-issue-sync — GitHub repository security advisory reconciliation
> Extracted from [`SKILL.md`](SKILL.md) so subagents that only need this
> slice can load just this file. Loaded when a tracker is **GHSA-sourced**
> — i.e. its report arrived through GitHub's *"Report a vulnerability"*
> flow, which creates a **repository security advisory** (a `GHSA-…`
> draft) on `<upstream>`.
This subdoc covers reconciling that GitHub advisory **record** with the
tracker, and the reporter-reply path when the operator has advisory API
access. It applies only when `<upstream>` is hosted on GitHub and the
operator is a **collaborator** on the repository's security advisories.
Historically the skills assumed *"GHSA threads have no GitHub API"* and
routed every reporter reply through an email relay to the hosting
foundation's security team (see [`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md)).
That is now only the **fallback**: the *repository security advisories
REST API* exposes the advisory record for read + field-edit, so the sync
reconciles it directly. Only the reporter⟷maintainer **discussion
thread** still has no API.
---
## Access tiers — probe before acting
The GitHub *repository security advisories* API grants different
operations at different tiers. Probe the operator's tier once per run and
record it in the observed-state bag:
| Operation | Endpoint | Tier required |
|---|---|---|
| List / read advisories (incl. `triage`/`draft`) | `GET /repos/<upstream>/security-advisories[/<GHSA>]` | **advisory collaborator** (or admin / security-manager) |
| Edit advisory **fields** (`cve_id`, `credits`, `severity`, `cwe_ids`, `vulnerabilities`) | `PATCH …/security-advisories/<GHSA>` | **advisory collaborator** |
| Manage **collaborators** (`collaborating_users`/`_teams`) | `PATCH …` with those fields | **admin / security-manager** |
| Change **state** / publish | `PATCH …` with `state`, or the publish call | **admin / security-manager** |
| Comment on the reporter discussion thread | *(none — `…/comments` → 404)* | **web UI only** |
Probe: `gh api /repos/<upstream>/security-advisories --jq 'length'`
(a `200` with a list ⇒ at least collaborator read). Attempting a
collaborator-management `PATCH` returns
`403 "Cannot update advisory collaborators unless you have
administrative/security management rights"` when the operator is a plain
collaborator — treat that 403 as the definitive *"not admin"* signal and
**do not retry**; route the corresponding action to the admin hand-off
below.
The common tier for a project committee member is **advisory
collaborator**: read + field-edit, but **not** collaborator-management
and **not** publish. Design the flow so the sync does what the
collaborator tier allows and *hands off* the rest — never blind-fire a
`state=published` PATCH hoping it works, because a success is a
**permanent public advisory**.
---
## Step 1 add-on — fetch + reconcile (read; always runs)
Detect GHSA-sourced trackers by grepping the body / provenance for
`GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}` ids scoped to `<upstream>`
(reuse the import skill's GHSA grep). For each id, fetch the advisory and
record it. Surface drift in the Step 2 proposal:
- **cve_id drift** — advisory `cve_id` ≠ the tracker's CVE (or empty).
- **field drift** — `credits` / `severity` / `cwe_ids` / `vulnerabilities`
(affected ranges) differ between advisory and tracker. The tracker +
CVE record is **authoritative**; the advisory is the mirror.
- **state drift** — advisory `state` vs the tracker's process step (e.g.
tracker `announced` + CVE published while the advisory is still
`triage`). Informational at the collaborator tier (state changes are an
admin hand-off).
- **access drift** — security-team roster
(`gh api repos/<tracker>/collaborators --jq '.[].login'`) members
missing from the advisory's `collaborating_users`. Informational at the
collaborator tier (adding collaborators is an admin hand-off).
**Record the advisory link(s) as a dedicated, clickable tracker field.**
So the GHSA is one click away from the tracker (not buried in prose), the
reconcile ensures the tracker carries a distinct field — e.g. a
`### GitHub Security Advisory (GHSA)` section — listing each advisory as a
markdown link
(`- [GHSA-…](https://github.com/<upstream>/security/advisories/GHSA-…)`).
Populate it on every GHSA-sourced tracker (retroactively on first touch).
---
## Step 4 add-on — advisory writes (propose-then-confirm each)
Each is a separate confirmable proposal item (SKILL Golden rule 1).
1. **Link cve_id + reconcile fields — collaborator tier, sync performs it.**
`gh api -X PATCH /repos/<upstream>/security-advisories/<GHSA> -f cve_id=<CVE>`,
and where they diverge, mirror the tracker's authoritative
`severity` / `cwe_ids` / `vulnerabilities` / `credits` onto the
advisory. **Never** copy an advisory-supplied CVSS back into the
tracker's severity field (the project's severity rule governs the
tracker; the advisory mirrors *it*).
2. **Mirror state (publish / close) — admin tier, HAND OFF.** Do not PATCH
`state` at the collaborator tier. Route it through the
[Admin hand-off relay](#admin-hand-off--relay-the-needed-change-to-the-advisory-admin-team)
below, and note that many foundation advisories stay `triage` by design
(the CVE ships through the foundation's own CVE tool, not GitHub's
advisory flow), so publishing is often not even desirable.
3. **Provide collaborator access — admin tier, HAND OFF.** The sync cannot
add collaborators at the collaborator tier; route the missing-roster
access drift through the
[Admin hand-off relay](#admin-hand-off--relay-the-needed-change-to-the-advisory-admin-team).
---
## Admin hand-off — relay the needed change to the advisory-admin team
Step 4 items 2 (state / publish / close) and 3 (collaborator management) are
**admin / security-manager** operations the collaborator tier cannot perform.
Do **not** leave them as a passive recap line, and do **not** post a
`<tracker>` comment @-mentioning project members — the project's own
security-team members typically do **not** hold GitHub advisory admin rights
either, so mentioning them leaves the action on nobody's desk. Instead,
**relay the needed change to the team that administers GitHub Private
Vulnerability Reporting for the hosting org** — for ASF projects, the
foundation security team, via the [`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md)
path.
### Delivery — an email relay (draft, never auto-sent)
Create a **draft** email to the org's advisory-admin security team
(`<security-team-list>`; for ASF, `security@apache.org`) with
`oauth-draft-create` — never send directly (SKILL Golden rule 1; and the
Gmail MCP mangles the `security/advisories/GHSA-…` URLs into redirects, so
use oauth-draft). **Always CC the project `<security-list>`** so the
project security team stays looped in on what was requested. Reply on the
originating `<security-list>` thread when the report was relayed there
(`--thread-id <id>`); otherwise it is a **new** message (omit `--thread-id`)
with a self-describing subject. Do **not** post a `<tracker>` comment as the
hand-off; the tracker gets at most a one-line rollup note recording that the
relay draft was prepared.
### What the relay says (three parts, terse)
1. **What the sync already did** — the collaborator-tier field edits applied
(e.g. *"linked `cve_id=<CVE>` onto GHSA-…; recorded @… as a co-finder"*),
each advisory as a clickable `…/security/advisories/GHSA-…` URL.
2. **The admin-only change + why the sync can't** — the exact action
(*"close GHSA-… as a duplicate of GHSA-…"*, *"publish GHSA-…"*, *"add @…
to collaborating_users"*) and that the operator holds only advisory-
**collaborator** rights (state / collaborator PATCHes return `403`).
3. **The ask** — one line asking the advisory admins to perform it.
### Guardrails
- **Propose-then-confirm** the draft (recipient, CC, subject, body); the
operator reviews the Gmail draft and sends it.
- **Idempotent** — record a prepared hand-off with a `<tracker>` rollup
marker (`<!-- <marker-prefix>: ghsa-admin-handoff v1 -->`) naming the GHSA
id + requested action; do not re-draft the same pending action.
- **Collaborator-doable ≠ hand-off** — field writes the operator CAN do
(`cve_id`, `credits`, `severity`, `cwe_ids`, `vulnerabilities`) are just
applied; the relay fires **only** for the admin-only cases above.
---
## Reporter reply — direct-post primary, relay fallback
There is **no** REST API for the advisory discussion thread, so the reply
text cannot be posted programmatically at any tier. Replace the email
relay as the *primary* path when the operator is an advisory collaborator:
- **Primary (operator IS a collaborator).** Do **not** draft a relay
email. Instead the sync (a) surfaces the exact reply text, (b) **opens
the advisory discussion in the browser** (`open`/`xdg-open` on the
advisory `html_url`), and (c) prints the **copy-pastable** reply block —
GitHub advisory discussions have no comment-prefill URL parameter, so
the operator pastes it into the web UI at the already-open thread. The
sync never claims to have *posted* the reply.
- **Fallback (operator is NOT a collaborator on that advisory).** Fall
back to the [`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md)
email relay to the hosting security team. Surface which path was taken
in the proposal + recap.
Non-GitHub forwarders (HackerOne, huntr, direct email relays) are
unaffected — they keep the [`tools/forwarder-relay/`](../../tools/forwarder-relay/README.md)
path.
---
## Guardrails
- **Propose-then-confirm every advisory write** — the advisory is a
surface on a public project.
- **Confidentiality** — private-advisory content never lands on a public
surface; it is already private, and so is the tracker.
- **Source of truth** — the tracker + CVE record is authoritative for CVE
fields; the advisory is reconciled *to* it.
- **No blind state writes** — never PATCH `state`/publish speculatively; a
success is permanent and public. Publishing is an explicit admin
hand-off, opt-in per tracker.