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:
cliffhall 2026-09-03 21:13:39 -04:00 • committed by GitHub
commit 66a3332f1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1085 additions and 17 deletions

89
.github/workflows/dependabot-alerts.yml vendored Normal file
View file

@ -0,0 +1,89 @@
# Dependabot alert sweep (#2233), the alert-consuming half of #2229.
#
# Dependabot's SECURITY-update PRs are turned off for this repo; its ALERTS are
# left on. Those are two independent settings, and this workflow depends on the
# split: it reads the alerts and turns them into ordinary board-tracked issues,
# so the fix is written by hand against `v2/main` like any other work.
#
# alert -> this sweep -> issue (labeled, milestoned, boarded) -> maintainer PR -> v2/main
#
# It is a SCHEDULE, not an event handler, because there is no `dependabot_alert`
# workflow trigger — that is a webhook event only. Daily is deliberate: with
# security PRs off there is no mergeable-against-`main` artifact and no window to
# race, so the merge guard #2060 needed has no analogue here. What replaces it is
# a precondition inside the script: `automated-security-fixes` is a repo SETTING
# and can be switched back on from the UI without a commit, so the sweep asserts
# it is still disabled and fails loudly if it is not.
#
# ⚠️ GitHub computes the dependency graph, and therefore every alert, from the
# DEFAULT branch (`main`), while we ship from `v2/main`. Two consequences:
#
# * An alert is re-checked against `v2/main`'s own lockfile before an issue is
# filed — hence the `ref: v2/main` checkout below. An alert whose vulnerable
# range no longer matches is already fixed on the branch we ship from and is
# waiting on a milestone merge to close, so it is skipped silently.
# * A vulnerable dependency introduced on `v2/main` and not yet merged to
# `main` produces NO alert at all. No approach that consumes GitHub's alerts
# avoids that. The release-time `npm audit fix` from #2231 is a second signal
# that partially covers it; a scheduled `npm audit --audit-level=high` over
# `v2/main`'s lockfiles would close it fully and is a separable follow-up.
#
# `vulnerability-alerts: read` is the one non-default permission, and
# `GITHUB_TOKEN` supports it — no PAT is needed to read the alerts themselves.
# Two side steps are outside its reach, and `PROJECT_TOKEN` is what covers them
# when it exists:
#
# * Writing the board card, since board #28 is an ORG project
# (`organization projects: write`). Absent, the issue is still filed labeled
# and milestoned and the next `/issue-triage` sweep boards it.
# * Reading back `automated-security-fixes`, which needs `administration: read`
# — a permission `permissions:` has no key for, so `GITHUB_TOKEN` can never
# have it. Absent, that assertion is reported as UNVERIFIED rather than
# failing the run; an explicit `enabled: true` still fails it.
#
# Nothing the sweep exists to do is skipped for want of that secret.
#
# The version-update half of #2229 is the sibling `dependency-refresh.yml`.
name: Dependabot Alert Sweep
on:
schedule:
- cron: "17 6 * * *" # 06:17 UTC daily; alerts are not minute-sensitive
workflow_dispatch:
permissions:
contents: read
issues: write
vulnerability-alerts: read
jobs:
alert-sweep:
runs-on: ubuntu-latest
steps:
- name: Checkout v2/main
uses: actions/checkout@v7
with:
ref: v2/main
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "22.x"
cache: "npm"
# Root install only, and no lifecycle scripts. The sweep's one dependency
# is `semver`; it reads every lockfile as JSON and never needs a client's
# tree on disk, so the postinstall cascade into `clients/*` that
# `dependency-refresh.yml` genuinely needs (it shells out to
# `npm outdated` in each) would be minutes of nothing here.
- name: Install root dependencies
run: npm ci --ignore-scripts
- name: Run the Dependabot alert sweep
run: node scripts/dependabot-alerts.mjs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
# Optional: an org-project PAT with `organization projects: write`.
# Absent, the issue is filed unboarded and triage picks it up.
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}

View file

