|
| 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(/&/g, '&') |
| 52 | + .replace(/</g, '<') |
| 53 | + .replace(/>/g, '>') |
| 54 | + .replace(/"/g, '"') |
| 55 | + .replace(/'/g, "'") |
| 56 | + .replace(/ /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