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>
265 lines
8.4 KiB
JavaScript
265 lines
8.4 KiB
JavaScript
// 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/);
|
|
});
|