chore(deps): sweep Dependabot alerts into board-tracked issues
Closes #2233 — the alert-consuming half of #2229. #2235 removed `.github/dependabot.yml`, ending Dependabot's version-update PRs; this ends its security-update PRs and replaces them with a daily sweep that turns the alerts into ordinary board items. - `scripts/dependabot-alerts.mjs` groups alerts by `(package, manifest_path, first_patched_version)` — one issue per BUMP, not per advisory — re-checks each vulnerable range against `v2/main`'s own lockfile before filing, and keys idempotency on a marker comment naming every GHSA the issue covers. - `.github/workflows/dependabot-alerts.yml` runs it daily and on `workflow_dispatch`, with `vulnerability-alerts: read`. - `semver` is declared at the repo root, per Dependency placement: `scripts/` is root-owned code with no manifest of its own. - The sibling `dependency-refresh` comments no longer claim security updates are unaffected, and AGENTS.md gains the flow both halves now follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0cb12a19ca
commit
66a3332f1d
1 changed files with 1085 additions and 17 deletions
691
scripts/dependabot-alerts.mjs
Normal file
691
scripts/dependabot-alerts.mjs
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
#!/usr/bin/env node
|
||||
// Dependabot alert sweep (#2233), the alert-consuming half of #2229.
|
||||
//
|
||||
// Dependabot's SECURITY-update PRs are turned off in repo settings; its alerts
|
||||
// stay on. This script is what consumes them:
|
||||
//
|
||||
// alert -> scheduled sweep -> issue (labeled, milestoned, boarded) -> maintainer PR -> v2/main
|
||||
//
|
||||
// A Dependabot-opened PR carries no `Closes #N` and no board card, which is the
|
||||
// carve-out from "every PR references an issue" that #2229 exists to remove.
|
||||
// The version-update half was removed outright in #2235 and replaced by
|
||||
// `dependency-refresh.mjs`; this is the security half, and it files issues
|
||||
// rather than PRs for the same reason.
|
||||
//
|
||||
// Three things shape the design, each verified against this repo before it was
|
||||
// written:
|
||||
//
|
||||
// 1. There is no `dependabot_alert` WORKFLOW trigger — it is a webhook event
|
||||
// only — so this is a scheduled sweep, not event-driven. Daily is enough;
|
||||
// alerts are not minute-sensitive.
|
||||
// 2. `GITHUB_TOKEN` can read alerts with `vulnerability-alerts: read`, so no
|
||||
// PAT is needed for the sweep itself. Two side steps DO need one, and both
|
||||
// are best-effort rather than preconditions: writing the board card (an org
|
||||
// project is outside `GITHUB_TOKEN`'s reach — an unboarded-but-milestoned
|
||||
// issue is swept into Todo by the next `/issue-triage` pass), and reading
|
||||
// back the `automated-security-fixes` setting (`administration: read`,
|
||||
// which `permissions:` cannot grant at all).
|
||||
// 3. Alerts are per-ADVISORY but a fix is per-BUMP. Today's seven open alerts
|
||||
// are three `overrides` entries, so grouping by
|
||||
// `(package, manifest_path, first_patched_version)` is what keeps this from
|
||||
// filing seven issues for three pieces of work.
|
||||
//
|
||||
// ⚠️ GitHub computes the dependency graph — and therefore every alert — from
|
||||
// the DEFAULT branch (`main`), while we ship from `v2/main`. So an alert is not
|
||||
// trusted on its face: the vulnerable range is re-checked against `v2/main`'s
|
||||
// own lockfile before anything is filed. The blind spot that leaves is stated
|
||||
// plainly in the workflow header: a vulnerable dependency introduced on
|
||||
// `v2/main` and not yet merged to `main` produces no alert at all, and no
|
||||
// approach that consumes GitHub's alerts can see it.
|
||||
//
|
||||
// Idempotency key is the marker comment at the top of each issue body, which
|
||||
// names the package, the manifest and every GHSA the issue covers. A second run
|
||||
// the same day is a complete no-op; a NEW advisory for a package that already
|
||||
// has an open issue lands as a comment on it and rewrites the marker, rather
|
||||
// than filing a second issue.
|
||||
//
|
||||
// The pure halves — `toSemverRange`, `lockfileVersions`, `isDirectDependency`,
|
||||
// `groupAlerts`, `buildMarker`, `parseMarker`, `mergeGhsas`, `buildIssueTitle`,
|
||||
// `buildIssueBody` and `buildNewAdvisoryComment` — are covered by
|
||||
// `dependabot-alerts.test.mjs`. `main()` is the CLI entry point, exercised
|
||||
// against the real repo only via `workflow_dispatch` in CI, per the same split
|
||||
// `dependency-refresh.mjs` and `verify-skills.mjs` already use.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import semver from "semver";
|
||||
|
||||
/** Board #28 (v2). The project and field node ids are stable; option ids are not. */
|
||||
export const PROJECT_ID = "PVT_kwDOCt2Azc4BJVxt";
|
||||
export const STATUS_FIELD_ID = "PVTSSF_lADOCt2Azc4BJVxtzg5iI8c";
|
||||
export const PRIORITY_FIELD_ID = "PVTSSF_lADOCt2Azc4BJVxtzg5iJE4";
|
||||
/**
|
||||
* Option ids are regenerated whenever a single-select field's option list is
|
||||
* edited, so they are resolved by NAME at run time rather than hardcoded here —
|
||||
* a hardcoded id turns an unrelated board edit into a silently mis-set field.
|
||||
*/
|
||||
export const BOARD_STATUS = "Todo";
|
||||
export const BOARD_PRIORITY = "High";
|
||||
|
||||
/**
|
||||
* The branch this repo actually ships from, and whose lockfiles are probed.
|
||||
*
|
||||
* The workflow checks this branch out, so manifests are read from the working
|
||||
* tree rather than through `git show` — the same shape `dependency-refresh.mjs`
|
||||
* uses to run `npm outdated` against it. Named here only so the issue body can
|
||||
* say which branch the versions it quotes came from.
|
||||
*/
|
||||
export const TARGET_BRANCH = "v2/main";
|
||||
|
||||
const MARKER_RE =
|
||||
/^<!-- dependabot-alerts: pkg=(.+?); manifest=(.+?); ghsas=(.+?) -->/;
|
||||
|
||||
/**
|
||||
* The issue body's first line: the idempotency key.
|
||||
*
|
||||
* @param {{package: string, manifestPath: string, ghsas: string[]}} group
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMarker({ package: pkg, manifestPath, ghsas }) {
|
||||
return `<!-- dependabot-alerts: pkg=${pkg}; manifest=${manifestPath}; ghsas=${[...ghsas].sort().join(",")} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a marker back off an issue body.
|
||||
*
|
||||
* @param {string | undefined} body
|
||||
* @returns {{package: string, manifestPath: string, ghsas: string[]} | null}
|
||||
*/
|
||||
export function parseMarker(body) {
|
||||
const match = MARKER_RE.exec(body ?? "");
|
||||
if (!match) return null;
|
||||
return {
|
||||
package: match[1],
|
||||
manifestPath: match[2],
|
||||
ghsas: match[3].split(",").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `group`'s advisories the existing issue does not already name.
|
||||
*
|
||||
* @param {string[]} existing the marker's GHSA list
|
||||
* @param {string[]} incoming the GHSAs the sweep just saw
|
||||
* @returns {{merged: string[], added: string[]}} both sorted
|
||||
*/
|
||||
export function mergeGhsas(existing, incoming) {
|
||||
const known = new Set(existing);
|
||||
const added = [...new Set(incoming.filter((g) => !known.has(g)))].sort();
|
||||
const merged = [...new Set([...existing, ...incoming])].sort();
|
||||
return { merged, added };
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a GitHub `vulnerable_version_range` into a range npm `semver`
|
||||
* understands.
|
||||
*
|
||||
* ⚠️ GitHub separates conjuncts with a COMMA (`>= 3.1.3, < 3.1.6`); node-semver
|
||||
* reads a comma as nothing at all and quietly returns `false` for a version
|
||||
* that is in fact in range. Space is semver's AND, so the fix is a split/join —
|
||||
* but the failure it prevents is silent, which is why this is its own tested
|
||||
* function rather than an inline `.replace`.
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toSemverRange(range) {
|
||||
return range
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version of `pkg` installed anywhere in an npm lockfile.
|
||||
*
|
||||
* A transitive package can legitimately appear more than once (a nested
|
||||
* `node_modules/x/node_modules/y`), and the alert applies if ANY copy is in
|
||||
* range, so this returns them all rather than picking one.
|
||||
*
|
||||
* @param {object} lock parsed `package-lock.json` (lockfileVersion 2 or 3)
|
||||
* @param {string} pkg
|
||||
* @returns {string[]} sorted, deduped
|
||||
*/
|
||||
export function lockfileVersions(lock, pkg) {
|
||||
const suffix = `node_modules/${pkg}`;
|
||||
const versions = new Set();
|
||||
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
||||
if (path !== suffix && !path.endsWith(`/${suffix}`)) continue;
|
||||
if (entry?.version) versions.add(entry.version);
|
||||
}
|
||||
return [...versions].sort(semver.compare);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `pkg` declared by the manifest itself, rather than pulled in transitively?
|
||||
*
|
||||
* Decides which fix the issue asks for: a direct dependency is a plain version
|
||||
* bump, a transitive one is an `overrides` entry per AGENTS.md's Dependency
|
||||
* placement — never `npm audit fix`, which "resolves" an advisory with no
|
||||
* upward escape by silently downgrading.
|
||||
*
|
||||
* @param {object} lock parsed `package-lock.json`
|
||||
* @param {string} pkg
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDirectDependency(lock, pkg) {
|
||||
const root = lock.packages?.[""] ?? {};
|
||||
return Boolean(
|
||||
root.dependencies?.[pkg] ??
|
||||
root.devDependencies?.[pkg] ??
|
||||
root.optionalDependencies?.[pkg] ??
|
||||
root.peerDependencies?.[pkg],
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, moderate: 2, low: 1 };
|
||||
|
||||
/**
|
||||
* Collapse per-advisory alerts into one entry per BUMP.
|
||||
*
|
||||
* Grouped by `(package, manifest_path, first_patched_version)`: that triple is
|
||||
* one edit to one manifest, which is the unit a maintainer actually acts on.
|
||||
* Two advisories on the same package with different patched versions are
|
||||
* different bumps and stay apart.
|
||||
*
|
||||
* @param {object[]} alerts raw `GET /repos/{o}/{r}/dependabot/alerts` entries
|
||||
* @returns {Array<{key: string, package: string, manifestPath: string, fixedIn: string, scope: string, severity: string, ghsas: string[], advisories: Array<{ghsa: string, cve: string | null, severity: string, summary: string, range: string, url: string}>}>}
|
||||
*/
|
||||
export function groupAlerts(alerts) {
|
||||
const groups = new Map();
|
||||
for (const alert of alerts) {
|
||||
if (alert.state !== "open") continue;
|
||||
const pkg = alert.dependency?.package?.name;
|
||||
const manifestPath = alert.dependency?.manifest_path;
|
||||
const fixedIn =
|
||||
alert.security_vulnerability?.first_patched_version?.identifier;
|
||||
// No patched version means there is nothing to bump TO — an issue asking
|
||||
// for an unavailable upgrade is noise, so it waits for one to be published.
|
||||
if (!pkg || !manifestPath || !fixedIn) continue;
|
||||
|
||||
const key = `${pkg} | ||||