@ -2,19 +2,21 @@
#
# A Dependabot version-update PR carries no issue and no board card, so
# `.github/dependabot.yml` was removed outright in #2235 — npm and
# github-actions alike. This workflow is what replaced those PRs (security
# updates are a separate mechanism and stay on; see below): it runs
# `scripts/dependency-refresh.mjs` against `v2/main` once a month and files or
# updates ONE tracking issue listing every outdated npm package across the root
# install and each client, plus any workflow `uses:` ref behind its action's
# highest released version. No PR is opened automatically. A maintainer
# reviews the
# issue, picks what to bump, and opens a normal PR against `v2/main`.
# github-actions alike. This workflow is what replaced those PRs (the
# security-update half is a separate mechanism, switched off separately; see
# below): it runs `scripts/dependency-refresh.mjs` against `v2/main` once a
# month and files or updates ONE tracking issue listing every outdated npm
# package across the root install and each client, plus any workflow `uses:`
# ref behind its action's highest released version. No PR is opened
# automatically. A maintainer reviews the issue, picks what to bump, and opens
# a normal PR against `v2/main`.
#
# Dependabot SECURITY updates are unaffected: they are enabled in repo
# settings, not in a config file, and kept working while `dependabot.yml` was
# missing entirely (see #1833, #1840). They are raised against the default
# branch and still need retargeting by hand.
# Dependabot SECURITY updates are the other half, and #2233 turned their PRs
# off too — they were enabled in repo settings rather than in a config file,
# which is why they kept working while `dependabot.yml` was missing entirely
# (see #1833, #1840) and why deleting that file did not touch them. Its
# ALERTS stay on and are swept into issues daily by the sibling
# `dependabot-alerts.yml`. Between them, Dependabot opens no PRs here at all.
#
# `GITHUB_TOKEN` is sufficient: it only needs to read milestones and the public
# release feeds of the actions we use, and to create/edit an issue

View file

