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>
691 lines
24 KiB
JavaScript
691 lines
24 KiB
JavaScript
#!/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} |