Skip to content

Commit eb88541

Browse files
committed
chore: add scripts to list and read GHSA
1 parent 093494c commit eb88541

4 files changed

Lines changed: 596 additions & 12 deletions

File tree

.claude/skills/fix-vulnerability/SKILL.md

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,24 @@ Wear three hats simultaneously:
2424

2525
## Tools
2626

27-
- **GitHub CLI (`gh`)** — authenticated. Use the **repository** security advisories endpoint (not `/advisories`, which only lists published ones):
27+
- **Repo scripts (preferred for advisory reads)**the REST API does not expose advisory comment threads, so we scrape the authenticated web UI via a `GH_SESSION_COOKIE` stored in `.env`. Use these instead of the `gh` advisory endpoints whenever you need the **discussion / reporter messages**:
2828
```bash
29-
# Fetch a private repo advisory by GHSA ID
29+
# List non-published advisories (draft / triage) — default
30+
node scripts/list-advisories.mjs
31+
# All states, or only published
32+
node scripts/list-advisories.mjs --all
33+
node scripts/list-advisories.mjs --published
34+
# Read the full thread (initial report + every comment) for one advisory
35+
node scripts/read-ghsa-thread.mjs GHSA-xxxx-xxxx-xxxx
36+
```
37+
The cookie acquisition flow is documented in `scripts/read-ghsa-thread.mjs`. If a call dies with "cookie expired", refresh it before continuing — do not fall back to `gh api` for thread content.
38+
39+
- **GitHub CLI (`gh`)** — authenticated. Use the **repository** security advisories endpoint for metadata, CVSS, CWE, affected versions, and for the **temporary private fork** workflow (see step 6):
40+
```bash
41+
# Advisory metadata (PoC excerpt, CVSS, CWE, severity, status)
3042
gh api repos/patriksimek/vm2/security-advisories/GHSA-xxxx-xxxx-xxxx
31-
# List all repo advisories (incl. draft / triage)
32-
gh api repos/patriksimek/vm2/security-advisories
33-
# Filter by state
34-
gh api "repos/patriksimek/vm2/security-advisories?state=triage"
3543
```
44+
The thread / comments are NOT in this payload — use the scripts above for those.
3645

3746
- **`docs/ATTACKS.md`** — institutional memory. Every fix you make updates this doc.
3847

@@ -50,7 +59,16 @@ Identify the trust boundary in precise terms: which objects live in the host rea
5059

5160
### 2. Advisory deep-dive
5261

53-
Fetch the full advisory with `gh`. Extract: the PoC, CVSS vector, CWE classification, and any linked priors the reporter references — **follow the full history chain**. Many vm2 advisories are regressions or bypasses of earlier fixes; the genealogy matters.
62+
Fetch the full advisory **and the discussion thread**:
63+
64+
```bash
65+
gh api repos/patriksimek/vm2/security-advisories/<GHSA-id> # metadata, CVSS, CWE
66+
node scripts/read-ghsa-thread.mjs <GHSA-id> # initial report + every comment
67+
```
68+
69+
The thread is where reporters post follow-ups, bypass PoCs, and confirmation/rejection of your patches — it is not available via the REST API. Always read it in full before designing a fix, and re-read it after the reporter responds.
70+
71+
Extract: the PoC, CVSS vector, CWE classification, and any linked priors the reporter references — **follow the full history chain**. Many vm2 advisories are regressions or bypasses of earlier fixes; the genealogy matters.
5472

5573
Classify the vulnerability against ATTACKS.md's Tier 1 primitives (categories 1–5) and Tier 2 techniques (6–15). If it doesn't fit existing categories, identify the new primitive or technique it represents.
5674

@@ -144,7 +162,60 @@ Apply the fix with minimal, self-contained diff. Comment every security-critical
144162

145163
**Iterate with `/hacker`**: run the red-team skill against the patched tree. A bypass means the structural invariant is wrong, not that you need a tighter patch on the same line. Loop steps 5–7 until `/hacker` finds nothing.
146164

