#!/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 // degrade rather than block: 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 — so that guard reports UNVERIFIED // rather than failing when the token cannot see it). // 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. The release-time // `npm audit --audit-level=high` report (#2231) is the partial second signal. // // 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. // // Everything here is covered by `dependabot-alerts.test.mjs`: the pure halves // directly, and `main()` through an injected spawn function, the same way // `dependency-refresh.mjs` does it. `workflow_dispatch` is a production // trigger, not a test, so the orchestration that handles API failures, lockfile // filtering, issue idempotency and partial board writes is exercised here // rather than left to a real run (Copilot). 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"; /** * The one ecosystem this sweep can act on. * * ⚠️ Dependabot alerts are NOT npm-only. This repo has a `Dockerfile` and * GitHub Actions workflows, and an alert against either arrives in the same * feed with a `manifest_path` that is not a lockfile — which the JSON parse * would reject, aborting the whole daily sweep before any npm group was * processed (Copilot). Everything downstream reads npm lockfiles, so a non-npm * alert is reported and skipped rather than guessed at: filing it properly * means knowing how to fix it, which is different work per ecosystem. */ export const SUPPORTED_ECOSYSTEM = "npm"; const MARKER_RE = /^/; /** Marker on the comment that announces newly-seen advisories, keyed by GHSA. */ const COMMENT_MARKER_RE = /^/; /** * The issue body's first line: the idempotency key. * * It carries `fixedIn` as well as the package and manifest because that triple * IS the grouping key — two advisories on one package needing different patched * versions are different bumps and get different issues. Keyed on the pair * alone, a second bump would match the first issue and merge its GHSAs and its * target version into the wrong one (Copilot). * * @param {{package: string, manifestPath: string, fixedIn: string, ghsas: string[]}} group * @returns {string} */ export function buildMarker({ package: pkg, manifestPath, fixedIn, ghsas }) { return ``; } /** * Read a marker back off an issue body. * * @param {string | undefined} body * @returns {{package: string, manifestPath: string, fixedIn: 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], fixedIn: match[3], ghsas: match[4].split(",").filter(Boolean), }; } /** * The GHSAs a previously-posted "new advisories" comment already announced. * * @param {string | undefined} body * @returns {string[] | null} `null` when the comment carries no marker */ export function parseCommentMarker(body) { const match = COMMENT_MARKER_RE.exec(body ?? ""); if (!match) return null; return match[1].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 installed copy of `pkg` in an npm lockfile, with its tree path. * * A package can legitimately appear more than once — a hoisted * `node_modules/x` plus one or more nested `node_modules/y/node_modules/x` — * and the copies can be at DIFFERENT versions. The path is kept rather than * just the version because it is what distinguishes the copy the manifest * declares from a copy some dependency dragged in, and the fix for those two * is not the same (Copilot). * * @param {object} lock parsed `package-lock.json` (lockfileVersion 2 or 3) * @param {string} pkg * @returns {Array<{path: string, version: string, hoisted: boolean}>} sorted by version */ export function lockfileEntries(lock, pkg) { const hoistedPath = `node_modules/${pkg}`; const entries = []; for (const [path, entry] of Object.entries(lock.packages ?? {})) { if (path !== hoistedPath && !path.endsWith(`/${hoistedPath}`)) continue; if (!entry?.version) continue; entries.push({ path, version: entry.version, hoisted: path === hoistedPath, }); } return entries.sort( (a, b) => semver.compare(a.version, b.version) || a.path.localeCompare(b.path), ); } /** * Every version of `pkg` installed anywhere in an npm lockfile. * * @param {object} lock parsed `package-lock.json` (lockfileVersion 2 or 3) * @param {string} pkg * @returns {string[]} sorted, deduped */ export function lockfileVersions(lock, pkg) { return [...new Set(lockfileEntries(lock, pkg).map((e) => e.version))].sort( semver.compare, ); } /** * Is `pkg` declared by the manifest itself, rather than pulled in transitively? * * ⚠️ Being declared is NOT by itself the question the issue needs answered — * see `remediation` below. A manifest can declare a safe `pkg@^4` while some * dependency drags a vulnerable `pkg@3` into a nested folder, and telling the * maintainer to raise an already-safe range would leave the vulnerable copy * exactly where it is (Copilot). * * @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 }; /** * The grouping key: one bump, i.e. one edit to one manifest. * * JSON rather than a delimited string, because the three fields are free-form * and any separator would have to be argued for — the one that was here was a * literal NUL, which classified the whole source file as binary and made * repository searches skip it (Copilot). Shared with the end-of-run * reconciliation, so a key built from a marker and a key built from an alert * cannot drift apart. * * @returns {string} */ export function groupKey(pkg, manifestPath, fixedIn) { return JSON.stringify([pkg, manifestPath, fixedIn]); } /** * One advisory as it applies to one manifest. * * ⚠️ A GHSA alone is not enough. Dependabot alerts are per MANIFEST, and this * repo has five lockfiles — so the same advisory legitimately appears for the * root install and for a client. Keying reconciliation on the GHSA alone lets * another manifest's still-filable alert vouch for this one, and an issue whose * own alert lost its patched version would be cleared on the strength of a * different lockfile's alert (Copilot). * * @returns {string} */ export function advisoryKey(pkg, manifestPath, ghsa) { return JSON.stringify([pkg, manifestPath, ghsa]); } /** * 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 ecosystem = alert.dependency?.package?.ecosystem ?? "unknown"; 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 = groupKey(pkg, manifestPath, fixedIn); const advisory = { ghsa: alert.security_advisory?.ghsa_id ?? "", cve: alert.security_advisory?.cve_id ?? null, severity: alert.security_advisory?.severity ?? "unknown", summary: alert.security_advisory?.summary ?? "", range: alert.security_vulnerability?.vulnerable_version_range ?? "*", url: alert.html_url ?? "", }; const existing = groups.get(key); if (existing) { existing.advisories.push(advisory); if ( (SEVERITY_RANK[advisory.severity] ?? 0) > (SEVERITY_RANK[existing.severity] ?? 0) ) { existing.severity = advisory.severity; } continue; } groups.set(key, { key, package: pkg, ecosystem, manifestPath, fixedIn, scope: alert.dependency?.scope ?? "runtime", severity: advisory.severity, advisories: [advisory], }); } return [...groups.values()] .map((group) => { group.advisories.sort((a, b) => a.ghsa.localeCompare(b.ghsa)); group.ghsas = group.advisories.map((a) => a.ghsa); return group; }) .sort( (a, b) => a.package.localeCompare(b.package) || a.manifestPath.localeCompare(b.manifestPath) || a.fixedIn.localeCompare(b.fixedIn), ); } /** * The milestone a new issue takes: the open one with the NEAREST due date. * * ⚠️ Selected here rather than in a `jq` expression because jq sorts `null` * BEFORE every string, so a `sort_by(.due_on) | .[0]` over the raw list hands * back an undated milestone in preference to every dated one (Copilot). An * undated bucket has no due date and so cannot be the nearest; it is dropped * rather than sorted last, and if nothing dated is open the issue is filed * unmilestoned and triage places it. * * @param {Array<{title: string, state?: string, due_on?: string | null}>} milestones * @returns {string | null} */ export function pickMilestone(milestones) { const dated = (milestones ?? []).filter( (m) => (m.state ?? "open") === "open" && m.due_on, ); if (dated.length === 0) return null; return dated.sort((a, b) => a.due_on.localeCompare(b.due_on))[0].title; } /** * Narrow a group to the advisories that actually apply to what is installed. * * ⚠️ Grouping is by `(package, manifest, first_patched_version)`, and two * advisories sharing that triple can still have DIFFERENT vulnerable ranges — * `>= 3.1.3, < 3.1.6` and `>= 3.0.0, < 3.1.6` both patch at 3.1.6, and an * installed `3.1.0` matches only the second. Validating at the group level * ("does ANY advisory match?") keeps both, and the issue's marker, title, * severity, advisory table and later comments then all claim an advisory that * does not apply on this branch (Copilot). * * @param {ReturnType[number]} group * @param {Array<{path: string, version: string, hoisted: boolean}>} entries every installed copy * @returns {{group: ReturnType[number], affected: Array<{path: string, version: string, hoisted: boolean}>} | null} * `null` when nothing installed is in range of any of the group's advisories */ export function narrowToApplicable(group, entries) { const applies = (advisory, entry) => semver.satisfies(entry.version, toSemverRange(advisory.range)); const advisories = group.advisories.filter((a) => entries.some((e) => applies(a, e)), ); if (advisories.length === 0) return null; const affected = entries.filter((e) => advisories.some((a) => applies(a, e))); const severity = advisories.reduce( (worst, a) => (SEVERITY_RANK[a.severity] ?? 0) > (SEVERITY_RANK[worst] ?? 0) ? a.severity : worst, advisories[0].severity, ); return { group: { ...group, advisories, ghsas: advisories.map((a) => a.ghsa), severity, }, affected, }; } /** * The title counts the advisories that APPLY, which is what the body shows — * so it tracks an issue that grows a new advisory and one whose exposure * shrinks alike. The marker's GHSA list is a different thing: it is monotonic, * because its job is to remember what has already been announced. * * @param {ReturnType[number]} group narrowed to what applies * @returns {string} */ export function buildIssueTitle(group) { const n = group.advisories.length; return `chore(deps): bump \`${group.package}\` to \`${group.fixedIn}\` in \`${group.manifestPath}\` (${n} ${n === 1 ? "advisory" : "advisories"})`; } /** Escape a value going into a Markdown table cell. */ const cell = (value) => String(value).replace(/\|/g, "\\|"); const PLACEMENT_DOC = "https://github.com/modelcontextprotocol/inspector/blob/v2/main/AGENTS.md#dependency-placement"; /** * The packages a nested copy sits under, outermost first. * * `node_modules/ajv/node_modules/fast-uri` -> `["ajv"]`. Scope-aware, since a * scoped name contains a slash of its own. * * @param {string} path a lockfile `packages` key * @returns {string[]} empty for the hoisted copy */ export function overrideAncestors(path) { const segments = path.replace(/^node_modules\//, "").split("/node_modules/"); return segments.slice(0, -1); } /** * A concrete parent-scoped `overrides` block for the nested vulnerable copies. * * npm rejects a package-wide override that contradicts a direct dependency of * the same name (`EOVERRIDE`), so when the manifest declares the package the * nested copies must be reached through their parents instead. * * @param {Array<{path: string, hoisted: boolean}>} affected * @param {{package: string, fixedIn: string}} group * @returns {string} pretty-printed JSON */ export function scopedOverrideExample(affected, group) { const overrides = {}; for (const entry of affected) { const ancestors = overrideAncestors(entry.path); if (ancestors.length === 0) continue; let node = overrides; for (const ancestor of ancestors) { node[ancestor] = node[ancestor] ?? {}; node = node[ancestor]; } node[group.package] = group.fixedIn; } return JSON.stringify({ overrides }, null, 2); } /** * What the maintainer actually has to change, derived from WHICH copies are * vulnerable rather than from whether the package is declared. * * The three cases are genuinely different edits, and the mixed one is why this * is not a boolean (Copilot): * * | vulnerable copies | fix | * | --- | --- | * | the declared (hoisted) one | raise the declared range | * | nested ones only | an `overrides` pin | * | both | both, and neither alone is enough | * * A manifest declaring a safe `pkg@^4` alongside a dependency that drags in a * vulnerable nested `pkg@3` lands in the middle row: the declared range is * already correct, and raising it changes nothing. * * @param {Array<{path: string, version: string, hoisted: boolean}>} affected * @param {boolean} declared whether the manifest declares the package * @returns {{direct: boolean, transitive: boolean}} */ export function remediation(affected, declared) { const isDeclaredCopy = (entry) => declared && entry.hoisted; return { direct: affected.some(isDeclaredCopy), // Everything that is NOT the declared copy needs the override — nested // copies, and also a hoisted copy of a package this manifest never // declared, which got there transitively like any other. transitive: affected.some((entry) => !isDeclaredCopy(entry)), }; } /** * @param {ReturnType[number]} group * @param {{affected: Array<{path: string, version: string, hoisted: boolean}>, declared: boolean, ghsas?: string[]}} probe * `ghsas` overrides the marker's list when an existing issue is being * rewritten to cover advisories it did not originally name. * @returns {string} */ export function buildIssueBody( group, { affected, declared, ghsas, securityPrsOff = true }, ) { const covered = ghsas ?? group.ghsas; const applying = group.advisories.length; // ⚠️ Every free-form cell is escaped, the RANGE included: a semver range is // allowed to contain `||`, so a disjoint advisory range like // `>= 1.0, < 2.0 || >= 3.0, < 3.5` would otherwise inject two extra column // separators and shear the table apart (Copilot). const rows = group.advisories .map( (a) => `| [${a.ghsa}](${a.url}) | ${cell(a.cve ?? "—")} | ${cell(a.severity)} | ${cell(a.range)} | ${cell(a.summary)} |`, ) .join("\n"); const manifestJson = group.manifestPath.replace( /package-lock\.json$/, "package.json", ); const { direct, transitive } = remediation(affected, declared); const steps = []; if (direct) { steps.push( `**Raise the declared range in \`${manifestJson}\`** so \`${group.package}\` can no longer resolve below \`${group.fixedIn}\`, keeping the operator the manifest already uses — widening it to a bare \`>=\` would drop the compatibility bound with it.`, ); } if (transitive) { steps.push( direct ? // ⚠️ A package-wide override cannot be used here: npm rejects an // override whose spec differs from a direct dependency of the same // name with EOVERRIDE, and this manifest declares one (Copilot). The // nested copies have to be reached through their parents. `**Add a parent-scoped [\`overrides\`](${PLACEMENT_DOC}) entry** for the nested copies below, which the declared range does not reach:\n\n\`\`\`json\n${scopedOverrideExample(affected, group)}\n\`\`\`\n\n A package-wide \`"${group.package}": "${group.fixedIn}"\` would be rejected with \`EOVERRIDE\`, because this manifest also declares \`${group.package}\` directly and npm refuses an override that contradicts a direct dependency. **Not** \`npm audit fix\` either, which "resolves" an advisory with no upward escape by silently downgrading.` : `**Add an [\`overrides\`](${PLACEMENT_DOC}) entry** pinning \`${group.package}\` to \`${group.fixedIn}\`, for the copies below, which no declared range reaches. **Not** \`npm audit fix\`, which "resolves" an advisory with no upward escape by silently downgrading.`, ); } const fix = [ ...(steps.length === 2 ? [ "Both edits are needed; neither alone clears every vulnerable copy.", "", ] : []), ...steps.map((step, i) => (steps.length > 1 ? `${i + 1}. ${step}` : step)), "", "| Vulnerable copy | Version |", "| --- | --- |", ...affected.map((e) => `| \`${e.path}\` | \`${e.version}\` |`), ].join("\n"); return [ buildMarker({ ...group, ghsas: covered }), // Counts what APPLIES, like the title and the table — `covered` is the // marker's monotonic history and would keep counting an advisory that has // since closed (Copilot). // ⚠️ The security-PR claim is only made when the run actually READ the // setting. The guard degrades to UNVERIFIED when the token cannot see it, // and an issue asserting what the run explicitly could not confirm is worse // than one that says so (Copilot). `Filed automatically from ${applying} open Dependabot ${applying === 1 ? "alert" : "alerts"} (#2233). ${securityPrsOff ? "Dependabot opens no security-update PRs on this repo; the" : "The"} fix is written by hand against \`${TARGET_BRANCH}\`.`, "", "| | |", "| --- | --- |", `| Package | \`${group.package}\` |`, `| Manifest | \`${group.manifestPath}\` |`, `| Vulnerable on \`${TARGET_BRANCH}\` | ${[...new Set(affected.map((e) => e.version))].map((v) => `\`${v}\``).join(", ") || "—"} |`, `| Fixed in | \`${group.fixedIn}\` |`, `| Scope | ${group.scope} |`, `| Highest severity | ${group.severity} |`, "", "## Advisories", "", "| GHSA | CVE | Severity | Vulnerable range | Summary |", "| --- | --- | --- | --- | --- |", rows, "", "## Fix", "", fix, "", "> [!NOTE]", `> **Priority is a standing rubric override.** A routine bump scores Medium; a security bump is filed **${BOARD_PRIORITY}** so it does not sit.`, ">", `> **Where each number comes from.** The GHSA, CVE, severity, vulnerable range and summary are the advisory's own, reported by Dependabot. What was verified independently against \`${TARGET_BRANCH}\` is the **installed versions, their paths, and whether each advisory's range still matches** — GitHub computes alerts from the default branch, so an alert is filed here only after that re-check.`, ].join("\n"); } /** * The body an issue is rewritten to once its exposure is gone. * * The marker is retained, so the sweep still recognises this issue and will not * file a fresh one if the same advisory comes back into range. The issue is NOT * closed automatically: whether the exposure went away because a PR fixed it or * because the dependency was dropped decides whether the board card is moved to * Done or deleted, and that is a judgement the sweep cannot make. * * @param {ReturnType[number]} group * @param {{ghsas: string[], reason: string, today: string}} context * @returns {string} */ /** * The date an already-cleared body records, or `null` if it is not one. * * ⚠️ A cleared issue is deliberately left OPEN, so the sweep sees it again * tomorrow. With today's date baked into the rendered body, the regenerated * body would differ by the date alone and every cleared issue would be edited * once a day, forever (Copilot). Reusing the original date is what makes the * comparison stable — and it is the more useful date to show anyway: when the * exposure went away, not when the sweep last looked. * * @param {string | undefined} body * @returns {string | null} */ export function parseClearedDate(body) { const match = /\*\*No longer applicable on `[^`]+` as of (\d{4}-\d{2}-\d{2})\*\*/.exec( body ?? "", ); return match ? match[1] : null; } export function buildClearedBody(group, { ghsas, reason, today }) { return [ buildMarker({ ...group, ghsas }), `**No longer applicable on \`${TARGET_BRANCH}\` as of ${today}** — ${reason}.`, "", `Nothing here needs bumping any more: \`${group.package}\` is no longer exposed to ${ghsas.length === 1 ? "the advisory" : "the advisories"} below on the branch we ship from. This body is rewritten in place rather than the issue being closed, because whether the card belongs in **Done** or should be **deleted** depends on why the exposure went away — a merged fix shipped something, a dropped dependency did not.`, "", `Previously covered: ${ghsas.map((g) => `\`${g}\``).join(", ")}.`, "", "If the same advisory comes back into range, this issue is reused rather than a new one filed.", ].join("\n"); } /** * The marker that makes a "new advisories" comment idempotent on its own. * * @param {string[]} added * @returns {string} */ export function buildCommentMarker(added) { return ``; } /** * The comment a NEW advisory for an already-open issue gets, instead of a * second issue. * * @param {ReturnType[number]} group * @param {string[]} added the GHSAs not previously covered * @returns {string} */ export function buildNewAdvisoryComment(group, added) { const rows = group.advisories .filter((a) => added.includes(a.ghsa)) .map( (a) => `| [${a.ghsa}](${a.url}) | ${a.severity} | ${a.summary.replace(/\|/g, "\\|")} |`, ) .join("\n"); return [ buildCommentMarker(added), // ⚠️ Posted BEFORE the body edit, so it must not assert anything about the // body's current state — the edit may not have happened yet, and may fail // (Copilot). It speaks for the comment's own marker, which is true the // moment this is posted. `${added.length} new Dependabot ${added.length === 1 ? "advisory" : "advisories"} for \`${group.package}\`, cleared by the same bump to \`${group.fixedIn}\`. This comment's own marker records ${added.length === 1 ? "it" : "them"} as announced, so a later run will not repeat this even if the issue body has yet to catch up.`, "", "| GHSA | Severity | Summary |", "| --- | --- | --- |", rows, ].join("\n"); } // --------------------------------------------------------------------------- // Impure half: everything below shells out to `gh`. Each takes its spawn // function as a parameter, defaulted to `spawnSync`, so `main()` is testable // with an injected fake rather than left to `workflow_dispatch` — the same // shape `dependency-refresh.mjs` uses. // --------------------------------------------------------------------------- function gh(spawn, args, { token } = {}) { const env = token ? { ...process.env, GH_TOKEN: token } : process.env; const result = spawn("gh", args, { encoding: "utf8", env }); if (result.error) throw result.error; return result; } function ghJson(spawn, args) { const result = gh(spawn, args); if (result.status !== 0) { throw new Error(`gh ${args[0]} failed: ${(result.stderr ?? "").trim()}`); } return JSON.parse(result.stdout || "null"); } /** * Is this failed lookup the "this token may not read that" answer, rather than * a real API failure? * * The distinction is what keeps the security-PR guard honest: a bad token, a * rate limit or a transient 5xx must NOT be waved through as "unverified", or * the sweep exits green having silently skipped its own precondition. * * ⚠️ Status alone is not enough, which is what the first version got wrong * (Copilot). GitHub answers BOTH "you lack `administration: read`" and "you * have exhausted your quota" with **403**, and the second is a real failure — * so the rate-limit wording is excluded explicitly. **401** is a bad or expired * token, never a scope question, and is a real failure too. **404** stays * tolerated because GitHub hides resources a token cannot see behind one rather * than admitting they exist. * * @param {string} stderr stderr from a non-zero `gh api` call * @returns {boolean} */ export function isPermissionDenied(stderr) { if (/rate limit/i.test(stderr)) return false; return /HTTP (403|404)\b/.test(stderr); } /** * Detect whether Dependabot's security-update PRs have been switched back on. * * `automated-security-fixes` is a repo SETTING, so it can be re-enabled from * the UI without a commit and nothing in this repo would record it. This check * is this design's analogue of the merge guard #2060 needed: one API call * standing in for a required status check plus a ruleset change. * * ⚠️ **The endpoint needs `administration: read`, which `GITHUB_TOKEN` cannot * be granted** — `permissions:` has no such key. So with the default token the * call 403s, and treating that as failure would make every scheduled run red * for a reason unrelated to the alerts. An authorization-shaped failure is * therefore reported and skipped; every other failure throws, and an explicit * `enabled: true` throws. Give `PROJECT_TOKEN` the extra `administration: read` * scope and the guard becomes a real assertion; without it the sweep still does * its job, it just cannot see that setting. * * @returns {boolean} whether the setting was actually read */ function checkSecurityPrsStillDisabled(repo, spawn) { const result = gh(spawn, ["api", `repos/${repo}/automated-security-fixes`], { token: process.env.PROJECT_TOKEN, }); if (result.status !== 0) { const stderr = (result.stderr ?? "").trim(); if (!isPermissionDenied(stderr)) { throw new Error(`automated-security-fixes lookup failed: ${stderr}`); } console.log( `dependabot-alerts: cannot read automated-security-fixes (${stderr}) — ` + "the token lacks `administration: read`, so whether Dependabot security " + "PRs are still off is UNVERIFIED this run", ); return false; } const state = JSON.parse(result.stdout || "{}"); if (state.enabled === true) { throw new Error( "Dependabot security-update PRs are ENABLED again. This sweep exists to replace them; " + "an enabled setting means both flows are running and Dependabot is opening PRs with no " + "issue and no board card. Disable it (Settings -> Code security, or " + `DELETE /repos/${repo}/automated-security-fixes) and re-run.`, ); } return true; } /** * Every open Dependabot alert. * * ⚠️ `--slurp` is load-bearing. Without it `gh api --paginate` concatenates one * top-level JSON array PER PAGE, which `JSON.parse` rejects outright the moment * open alerts exceed the 100-per-page limit (Copilot). With it the pages arrive * as an array of arrays, flattened here. */ function openAlerts(repo, spawn) { const pages = ghJson(spawn, [ "api", "--paginate", "--slurp", `repos/${repo}/dependabot/alerts?state=open&per_page=100`, ]); return (pages ?? []).flat(); } /** * A manifest's contents in the checkout, or `null` when it is absent — an alert * against a manifest this branch does not have is not actionable. */ /** * Read a manifest, distinguishing the two ways it can fail to produce a lock. * * ⚠️ These must NOT collapse into one `null` (Copilot). "Absent" means the * manifest is genuinely gone from the branch, which is real evidence that the * exposure went away and is grounds for clearing the issue. "Unparseable" is * evidence of nothing at all — a malformed or truncated lockfile, or a * non-npm manifest — and clearing on it would stand down a live alert on the * strength of a read error. * * @returns {{lock: object} | {absent: true} | {unparseable: true}} */ function readManifest(manifestPath) { let raw; try { raw = readFileSync(manifestPath, "utf8"); } catch (error) { if (error.code === "ENOENT") return { absent: true }; throw error; } try { return { lock: JSON.parse(raw) }; } catch { // One unreadable manifest must not abort the sweep either. return { unparseable: true }; } } /** * Every open `dependabot`-labeled issue. * * Paginated rather than capped: the marker lookup is what makes this sweep * idempotent, so a truncated list files a duplicate for every issue it could * not see — at exactly the scale the `--slurp`ed alert fetch is built to handle * (Copilot). `/issues` also returns pull requests, which carry no marker and * are dropped. */ function openDependabotIssues(repo, spawn) { const pages = ghJson(spawn, [ "api", "--paginate", "--slurp", `repos/${repo}/issues?state=open&labels=dependabot&per_page=100`, ]); return (pages ?? []) .flat() .filter((issue) => !issue.pull_request) .map((issue) => ({ number: issue.number, title: issue.title, body: issue.body, })); } /** * Every GHSA already announced by a comment on an issue, unioned. * * Unioned per ADVISORY, not compared per comment. Comparing whole sets looks * equivalent and is not: a run that posts `[B]` and then fails before rewriting * the marker leaves the next run computing `[B, C]`, which matches no existing * comment, and `B` is announced a second time (Copilot). Individual GHSAs are * what a comment actually claims to have announced. */ function announcedAdvisories(repo, number, spawn) { const issue = ghJson(spawn, [ "issue", "view", String(number), "--repo", repo, "--json", "comments", ]); const announced = new Set(); for (const comment of issue?.comments ?? []) { for (const ghsa of parseCommentMarker(comment.body) ?? []) { announced.add(ghsa); } } return announced; } function currentMilestone(repo, spawn) { const result = gh(spawn, ["api", `repos/${repo}/milestones?state=open`]); if (result.status !== 0) { throw new Error(`milestone lookup failed: ${(result.stderr ?? "").trim()}`); } return pickMilestone(JSON.parse(result.stdout || "[]")); } /** * Resolve a single-select option id by NAME. * * Option ids are regenerated whenever the field's option list is edited, so * looking them up each run is what keeps an unrelated board edit from turning * into a silently mis-set field here. */ function optionId(fieldName, optionName, token, spawn) { const result = gh( spawn, [ "project", "field-list", "28", "--owner", "modelcontextprotocol", "--format", "json", ], { token }, ); if (result.status !== 0) { throw new Error(`field-list failed: ${(result.stderr ?? "").trim()}`); } const field = JSON.parse(result.stdout).fields.find( (f) => f.name === fieldName, ); const option = field?.options?.find((o) => o.name === optionName); if (!option) { throw new Error( `no ${fieldName} option named "${optionName}" on board #28`, ); } return option.id; } /** * Put the issue on board #28 at Todo / High. * * Failing to add the card at all is benign — the issue is already labeled and * milestoned, which is enough for the next `/issue-triage` sweep to board it * (its documented exception moves an unboarded-but-milestoned issue straight * into Todo). That is why an org-project PAT is an optimization here rather * than a prerequisite. * * ⚠️ **Failing PART WAY through is not benign**, and the two cases must not be * reported the same way (Copilot). Once `item-add` succeeds the issue IS * boarded, so no later triage sweep will look at it — a failed field edit * leaves a card sitting on the board with no Status or no Priority, in exactly * the state nothing else will fix. So a partial placement is returned to the * caller, which finishes every remaining group and then fails the run. * * Todo rather than Incoming: arriving through this pipeline IS the approval. * * @returns {string | null} a description of a PARTIAL placement, else `null` */ function addToBoard(issueUrl, spawn) { const token = process.env.PROJECT_TOKEN; if (!token) { console.log( "dependabot-alerts: PROJECT_TOKEN unset — issue left unboarded for the next triage sweep", ); return null; } let itemId; try { const added = gh( spawn, [ "project", "item-add", "28", "--owner", "modelcontextprotocol", "--url", issueUrl, "--format", "json", ], { token }, ); if (added.status !== 0) throw new Error((added.stderr ?? "").trim()); itemId = JSON.parse(added.stdout).id; } catch (error) { // Nothing was added, so the issue is simply unboarded — recoverable. console.log( `dependabot-alerts: board add failed (${error.message}) — issue is labeled and milestoned, next triage sweep will board it`, ); return null; } // Each item-edit sets exactly one field, so Status and Priority are two calls. for (const [fieldId, fieldName, optionName] of [ [STATUS_FIELD_ID, "Status", BOARD_STATUS], [PRIORITY_FIELD_ID, "Priority", BOARD_PRIORITY], ]) { try { const edit = gh( spawn, [ "project", "item-edit", "--project-id", PROJECT_ID, "--id", itemId, "--field-id", fieldId, "--single-select-option-id", optionId(fieldName, optionName, token, spawn), ], { token }, ); if (edit.status !== 0) throw new Error((edit.stderr ?? "").trim()); } catch (error) { return `${issueUrl} is on board #28 but its ${fieldName} was not set (${error.message}) — no triage sweep will fix this, set it by hand`; } } console.log( `dependabot-alerts: boarded ${issueUrl} at ${BOARD_STATUS}/${BOARD_PRIORITY}`, ); return null; } /** * @returns {{url: string, milestone: string | null}} */ function createIssue(repo, group, body, spawn) { const milestone = currentMilestone(repo, spawn); const args = [ "issue", "create", "--repo", repo, "--title", buildIssueTitle(group), "--label", "v2", "--label", "chore", "--label", "dependabot", "--body", body, ]; if (milestone) args.push("--milestone", milestone); const result = gh(spawn, args); if (result.status !== 0) { throw new Error(`gh issue create failed: ${(result.stderr ?? "").trim()}`); } const url = result.stdout.trim(); console.log(`dependabot-alerts: filed ${url}`); return { url, milestone }; } export function main( repo = process.env.GITHUB_REPOSITORY, spawn = spawnSync, today = new Date().toISOString().slice(0, 10), ) { if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)"); const securityPrsOff = checkSecurityPrsStillDisabled(repo, spawn); const alerts = openAlerts(repo, spawn); const groups = groupAlerts(alerts); // ⚠️ Loaded BEFORE the zero-group early return, and reconciled after the loop. // `openAlerts` asks for `state=open`, so an alert that is FIXED or DISMISSED // simply vanishes from the feed — its group is never built, the loop never // visits it, and the issue it produced would keep asserting a vulnerability // with a live Todo/High card forever (Copilot). The disappearance is the // signal, so it has to be read from the issues rather than from the alerts. const existingIssues = openDependabotIssues(repo, spawn).map((issue) => ({ ...issue, marker: parseMarker(issue.body), })); const manifests = new Map(); const boardProblems = []; /** * Rewrite an issue to its cleared state, at most once. * * The date is taken from the body already there when there is one, so a * cleared issue — which stays open, and so is seen again tomorrow — does not * get re-edited every day for a date change alone. */ const writeCleared = (issue, group, reason) => { const priorDate = parseClearedDate(issue.body); const ghsas = issue.marker.ghsas; if ( priorDate && issue.body === buildClearedBody(group, { ghsas, reason, today: priorDate }) ) { return; } const body = buildClearedBody(group, { ghsas, reason, today }); const edit = gh(spawn, [ "issue", "edit", String(issue.number), "--repo", repo, "--body", body, ]); if (edit.status !== 0) { throw new Error(`gh issue edit failed: ${(edit.stderr ?? "").trim()}`); } console.log(`dependabot-alerts: cleared #${issue.number} — ${reason}`); }; /** Grouping keys this run actually saw in the open feed. */ const seenKeys = new Set(); /** * Every open `(package, manifest, GHSA)`, taken from the RAW feed rather * than from `groups`. * * ⚠️ `groupAlerts` deliberately drops an alert with no `first_patched_version` * — there is nothing to bump to, so nothing to file. Building this set from * the groups would inherit that filter, so an advisory that stays open but * LOSES its patched version would vanish from both the keys and this set, and * reconciliation would call it "fixed or dismissed" (Copilot). Same wrong * direction as the superseded case, reached a different way: what a still-open * advisory must never do is stand itself down. */ /** * What this run actually established about each `(package, manifest, GHSA)`. * * ⚠️ Recorded DURING the loop, not derived from `groups` beforehand. Being in * a group only means an alert exists; whether an issue tracks it is decided * by the ecosystem check, the manifest read and the range probe that follow * (Copilot). Reading "is it in a group?" as "does it have an issue?" would * clear an old issue as superseded while its replacement was skipped. * * - `tracked` — an issue for it exists after this run. * - `not-exposed` — probed, and nothing installed is in range. * - `indeterminate` — could not be probed at all this run. * * Absent means no group carried it, i.e. no bump is available for it. * * @type {Map} */ const disposition = new Map(); const note = (group, ghsas, value) => { for (const ghsa of ghsas) { disposition.set( advisoryKey(group.package, group.manifestPath, ghsa), value, ); } }; const openAdvisories = new Set( alerts .filter((a) => a.state === "open") .map((a) => a.dependency?.package?.name && a.dependency?.manifest_path && a.security_advisory?.ghsa_id ? advisoryKey( a.dependency.package.name, a.dependency.manifest_path, a.security_advisory.ghsa_id, ) : null, ) .filter(Boolean), ); for (const rawGroup of groups) { seenKeys.add(rawGroup.key); if (rawGroup.ecosystem !== SUPPORTED_ECOSYSTEM) { // Loud, not silent: nothing else will file this, so a human has to. console.log( `dependabot-alerts: ${rawGroup.package} (${rawGroup.ecosystem}, ${rawGroup.manifestPath}) is not an npm dependency — this sweep cannot file it, raise it by hand: ${rawGroup.ghsas.join(", ")}`, ); note(rawGroup, rawGroup.ghsas, "indeterminate"); continue; } // ⚠️ Resolved BEFORE the skips below, not after. An issue filed yesterday // is still open today, and if the manifest has since gone or every copy has // moved out of range, skipping straight past it leaves its body asserting a // vulnerability that no longer exists and its Todo/High card live forever // (Copilot). Matched on the full grouping key, `fixedIn` included: a second // bump of the same package is a different issue, not an update to this one. const existing = existingIssues.find( (i) => i.marker?.package === rawGroup.package && i.marker?.manifestPath === rawGroup.manifestPath && i.marker?.fixedIn === rawGroup.fixedIn, ); /** Rewrite an open issue to its cleared state, once. */ const clear = (reason) => { if (!existing) return; writeCleared(existing, rawGroup, reason); }; if (!manifests.has(rawGroup.manifestPath)) { manifests.set(rawGroup.manifestPath, readManifest(rawGroup.manifestPath)); } const manifest = manifests.get(rawGroup.manifestPath); if (manifest.unparseable) { // Deliberately does NOT clear: a read error is not evidence that the // exposure went away, and treating it as such stands down a live alert. console.log( `dependabot-alerts: ${rawGroup.manifestPath} could not be parsed as an npm lockfile — skipping ${rawGroup.package} WITHOUT clearing its issue`, ); note(rawGroup, rawGroup.ghsas, "indeterminate"); continue; } if (manifest.absent) { console.log( `dependabot-alerts: ${rawGroup.manifestPath} absent on ${TARGET_BRANCH} — skipping ${rawGroup.package}`, ); note(rawGroup, rawGroup.ghsas, "not-exposed"); clear(`\`${rawGroup.manifestPath}\` is no longer part of this repo`); continue; } const { lock } = manifest; const entries = lockfileEntries(lock, rawGroup.package); const applicable = narrowToApplicable(rawGroup, entries); if (applicable === null) { const seen = [...new Set(entries.map((e) => e.version))]; console.log( `dependabot-alerts: ${rawGroup.package}@${seen.join("/") || "(absent)"} is already out of range on ${TARGET_BRANCH} — skipping`, ); note(rawGroup, rawGroup.ghsas, "not-exposed"); clear( seen.length > 0 ? `every installed copy is out of range (${seen.map((v) => `\`${v}\``).join(", ")})` : "the package is no longer installed at all", ); continue; } // From here on `group` carries only the advisories that apply to this // branch, so the marker, title, severity and table cannot overstate it. const { group, affected } = applicable; // The narrowing dropped advisories whose range no longer matches: those are // probed-and-clear, the survivors get an issue. note( rawGroup, rawGroup.ghsas.filter((g) => !group.ghsas.includes(g)), "not-exposed", ); note(group, group.ghsas, "tracked"); const declared = isDirectDependency(lock, group.package); if (!existing) { const { url, milestone } = createIssue( repo, group, buildIssueBody(group, { affected, declared, securityPrsOff }), spawn, ); // `Incoming` <=> no milestone, everything past it <=> milestoned. With no // open milestone to assign there is nothing to put the card past Incoming // WITH, so boarding it at Todo would assert an approval the invariant // reads off the milestone (Copilot). Leave it for triage instead. if (!milestone) { console.log( "dependabot-alerts: no open milestone — issue filed unmilestoned and unboarded, next triage sweep places it", ); continue; } const problem = addToBoard(url, spawn); if (problem) boardProblems.push(problem); continue; } const { merged, added } = mergeGhsas(existing.marker.ghsas, group.ghsas); const title = buildIssueTitle(group); const body = buildIssueBody(group, { affected, declared, ghsas: merged, securityPrsOff, }); // ⚠️ "Nothing NEW" is not the same as "nothing CHANGED" (Copilot). An issue // filed for A+B whose branch has since moved so only B applies has no added // GHSAs, yet its table, severity, affected copies and remediation are all // stale. So the no-op is decided by comparing the rendered issue, not by // counting additions — while a COMMENT stays reserved for advisories that // are genuinely new. if ( added.length === 0 && existing.title === title && existing.body === body ) { console.log( `dependabot-alerts: #${existing.number} is up to date for ${group.package} — no-op`, ); continue; } // ⚠️ Comment FIRST, then rewrite the marker. The marker is the idempotency // key, so editing it first and failing on the comment would make the next // run take the no-op branch above and skip the comment permanently // (Copilot). In this order the worst case is a repeat, and the comment's // own marker rules that out too. const announced = announcedAdvisories(repo, existing.number, spawn); const unannounced = added.filter((ghsa) => !announced.has(ghsa)); if (unannounced.length > 0) { const comment = gh(spawn, [ "issue", "comment", String(existing.number), "--repo", repo, "--body", buildNewAdvisoryComment(group, unannounced), ]); if (comment.status !== 0) { throw new Error( `gh issue comment failed: ${(comment.stderr ?? "").trim()}`, ); } } const edit = gh(spawn, [ "issue", "edit", String(existing.number), "--repo", repo, "--title", title, "--body", body, ]); if (edit.status !== 0) { throw new Error(`gh issue edit failed: ${(edit.stderr ?? "").trim()}`); } console.log( added.length > 0 ? `dependabot-alerts: added ${added.join(", ")} to #${existing.number}` : `dependabot-alerts: refreshed #${existing.number} for ${group.package}`, ); } // Any marked issue whose bump is no longer in the open feed at all: its last // alert was fixed or dismissed, so there is nothing left to bump. for (const issue of existingIssues) { if (!issue.marker) continue; const key = groupKey( issue.marker.package, issue.marker.manifestPath, issue.marker.fixedIn, ); if (seenKeys.has(key)) continue; // ⚠️ A vanished KEY is not the same as a closed ADVISORY. GitHub can revise // an alert's `first_patched_version`, which moves it to a different key // while the GHSA stays open — reporting that as "fixed or dismissed" would // stand down a live exposure (Copilot). So the reason is decided by whether // the GHSAs are still in the open feed, not by the key's absence. const key3 = (ghsa) => advisoryKey(issue.marker.package, issue.marker.manifestPath, ghsa); const stillOpen = issue.marker.ghsas.filter((g) => openAdvisories.has(key3(g)), ); // ⚠️ Clearing needs positive evidence about every advisory still open here. // Two things deny it, and both mean "leave the issue alone" (Copilot): // an advisory this run could not probe (`indeterminate` — a non-npm // manifest, or one that would not parse), and one no group carried at all // (absent — `groupAlerts` drops an alert with no `first_patched_version`, // so there is nothing to bump to and no replacement issue). const unresolved = stillOpen.filter((g) => { const state = disposition.get(key3(g)); return state === undefined || state === "indeterminate"; }); if (unresolved.length > 0) { console.log( `dependabot-alerts: #${issue.number} left as is — ${unresolved.join(", ")} ${unresolved.length === 1 ? "is" : "are"} still open and this run could not establish a replacement`, ); continue; } const tracked = stillOpen.filter( (g) => disposition.get(key3(g)) === "tracked", ); const reason = stillOpen.length === 0 ? "every alert it tracked has been fixed or dismissed" : tracked.length > 0 ? `this bump was superseded — ${tracked.map((g) => `\`${g}\``).join(", ")} ${tracked.length === 1 ? "is" : "are"} still open under a different patched version, and ${tracked.length === 1 ? "has" : "have"} their own issue` : "no installed copy is in range of its advisories any more"; writeCleared( issue, { package: issue.marker.package, manifestPath: issue.marker.manifestPath, fixedIn: issue.marker.fixedIn, }, reason, ); } if (groups.length === 0) { console.log("dependabot-alerts: no open alerts"); } // Every group is processed before this throws: a half-placed card is worth // failing the run over, but not at the cost of the issues still unfiled. if (boardProblems.length > 0) { throw new Error( `dependabot-alerts: incomplete board placement —\n ${boardProblems.join("\n ")}`, ); } } if (import.meta.url === `file://${process.argv[1]}`) { main(); }