@ -98,6 +98,24 @@ The reasoning behind each of these, and what breaks when it is ignored, is the
- **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this.
- **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` — which "resolves" an advisory with no upward escape by silently downgrading.
### Dependency updates are issue-driven, like everything else
**Dependabot opens no pull requests against this repo — neither version updates nor security updates.** A Dependabot PR carries no `Closes #N` and no board card, so it was the one standing exception to [Issue-driven Work Style](#issue-driven-work-style), enforced by nothing. Both halves are now replaced by scheduled workflows that file **issues**, and a maintainer writes the fix by hand against `v2/main`.
| Half | Switched off by | Replaced by | Cadence |
| --- | --- | --- | --- |
| Version updates | Deleting `.github/dependabot.yml` outright (#2235) — an empty `updates:` list is not valid config | `.github/workflows/dependency-refresh.yml` → `scripts/dependency-refresh.mjs`: `npm outdated` across every install, plus a `uses:` check against each action's latest release, folded into **one** tracking issue | Monthly |
| Security updates | `DELETE /repos/{owner}/{repo}/automated-security-fixes` — a **repo setting**, not a file | `.github/workflows/dependabot-alerts.yml` → `scripts/dependabot-alerts.mjs`: reads the alerts and files one issue **per bump** | Daily |
Four things about this that are not obvious from the code:
- **Dependabot *alerts* stay on.** Alerts and security-update PRs are independent settings; only the PRs are off. Turning alerts off would blind the sweep that replaced them.
- **The security half is a schedule, not an event handler**, because there is no `dependabot_alert` workflow trigger — it is a webhook event only.
- **Alerts are computed from the default branch (`main`), and we ship from `v2/main`.** So the sweep re-checks each alert's vulnerable range against `v2/main`'s own lockfile before filing, and skips one that is already fixed there. The converse is a real blind spot with no fix on this path: a vulnerable dependency introduced on `v2/main` and not yet merged to `main` produces **no alert at all**. The release-time `npm audit fix` (#2231) is the partial second signal.
- **`automated-security-fixes` can be re-enabled from the UI without a commit**, so nothing in the repo would record it. The sweep asserts it is still disabled and **fails loudly** if it is not; a red run of that workflow means the setting was flipped, not that the script broke.
An issue filed by either sweep is an ordinary board item — `v2` + `chore` + `dependabot`, the current milestone, and a card on #28. A security issue lands at **Todo / High**: arriving through this pipeline *is* the approval, and `High` is a standing override of the [priority rubric](.claude/skills/issue-triage/SKILL.md), which would otherwise score a routine bump Medium. Board placement needs an org-project PAT that `GITHUB_TOKEN` cannot have, so it is **best-effort** — without the secret the issue is still created labeled and milestoned, and the next triage sweep boards it.
## Contributing
External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow.

1
package-lock.json generated
View file

@ -46,6 +46,7 @@
"express": "^5.2.1",
"globals": "^17.7.0",
"prettier": "3.8.4",
"semver": "^7.8.5",
"typescript": "~5.9.3",
"typescript-eslint": "^8.65.0",
"vitest": "4.1.10"

View file

@ -123,6 +123,7 @@
"express": "^5.2.1",
"globals": "^17.7.0",
"prettier": "3.8.4",
"semver": "^7.8.5",
"typescript": "~5.9.3",
"typescript-eslint": "^8.65.0",
"vitest": "4.1.10"

View 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}${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,
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),
);
}
/**
* @param {ReturnType<typeof groupAlerts>[number]} group
* @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"})`;
}
const PLACEMENT_DOC =
"https://github.com/modelcontextprotocol/inspector/blob/v2/main/AGENTS.md#dependency-placement";
/**
* @param {ReturnType<typeof groupAlerts>[number]} group
* @param {{installed: string[], direct: 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, { installed, direct, ghsas }) {
const covered = ghsas ?? group.ghsas;
const rows = group.advisories
.map(
(a) =>
`| [${a.ghsa}](${a.url}) | ${a.cve ?? "—"} | ${a.severity} | ${a.range} | ${a.summary.replace(/\|/g, "\\|")} |`,
)
.join("\n");
const fix = direct
? `\`${group.package}\` is a **direct** dependency of \`${group.manifestPath.replace(/package-lock\.json$/, "package.json")}\` — bump its declared range to \`>=${group.fixedIn}\`.`
: `\`${group.package}\` is **transitive**, so the fix is an [\`overrides\`](${PLACEMENT_DOC}) entry pinning it to \`${group.fixedIn}\` — **not** \`npm audit fix\`, which "resolves" an advisory with no upward escape by silently downgrading.`;
return [
buildMarker({ ...group, ghsas: covered }),
`Filed automatically from ${covered.length} open Dependabot ${covered.length === 1 ? "alert" : "alerts"} (#2233). Dependabot opens no security-update PRs on this repo; the fix is written by hand against \`v2/main\`.`,
"",
"| | |",
"| --- | --- |",
`| Package | \`${group.package}\` |`,
`| Manifest | \`${group.manifestPath}\` |`,
`| Installed on \`v2/main\` | ${installed.length > 0 ? installed.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. The version and severity above come from \`${TARGET_BRANCH}\`'s own lockfile, not from the alert — GitHub computes alerts from the default branch, so an alert is only filed here after its vulnerable range is re-checked against the branch we ship from.`,
].join("\n");
}
/**
* The comment a NEW advisory for an already-open issue gets, instead of a
* second issue.
*
* @param {ReturnType<typeof groupAlerts>[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 [
`${added.length} new Dependabot ${added.length === 1 ? "advisory" : "advisories"} for \`${group.package}\`, cleared by the same bump to \`${group.fixedIn}\`. The issue body's marker now covers ${added.length === 1 ? "it" : "them"} too.`,
"",
"| GHSA | Severity | Summary |",
"| --- | --- | --- |",
rows,
].join("\n");
}
// ---------------------------------------------------------------------------
// Impure half: everything below shells out to `gh` or `git`.
// ---------------------------------------------------------------------------
function gh(args, { token } = {}) {
const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
const result = spawnSync("gh", args, { encoding: "utf8", env });
if (result.error) throw result.error;
return result;
}
function ghJson(args) {
const result = gh(args);
if (result.status !== 0) {
throw new Error(`gh ${args[0]} failed: ${(result.stderr ?? "").trim()}`);
}
return JSON.parse(result.stdout || "null");
}
/**
* 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. It is therefore reported and skipped:
* only 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.
*/
function checkSecurityPrsStillDisabled(repo) {
const result = gh(["api", `repos/${repo}/automated-security-fixes`], {
token: process.env.PROJECT_TOKEN,
});
if (result.status !== 0) {
console.log(
"dependabot-alerts: cannot read automated-security-fixes " +
`(${(result.stderr ?? "").trim()}) — the token lacks \`administration: read\`, ` +
"so whether Dependabot security PRs are still off is UNVERIFIED this run",
);
return;
}
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.`,
);
}
}
function openAlerts(repo) {
return ghJson([
"api",
"--paginate",
`repos/${repo}/dependabot/alerts?state=open&per_page=100`,
]);
}
/**
* 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.
*/
function readManifest(manifestPath) {
try {
return JSON.parse(readFileSync(manifestPath, "utf8"));
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
function openDependabotIssues(repo) {
return ghJson([
"issue",
"list",
"--repo",
repo,
"--state",
"open",
"--label",
"dependabot",
"--json",
"number,body",
"--limit",
"100",
]);
}
function currentMilestone(repo) {
const result = gh([
"api",
`repos/${repo}/milestones`,
"--jq",
'map(select(.state=="open")) | sort_by(.due_on) | .[0].title // empty',
]);
if (result.status !== 0) {
throw new Error(`milestone lookup failed: ${(result.stderr ?? "").trim()}`);
}
return result.stdout.trim() || null;
}
/**
* 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) {
const result = gh(
[
"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.
*
* Best-effort by design: an org project is outside `GITHUB_TOKEN`'s reach, so
* this needs a PAT the workflow may not have. A failure here is logged and the
* run continues — 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).
*
* Todo rather than Incoming: arriving through this pipeline IS the approval.
*/
function addToBoard(issueUrl) {
const token = process.env.PROJECT_TOKEN;
if (!token) {
console.log(
"dependabot-alerts: PROJECT_TOKEN unset — issue left unboarded for the next triage sweep",
);
return;
}
try {
const added = gh(
[
"project",
"item-add",
"28",
"--owner",
"modelcontextprotocol",
"--url",
issueUrl,
"--format",
"json",
],
{ token },
);
if (added.status !== 0) {
throw new Error((added.stderr ?? "").trim());
}
const itemId = JSON.parse(added.stdout).id;
// 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],
]) {
const edit = gh(
[
"project",
"item-edit",
"--project-id",
PROJECT_ID,
"--id",
itemId,
"--field-id",
fieldId,
"--single-select-option-id",
optionId(fieldName, optionName, token),
],
{ token },
);
if (edit.status !== 0) throw new Error((edit.stderr ?? "").trim());
}
console.log(
`dependabot-alerts: boarded ${issueUrl} at ${BOARD_STATUS}/${BOARD_PRIORITY}`,
);
} catch (error) {
console.log(
`dependabot-alerts: board write failed (${error.message}) — issue is labeled and milestoned, next triage sweep will board it`,
);
}
}
function createIssue(repo, group, body) {
const milestone = currentMilestone(repo);
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(args);
if (result.status !== 0) {
throw new Error(`gh issue create failed: ${(result.stderr ?? "").trim()}`);
}
const url = result.stdout.trim();
if (!milestone) {
console.log(
"dependabot-alerts: no open milestone — issue filed unmilestoned",
);
}
console.log(`dependabot-alerts: filed ${url}`);
return url;
}
export function main(repo = process.env.GITHUB_REPOSITORY) {
if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)");
checkSecurityPrsStillDisabled(repo);
const groups = groupAlerts(openAlerts(repo));
if (groups.length === 0) {
console.log("dependabot-alerts: no open alerts — no-op");
return;
}
const existingIssues = openDependabotIssues(repo).map((issue) => ({
...issue,
marker: parseMarker(issue.body),
}));
const manifests = new Map();
for (const group of groups) {
if (!manifests.has(group.manifestPath)) {
manifests.set(group.manifestPath, readManifest(group.manifestPath));
}
const lock = manifests.get(group.manifestPath);
if (lock === null) {
console.log(
`dependabot-alerts: ${group.manifestPath} absent on ${TARGET_BRANCH} — skipping ${group.package}`,
);
continue;
}
const installed = lockfileVersions(lock, group.package);
const affected = installed.filter((version) =>
group.advisories.some((a) =>
semver.satisfies(version, toSemverRange(a.range)),
),
);
if (affected.length === 0) {
console.log(
`dependabot-alerts: ${group.package}@${installed.join("/") || "(absent)"} is already out of range on ${TARGET_BRANCH} — skipping`,
);
continue;
}
const direct = isDirectDependency(lock, group.package);
const existing = existingIssues.find(
(i) =>
i.marker?.package === group.package &&
i.marker?.manifestPath === group.manifestPath,
);
if (!existing) {
const url = createIssue(
repo,
group,
buildIssueBody(group, { installed: affected, direct }),
);
addToBoard(url);
continue;
}
const { merged, added } = mergeGhsas(existing.marker.ghsas, group.ghsas);
if (added.length === 0) {
console.log(
`dependabot-alerts: #${existing.number} already covers ${group.package} — no-op`,
);
continue;
}
const edit = gh([
"issue",
"edit",
String(existing.number),
"--repo",
repo,
"--body",
buildIssueBody(group, { installed: affected, direct, ghsas: merged }),
]);
if (edit.status !== 0) {
throw new Error(`gh issue edit failed: ${(edit.stderr ?? "").trim()}`);
}
const comment = gh([
"issue",
"comment",
String(existing.number),
"--repo",
repo,
"--body",
buildNewAdvisoryComment(group, added),
]);
if (comment.status !== 0) {
throw new Error(
`gh issue comment failed: ${(comment.stderr ?? "").trim()}`,
);
}
console.log(
`dependabot-alerts: added ${added.join(", ")} to #${existing.number}`,
);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View file