147-
### 8. Document
165+
### 8. Commit to the temporary private fork
166+
167+
Each advisory gets its own **temporary private fork** at `patriksimek/vm2-ghsa-<short-id>`. This fork is what the reporter (added as a collaborator on the advisory) sees and reviews; **never** push the embargoed fix to `origin` (`patriksimek/vm2`) until the advisory is published.
168+
169+
**Check whether a fork already exists** as a git remote pointing at the GHSA-specific repo:
170+
171+
```bash
172+
git remote -v | grep -i "vm2-ghsa-<short-id>" || echo "no remote yet"
173+
```
174+
175+
Naming convention: the remote is conventionally named with the leading short-id chunk (e.g. `ghsa-248r` for `GHSA-248r-7h7q-cr24`), the repository is `patriksimek/vm2-ghsa-<full-id>`.
176+
177+
**If the fork does NOT exist** — create it via the dedicated advisory-fork endpoint (not a generic `gh repo fork`). This endpoint creates the temporary private fork that is linked back to the advisory's UI:
178+
179+
```bash
180+
# Create the temporary private fork for this advisory
181+
gh api -X POST repos/patriksimek/vm2/security-advisories/<GHSA-id>/forks
182+
# Response includes the new repo's full name and ssh_url; wire it up as a remote
183+
git remote add ghsa-<short-id> git@github.com:patriksimek/vm2-ghsa-<GHSA-id>.git
184+
```
185+
186+
If the API call returns 202/Accepted, the fork is being created asynchronously — poll `gh api repos/patriksimek/vm2-ghsa-<GHSA-id>` until it returns 200 before pushing.
187+
188+
**If the fork DOES exist** — this is a follow-up commit (e.g. reporter found a bypass, requested a tweak). Add an additional commit to the existing branch on that fork. Do not force-push or rebase prior commits the reporter has already reviewed.
189+
190+
**Push the fix**:
191+
192+
```bash
193+
git push ghsa-<short-id> HEAD:main # or a dedicated fix branch
194+
```
195+
196+
**Disclosure hygiene**:
197+
- Do not push to `origin` or open a public PR.
198+
- Commit messages MAY reference the GHSA ID — the fork is private and visible only to maintainers + reporter.
199+
- Never reference reporter names or embargo dates in commits, code comments, or `CHANGELOG.md`.
200+
201+
### 9. Post-commit summary
202+
203+
After every commit (initial fix or follow-up), produce a **short** summary of what changed and post it to the advisory thread / share with the user. Keep it terse — the details live in the diff, the tests, and `ATTACKS.md`.
204+
205+
Template:
206+
207+
```
208+
GHSA-<id> — <one-line description>
209+
210+
Root cause: <one sentence>
211+
Fix: <one sentence pointing at the chokepoint, e.g. "added X check in lib/bridge.js apply trap">
212+
Tests: test/ghsa/<GHSA-id>/repro.js + N variants
213+
ATTACKS.md: category <N> (<new | updated>)
214+
```
215+
216+
Do not restate the PoC, list every changed line, or rehash the threat model — the reporter can read the diff.
217+
218+
### 10. Document
148219