@ -0,0 +1,265 @@
// Unit tests for the pure halves of dependabot-alerts.mjs (#2233). The impure
// half (`main()`, which shells out to `gh`) is exercised only via
// `workflow_dispatch` in CI, per the same split `dependency-refresh.mjs` and
// `verify-skills.mjs` use. Run via `npm run test:scripts`.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildIssueBody,
buildIssueTitle,
buildMarker,
buildNewAdvisoryComment,
groupAlerts,
isDirectDependency,
lockfileVersions,
mergeGhsas,
parseMarker,
toSemverRange,
} from "./dependabot-alerts.mjs";
/** Shaped like a real `GET /repos/{o}/{r}/dependabot/alerts` entry. */
function alert({
ghsa,
pkg = "fast-uri",
manifest = "package-lock.json",
fixed = "3.1.6",
severity = "high",
range = ">= 3.0.0, < 3.1.6",
scope = "runtime",
cve = null,
state = "open",
}) {
return {
state,
html_url: `https://github.com/o/r/security/dependabot/${ghsa}`,
dependency: {
package: { name: pkg },
manifest_path: manifest,
scope,
},
security_advisory: {
ghsa_id: ghsa,
cve_id: cve,
severity,
summary: `${pkg} is bad`,
},
security_vulnerability: {
vulnerable_version_range: range,
first_patched_version: { identifier: fixed },
},
};
}
test("toSemverRange turns GitHub's comma-separated conjuncts into semver ANDs", () => {
assert.equal(toSemverRange(">= 3.1.3, < 3.1.6"), ">= 3.1.3 < 3.1.6");
assert.equal(toSemverRange("<= 4.28.6"), "<= 4.28.6");
assert.equal(toSemverRange(">= 2.2.5, < 6.16.0"), ">= 2.2.5 < 6.16.0");
});
test("toSemverRange tolerates stray whitespace and trailing commas", () => {
assert.equal(toSemverRange(" >= 1.0.0 , < 2.0.0 , "), ">= 1.0.0 < 2.0.0");
});
test("lockfileVersions finds hoisted and nested copies, deduped and sorted", () => {
const lock = {
packages: {
"": { dependencies: { zod: "^3.0.0" } },
"node_modules/fast-uri": { version: "3.1.5" },
"node_modules/ajv/node_modules/fast-uri": { version: "3.0.1" },
"node_modules/other/node_modules/fast-uri": { version: "3.1.5" },
"node_modules/fast-uri-lookalike": { version: "9.9.9" },
},
};
assert.deepEqual(lockfileVersions(lock, "fast-uri"), ["3.0.1", "3.1.5"]);
});
test("lockfileVersions returns [] when the package is absent", () => {
assert.deepEqual(lockfileVersions({ packages: {} }, "qs"), []);
assert.deepEqual(lockfileVersions({}, "qs"), []);
});
test("isDirectDependency reads the root manifest entry, not the tree", () => {
const lock = {
packages: {
"": { dependencies: { zod: "^4.0.0" }, devDependencies: { vitest: "1" } },
"node_modules/fast-uri": { version: "3.1.5" },
},
};
assert.equal(isDirectDependency(lock, "zod"), true);
assert.equal(isDirectDependency(lock, "vitest"), true);
assert.equal(isDirectDependency(lock, "fast-uri"), false);
});
test("groupAlerts collapses advisories into one entry per bump", () => {
const grouped = groupAlerts([
alert({ ghsa: "GHSA-5jgf", range: ">= 3.1.3, < 3.1.6" }),
alert({ ghsa: "GHSA-f65p" }),
alert({ ghsa: "GHSA-jqff" }),
alert({ ghsa: "GHSA-fph4", range: ">= 3.1.2, < 3.1.6" }),
alert({
ghsa: "GHSA-x5fp",
pkg: "qs",
fixed: "6.16.0",
severity: "medium",
range: ">= 6.14.2, <= 6.15.3",
}),
alert({
ghsa: "GHSA-73wf",
pkg: "browserslist",
manifest: "clients/tui/package-lock.json",
fixed: "4.28.7",
scope: "development",
range: "<= 4.28.6",
}),
]);
assert.deepEqual(
grouped.map((g) => [g.package, g.manifestPath, g.fixedIn, g.ghsas.length]),
[
["browserslist", "clients/tui/package-lock.json", "4.28.7", 1],
["fast-uri", "package-lock.json", "3.1.6", 4],
["qs", "package-lock.json", "6.16.0", 1],
],
);
// GHSAs are sorted within a group so the marker is stable across runs.
assert.deepEqual(grouped[1].ghsas, [
"GHSA-5jgf",
"GHSA-f65p",
"GHSA-fph4",
"GHSA-jqff",
]);
});
test("groupAlerts keeps the highest severity across a group", () => {
const [group] = groupAlerts([
alert({ ghsa: "GHSA-a", severity: "low" }),
alert({ ghsa: "GHSA-b", severity: "critical" }),
alert({ ghsa: "GHSA-c", severity: "medium" }),
]);
assert.equal(group.severity, "critical");
});
test("groupAlerts splits a package whose advisories need different bumps", () => {
const grouped = groupAlerts([
alert({ ghsa: "GHSA-a", fixed: "3.1.6" }),
alert({ ghsa: "GHSA-b", fixed: "4.0.0" }),
]);
assert.equal(grouped.length, 2);
assert.deepEqual(
grouped.map((g) => g.fixedIn),
["3.1.6", "4.0.0"],
);
});
test("groupAlerts drops closed alerts and ones with no patched version", () => {
const unpatched = alert({ ghsa: "GHSA-x" });
unpatched.security_vulnerability.first_patched_version = null;
assert.deepEqual(
groupAlerts([alert({ ghsa: "GHSA-y", state: "fixed" }), unpatched]),
[],
);
});
test("buildMarker and parseMarker round-trip, sorting the GHSA list", () => {
const marker = buildMarker({
package: "fast-uri",
manifestPath: "package-lock.json",
ghsas: ["GHSA-b", "GHSA-a"],
});
assert.equal(
marker,
"<!-- dependabot-alerts: pkg=fast-uri; manifest=package-lock.json; ghsas=GHSA-a,GHSA-b -->",
);
assert.deepEqual(parseMarker(`${marker}\nbody text`), {
package: "fast-uri",
manifestPath: "package-lock.json",
ghsas: ["GHSA-a", "GHSA-b"],
});
});
test("parseMarker returns null for an unmarked or absent body", () => {
assert.equal(parseMarker(undefined), null);
assert.equal(parseMarker("just an issue someone wrote"), null);
// The marker is the FIRST line or it is not the idempotency key.
assert.equal(
parseMarker(
"preamble\n<!-- dependabot-alerts: pkg=x; manifest=y; ghsas=GHSA-a -->",
),
null,
);
});
test("mergeGhsas reports only the advisories the issue does not already name", () => {
assert.deepEqual(mergeGhsas(["GHSA-a", "GHSA-b"], ["GHSA-b", "GHSA-c"]), {
merged: ["GHSA-a", "GHSA-b", "GHSA-c"],
added: ["GHSA-c"],
});
});
test("mergeGhsas reports nothing added when the issue already covers them", () => {
assert.deepEqual(mergeGhsas(["GHSA-a", "GHSA-b"], ["GHSA-a"]), {
merged: ["GHSA-a", "GHSA-b"],
added: [],
});
});
test("buildIssueTitle names the bump and pluralizes the advisory count", () => {
const [many] = groupAlerts([
alert({ ghsa: "GHSA-a" }),
alert({ ghsa: "GHSA-b" }),
]);
assert.equal(
buildIssueTitle(many),
"chore(deps): bump `fast-uri` to `3.1.6` in `package-lock.json` (2 advisories)",
);
const [one] = groupAlerts([alert({ ghsa: "GHSA-a" })]);
assert.equal(
buildIssueTitle(one),
"chore(deps): bump `fast-uri` to `3.1.6` in `package-lock.json` (1 advisory)",
);
});
test("buildIssueBody leads with the marker and asks for an overrides pin when transitive", () => {
const [group] = groupAlerts([
alert({ ghsa: "GHSA-a", cve: "CVE-2026-1" }),
alert({ ghsa: "GHSA-b" }),
]);
const body = buildIssueBody(group, { installed: ["3.1.5"], direct: false });
assert.ok(body.startsWith(buildMarker(group)));
assert.match(body, /\| Installed on `v2\/main` \| `3\.1\.5` \|/);
assert.match(body, /\| Fixed in \| `3\.1\.6` \|/);
assert.match(body, /GHSA-a/);
assert.match(body, /CVE-2026-1/);
assert.match(body, /`overrides`/);
assert.doesNotMatch(body, /bump its declared range/);
});
test("buildIssueBody asks for a plain range bump when the dependency is direct", () => {
const [group] = groupAlerts([alert({ ghsa: "GHSA-a" })]);
const body = buildIssueBody(group, { installed: ["3.1.5"], direct: true });
assert.match(body, /\*\*direct\*\* dependency of `package\.json`/);
assert.match(body, /bump its declared range to `>=3\.1\.6`/);
});
test("buildIssueBody honors an overridden GHSA list when rewriting an issue", () => {
const [group] = groupAlerts([alert({ ghsa: "GHSA-a" })]);
const body = buildIssueBody(group, {
installed: ["3.1.5"],
direct: false,
ghsas: ["GHSA-a", "GHSA-old"],
});
assert.deepEqual(parseMarker(body).ghsas, ["GHSA-a", "GHSA-old"]);
});
test("buildNewAdvisoryComment lists only the newly-seen advisories", () => {
const [group] = groupAlerts([
alert({ ghsa: "GHSA-a" }),
alert({ ghsa: "GHSA-b" }),
]);
const comment = buildNewAdvisoryComment(group, ["GHSA-b"]);
assert.match(comment, /1 new Dependabot advisory/);
assert.match(comment, /GHSA-b/);
assert.doesNotMatch(comment, /GHSA-a/);
});

View file

@ -3,12 +3,13 @@
//
// A Dependabot version-update PR carries no issue and no board card — the same
// carve-out from "every PR references an issue" that the security-update flow
// had (that half is handled separately by the alert-driven pipeline, also
// #2229). #2235 removed `.github/dependabot.yml` outright, so Dependabot opens
// had (that half is `dependabot-alerts.mjs`, #2233, which turned those PRs off
// as well and files an issue per bump from the alerts they leave behind — so
// between them Dependabot opens no PRs against this repo at all, though its
// security ALERTS stay on, since that sweep is what consumes them).
// #2235 removed `.github/dependabot.yml` outright, so Dependabot opens
// no version-update PRs against this repo at all and this script is what
// replaced them. Dependabot SECURITY updates are a separate mechanism, enabled
// in repo settings rather than in that file, and are deliberately still on —
// so this replaces the version-update half only, not Dependabot wholesale.
// replaced them.
//
// Once a month it runs `npm outdated` across the root install and every client
// under `clients/*` (each has its own package.json + lockfile — v2 is not a