149220
Update `docs/ATTACKS.md` following the [Category Entry Format](../../../docs/ATTACKS.md#category-entry-format) at the top of the doc:
150221

@@ -156,9 +227,7 @@ Update `docs/ATTACKS.md` following the [Category Entry Format](../../../docs/ATT
156227

157228
Update `CHANGELOG.md` with a one-line entry under the next release: `fix(GHSA-xxxx-xxxx-xxxx): <one-line description>`.
158229

159-
**Disclosure hygiene**: do not push the fix branch to a public remote, open a public PR, or reference the GHSA ID in commit messages until the advisory is published. For embargoed work, commit locally and coordinate per `SECURITY.md`.
160-
161-
### 9. Final review
230+
### 11. Final review
162231

163232
Answer every question with evidence:
164233

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/node_modules
22
.DS_Store
33
.vscode
4-
/test.js
4+
/test.js
5+
.env

scripts/list-advisories.mjs

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env node
2+
// List non-published GitHub Security Advisories on patriksimek/vm2.
3+
//
4+
// Uses the same session cookie as scripts/read-ghsa-thread.mjs. See that file
5+
// for cookie acquisition instructions.
6+
//
7+
// Usage:
8+
// node scripts/list-advisories.mjs # non-published (default)
9+
// node scripts/list-advisories.mjs --all # published too
10+
// node scripts/list-advisories.mjs --published # only published
11+
12+
import { readFileSync, existsSync } from 'node:fs';
13+
import { fileURLToPath } from 'node:url';
14+
import { dirname, resolve } from 'node:path';
15+
16+
const OWNER = 'patriksimek';
17+
const REPO = 'vm2';
18+
const ENV_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.env');
19+
20+
function loadDotenv(path) {
21+
if (!existsSync(path)) return;
22+
for (const rawLine of readFileSync(path, 'utf8').split('\n')) {
23+
const line = rawLine.trim();
24+
if (!line || line.startsWith('#')) continue;
25+
const eq = line.indexOf('=');
26+
if (eq === -1) continue;
27+
const key = line.slice(0, eq).trim();
28+
let val = line.slice(eq + 1).trim();
29+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
30+
val = val.slice(1, -1);
31+
}
32+
if (!(key in process.env)) process.env[key] = val;
33+
}
34+
}
35+
36+
loadDotenv(ENV_FILE);
37+
38+
function die(msg, code = 1) {
39+
process.stderr.write(`error: ${msg}\n`);
40+
process.exit(code);
41+
}
42+
43+
function loadCookie() {
44+
const c = process.env.GH_SESSION_COOKIE?.trim();
45+
if (!c) die(`GH_SESSION_COOKIE not set. Add it to ${ENV_FILE} or export in your shell.`);
46+
return c;
47+
}
48+
49+
function decodeEntities(s) {
50+
return s
51+
.replace(/&amp;/g, '&')
52+
.replace(/&lt;/g, '<')
53+
.replace(/&gt;/g, '>')
54+
.replace(/&quot;/g, '"')
55+
.replace(/&#39;/g, "'")
56+
.replace(/&nbsp;/g, ' ')
57+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
58+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)));
59+
}
60+
61+
function stripTags(s) {
62+
return decodeEntities(s.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
63+
}
64+
65+
async function fetchPage(url, cookie) {
66+
const res = await fetch(url, {
67+
redirect: 'manual',
68+
headers: {
69+
cookie,
70+
'user-agent': 'vm2-maintainer ghsa-list',
71+
accept: 'text/html',
72+
},
73+
});
74+
if (res.status >= 300 && res.status < 400) {
75+
const loc = res.headers.get('location') || '';
76+
if (loc.includes('/login')) {
77+
die(`cookie expired. Refresh GH_SESSION_COOKIE (see scripts/read-ghsa-thread.mjs header).`);
78+
}
79+
die(`unexpected redirect to ${loc}`);
80+
}
81+
if (!res.ok) die(`HTTP ${res.status} fetching ${url}`);
82+
const html = await res.text();
83+
if (/<title>Sign in to GitHub/i.test(html)) {
84+
die(`got login page despite 200 OK — cookie likely stale.`);
85+
}
86+
return html;
87+
}
88+
89+
// Each row is an <li class="Box-row..."> containing a status icon, title link,
90+
// id, opened-by metadata, status label, and severity label.
91+
function parseRows(html) {
92+
const rows = [];
93+
const liRe = /<li\b[^>]*class="[^"]*Box-row[^"]*"[^>]*>([\s\S]*?)<\/li>/gi;
94+
let m;
95+
while ((m = liRe.exec(html)) !== null) {
96+
const block = m[1];
97+
98+
const idM = block.match(/href="\/[^"]+\/security\/advisories\/(GHSA-[a-z0-9-]+)"/i);
99+
if (!idM) continue;
100+
const ghsa = idM[1];
101+
102+
const titleM = block.match(
103+
/<a\b[^>]*href="\/[^"]+\/security\/advisories\/GHSA-[a-z0-9-]+"[^>]*class="[^"]*Link--primary[^"]*"[^>]*>([\s\S]*?)<\/a>/i,
104+
);
105+
const title = titleM ? stripTags(titleM[1]) : '(no title)';
106+
107+
const statusM = block.match(/aria-label="([A-Za-z]+) advisory"/i);
108+
const status = statusM ? statusM[1] : null;
109+
110+
const sevM = block.match(/title="Severity:\s*([^"]+)"/i);
111+
const severity = sevM ? sevM[1] : null;
112+
113+
const dateM = block.match(/<relative-time\b[^>]*datetime="([^"]+)"/i);
114+
const date = dateM ? dateM[1] : null;
115+
116+
const verbM = block.match(/<span\b[^>]*class="opened-by"[^>]*>\s*([A-Za-z]+)\s*<relative-time/i);
117+
const verb = verbM ? verbM[1] : null;
118+
119+
const authorM = block.match(/<a\b[^>]*class="author[^"]*"[^>]*href="\/([A-Za-z0-9-]+)"/i)
120+
|| block.match(/data-hovercard-url="\/users\/([A-Za-z0-9-]+)\/hovercard"/i);
121+
const author = authorM ? authorM[1] : null;
122+
123+
// Fine-grained sub-status label (e.g. "Triage", "Draft") sits in a small
124+
// <span title="X" class="Label Label--secondary">X</span> next to severity.
125+
const subM = block.match(/<span\b[^>]*title="([^"]+)"[^>]*class="Label Label--secondary"/i);
126+
const subStatus = subM ? subM[1] : null;
127+
128+
rows.push({ ghsa, title, status, subStatus, severity, verb, date, author });
129+
}
130+
return rows;
131+
}
132+
133+
function parseNextPage(html, currentUrl) {
134+
const m = html.match(/<a\b[^>]*aria-label="Next page"[^>]*href="([^"]+)"/i)
135+
|| html.match(/<a\b[^>]*href="([^"]+)"[^>]*aria-label="Next page"/i);
136+
if (!m) return null;
137+
return new URL(decodeEntities(m[1]), currentUrl).toString();
138+
}
139+
140+
async function fetchAll(stateParam, cookie) {
141+
const base = `https://github.com/${OWNER}/${REPO}/security/advisories`;
142+
let url = stateParam ? `${base}?state=${stateParam}` : base;
143+
const all = [];
144+
const seen = new Set();
145+
while (url) {
146+
const html = await fetchPage(url, cookie);
147+
const rows = parseRows(html);
148+
for (const r of rows) {
149+
if (seen.has(r.ghsa)) continue;
150+
seen.add(r.ghsa);
151+
all.push(r);
152+
}
153+
const next = parseNextPage(html, url);
154+
if (!next || next === url) break;
155+
url = next;
156+
}
157+
return all;
158+
}
159+
160+
function fmtDate(iso) {
161+
if (!iso) return '';
162+
return iso.replace('T', ' ').replace(/\.\d+Z$/, 'Z');
163+
}
164+
165+
function renderTable(rows) {
166+
if (rows.length === 0) return '_No advisories matched._';
167+
const lines = [];
168+
lines.push('| GHSA | Title | Status | Severity | Opened | By |');
169+
lines.push('|------|-------|--------|----------|--------|----|');
170+
for (const r of rows) {
171+
const status = r.subStatus || r.status || '?';
172+
const sev = r.severity || '?';
173+
const title = r.title.replace(/\|/g, '\\|');
174+
const opened = fmtDate(r.date);
175+
const verb = r.verb ? ` (${r.verb})` : '';
176+
lines.push(`| ${r.ghsa} | ${title} | ${status} | ${sev} | ${opened}${verb} | ${r.author ? '@' + r.author : ''} |`);
177+
}
178+
return lines.join('\n');
179+
}
180+
181+
async function main() {
182+
const args = process.argv.slice(2);
183+
let mode = 'non-published';
184+
if (args.includes('--all')) mode = 'all';
185+
else if (args.includes('--published')) mode = 'published';
186+
187+
const cookie = loadCookie();
188+
189+
let rows = [];
190+
if (mode === 'published') {
191+
rows = await fetchAll('published', cookie);
192+
} else if (mode === 'all') {
193+
const a = await fetchAll('triage', cookie);
194+
const b = await fetchAll('published', cookie);
195+
rows = [...a, ...b];
196+
} else {
197+
rows = await fetchAll('triage', cookie);
198+
}
199+
200+
// Sort newest first.
201+
rows.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
202+
203+
const heading = mode === 'published'
204+
? `Published advisories — ${OWNER}/${REPO}`
205+
: mode === 'all'
206+
? `All advisories — ${OWNER}/${REPO}`
207+
: `Non-published advisories — ${OWNER}/${REPO}`;
208+
209+
process.stdout.write(`# ${heading}\n\n`);
210+
process.stdout.write(`Total: ${rows.length}\n\n`);
211+
process.stdout.write(renderTable(rows) + '\n');
212+
}
213+
214+
main().catch((e) => die(e.stack || String(e)));

0 commit comments

Comments
 (0)