chore(deps): address Copilot review round 6 on #2243
Both were suppressed "previously missed" findings, and both were real. - isPermissionDenied matched on status alone, so a rate limit (also a 403) and a bad token (401) were waved through as "missing scope" — contradicting the comment right above it, which said those must stop the sweep. It now excludes rate-limit wording and drops 401. - The no-op path asked "were advisories ADDED?" when the question is "did the issue CHANGE?". An issue filed for A+B whose branch moved so only B applies has nothing added, yet its table, severity, affected copies and remediation are all stale. The rendered title and body are now compared against the issue, and a comment stays reserved for genuinely new advisories. - The title counts the advisories that APPLY, matching the body, so it tracks shrinking exposure as well as growth. The marker's GHSA list stays monotonic — its job is to remember what has been announced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
a17eace0bb
commit
ff339dc7c3
2 changed files with 145 additions and 31 deletions
|
|
@ -375,13 +375,16 @@ export function narrowToApplicable(group, entries) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ReturnType<typeof groupAlerts>[number]} group
|
* The title counts the advisories that APPLY, which is what the body shows —
|
||||||
* @param {number} [count] advisories the issue covers, when that is more than
|
* so it tracks an issue that grows a new advisory and one whose exposure
|
||||||
* this run saw — an issue grown by a later advisory keeps one title.
|
* 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<typeof groupAlerts>[number]} group narrowed to what applies
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function buildIssueTitle(group, count = group.advisories.length) {
|
export function buildIssueTitle(group) {
|
||||||
const n = count;
|
const n = group.advisories.length;
|
||||||
return `chore(deps): bump \`${group.package}\` to \`${group.fixedIn}\` in \`${group.manifestPath}\` (${n} ${n === 1 ? "advisory" : "advisories"})`;
|
return `chore(deps): bump \`${group.package}\` to \`${group.fixedIn}\` in \`${group.manifestPath}\` (${n} ${n === 1 ? "advisory" : "advisories"})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,19 +563,27 @@ function ghJson(spawn, args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is this failed lookup the "the token may not read this" answer, rather than a
|
* Is this failed lookup the "this token may not read that" answer, rather than
|
||||||
* real API failure?
|
* a real API failure?
|
||||||
*
|
*
|
||||||
* The distinction is what keeps the security-PR guard honest: a bad token, a
|
* 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
|
* rate limit or a transient 5xx must NOT be waved through as "unverified", or
|
||||||
* the sweep exits green having silently skipped its own precondition
|
* the sweep exits green having silently skipped its own precondition.
|
||||||
* (Copilot). Only an authorization-shaped status is tolerated.
|
*
|
||||||
|
* ⚠️ 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
|
* @param {string} stderr stderr from a non-zero `gh api` call
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isPermissionDenied(stderr) {
|
export function isPermissionDenied(stderr) {
|
||||||
return /HTTP (401|403|404)\b/.test(stderr);
|
if (/rate limit/i.test(stderr)) return false;
|
||||||
|
return /HTTP (403|404)\b/.test(stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -672,7 +683,11 @@ function openDependabotIssues(repo, spawn) {
|
||||||
return (pages ?? [])
|
return (pages ?? [])
|
||||||
.flat()
|
.flat()
|
||||||
.filter((issue) => !issue.pull_request)
|
.filter((issue) => !issue.pull_request)
|
||||||
.map((issue) => ({ number: issue.number, body: issue.body }));
|
.map((issue) => ({
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
body: issue.body,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -945,9 +960,26 @@ export function main(repo = process.env.GITHUB_REPOSITORY, spawn = spawnSync) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { merged, added } = mergeGhsas(existing.marker.ghsas, group.ghsas);
|
const { merged, added } = mergeGhsas(existing.marker.ghsas, group.ghsas);
|
||||||
if (added.length === 0) {
|
const title = buildIssueTitle(group);
|
||||||
|
const body = buildIssueBody(group, {
|
||||||
|
affected,
|
||||||
|
declared,
|
||||||
|
ghsas: merged,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ⚠️ "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(
|
console.log(
|
||||||
`dependabot-alerts: #${existing.number} already covers ${group.package} — no-op`,
|
`dependabot-alerts: #${existing.number} is up to date for ${group.package} — no-op`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -976,8 +1008,6 @@ export function main(repo = process.env.GITHUB_REPOSITORY, spawn = spawnSync) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The title carries the advisory count, so it goes stale the moment the
|
|
||||||
// issue covers one more than it was filed with (Copilot).
|
|
||||||
const edit = gh(spawn, [
|
const edit = gh(spawn, [
|
||||||
"issue",
|
"issue",
|
||||||
"edit",
|
"edit",
|
||||||
|
|
@ -985,15 +1015,17 @@ export function main(repo = process.env.GITHUB_REPOSITORY, spawn = spawnSync) {
|
||||||
"--repo",
|
"--repo",
|
||||||
repo,
|
repo,
|
||||||
"--title",
|
"--title",
|
||||||
buildIssueTitle(group, merged.length),
|
title,
|
||||||
"--body",
|
"--body",
|
||||||
buildIssueBody(group, { affected, declared, ghsas: merged }),
|
body,
|
||||||
]);
|
]);
|
||||||
if (edit.status !== 0) {
|
if (edit.status !== 0) {
|
||||||
throw new Error(`gh issue edit failed: ${(edit.stderr ?? "").trim()}`);
|
throw new Error(`gh issue edit failed: ${(edit.stderr ?? "").trim()}`);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`dependabot-alerts: added ${added.join(", ")} to #${existing.number}`,
|
added.length > 0
|
||||||
|
? `dependabot-alerts: added ${added.join(", ")} to #${existing.number}`
|
||||||
|
: `dependabot-alerts: refreshed #${existing.number} for ${group.package}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,18 +212,35 @@ test("buildCommentMarker and parseCommentMarker round-trip", () => {
|
||||||
assert.equal(parseCommentMarker("an ordinary comment"), null);
|
assert.equal(parseCommentMarker("an ordinary comment"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isPermissionDenied separates an authorization failure from a real one", () => {
|
test("isPermissionDenied tolerates a scope refusal", () => {
|
||||||
|
// The two ways GitHub says "this token may not read that": an explicit 403,
|
||||||
|
// and a 404 hiding a resource the token cannot see.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isPermissionDenied("gh: HTTP 403: Resource not accessible"),
|
isPermissionDenied("gh: HTTP 403: Resource not accessible by integration"),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(isPermissionDenied("gh: HTTP 401: Bad credentials"), true);
|
|
||||||
assert.equal(isPermissionDenied("gh: HTTP 404: Not Found"), true);
|
assert.equal(isPermissionDenied("gh: HTTP 404: Not Found"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isPermissionDenied treats a bad token or a rate limit as a real failure", () => {
|
||||||
|
// ⚠️ A rate limit is also a 403, so status alone cannot decide this — waving
|
||||||
|
// it through would exit green having skipped the sweep's own precondition.
|
||||||
|
assert.equal(
|
||||||
|
isPermissionDenied("gh: API rate limit exceeded (HTTP 403)"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isPermissionDenied(
|
||||||
|
"gh: HTTP 403: You have exceeded a secondary rate limit",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// 401 is a bad or expired token, never a scope question.
|
||||||
|
assert.equal(isPermissionDenied("gh: HTTP 401: Bad credentials"), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isPermissionDenied("gh: HTTP 500: Internal Server Error"),
|
isPermissionDenied("gh: HTTP 500: Internal Server Error"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(isPermissionDenied("gh: API rate limit exceeded"), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseMarker returns null for an unmarked or absent body", () => {
|
test("parseMarker returns null for an unmarked or absent body", () => {
|
||||||
|
|
@ -637,6 +654,16 @@ function inTempRepo(files, body) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The probe `main()` derives from `lockWith`: one hoisted, undeclared copy.
|
||||||
|
* A fixture issue built from this renders byte-identically to what `main()`
|
||||||
|
* would produce, which is what makes the no-op path assertable.
|
||||||
|
*/
|
||||||
|
const asInstalled = (version = "3.1.5") => ({
|
||||||
|
affected: [{ path: "node_modules/fast-uri", version, hoisted: true }],
|
||||||
|
declared: false,
|
||||||
|
});
|
||||||
|
|
||||||
/** A lockfile holding one transitive copy of `pkg` at `version`. */
|
/** A lockfile holding one transitive copy of `pkg` at `version`. */
|
||||||
const lockWith = (pkg, version) => ({
|
const lockWith = (pkg, version) => ({
|
||||||
lockfileVersion: 3,
|
lockfileVersion: 3,
|
||||||
|
|
@ -739,7 +766,8 @@ test("main is a complete no-op on a second run", () => {
|
||||||
issues: [
|
issues: [
|
||||||
{
|
{
|
||||||
number: 41,
|
number: 41,
|
||||||
body: buildIssueBody(group, nested()),
|
title: buildIssueTitle(group),
|
||||||
|
body: buildIssueBody(group, asInstalled()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -750,7 +778,7 @@ test("main is a complete no-op on a second run", () => {
|
||||||
assert.equal(ghCall(spawn, "create"), undefined);
|
assert.equal(ghCall(spawn, "create"), undefined);
|
||||||
assert.equal(ghCall(spawn, "comment"), undefined);
|
assert.equal(ghCall(spawn, "comment"), undefined);
|
||||||
assert.equal(ghCall(spawn, "edit"), undefined);
|
assert.equal(ghCall(spawn, "edit"), undefined);
|
||||||
assert.ok(log.some((l) => l.includes("#41 already covers")));
|
assert.ok(log.some((l) => l.includes("#41 is up to date")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("main will not update an issue whose bump differs, even for the same package", () => {
|
test("main will not update an issue whose bump differs, even for the same package", () => {
|
||||||
|
|
@ -761,7 +789,8 @@ test("main will not update an issue whose bump differs, even for the same packag
|
||||||
issues: [
|
issues: [
|
||||||
{
|
{
|
||||||
number: 41,
|
number: 41,
|
||||||
body: buildIssueBody(old, nested()),
|
title: buildIssueTitle(old),
|
||||||
|
body: buildIssueBody(old, asInstalled()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -779,7 +808,8 @@ test("main comments a new advisory BEFORE rewriting the marker", () => {
|
||||||
issues: [
|
issues: [
|
||||||
{
|
{
|
||||||
number: 41,
|
number: 41,
|
||||||
body: buildIssueBody(old, nested()),
|
title: buildIssueTitle(old),
|
||||||
|
body: buildIssueBody(old, asInstalled()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -814,7 +844,8 @@ test("main does not repeat a comment it already posted", () => {
|
||||||
issues: [
|
issues: [
|
||||||
{
|
{
|
||||||
number: 41,
|
number: 41,
|
||||||
body: buildIssueBody(old, nested()),
|
title: buildIssueTitle(old),
|
||||||
|
body: buildIssueBody(old, asInstalled()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
comments: [{ body: `${buildCommentMarker(["GHSA-b"])}\nsaid already` }],
|
comments: [{ body: `${buildCommentMarker(["GHSA-b"])}\nsaid already` }],
|
||||||
|
|
@ -843,7 +874,8 @@ test("main announces only the advisories no comment has claimed yet", () => {
|
||||||
issues: [
|
issues: [
|
||||||
{
|
{
|
||||||
number: 41,
|
number: 41,
|
||||||
body: buildIssueBody(old, nested()),
|
title: buildIssueTitle(old),
|
||||||
|
body: buildIssueBody(old, asInstalled()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
comments: [{ body: `${buildCommentMarker(["GHSA-b"])}\nannounced b` }],
|
comments: [{ body: `${buildCommentMarker(["GHSA-b"])}\nannounced b` }],
|
||||||
|
|
@ -866,7 +898,8 @@ test("main refreshes the title when an issue grows another advisory", () => {
|
||||||
issues: [
|
issues: [
|
||||||
{
|
{
|
||||||
number: 41,
|
number: 41,
|
||||||
body: buildIssueBody(old, nested()),
|
title: buildIssueTitle(old),
|
||||||
|
body: buildIssueBody(old, asInstalled()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -934,6 +967,49 @@ test("main leaves an unmilestoned issue off the board for triage", () => {
|
||||||
assert.ok(log.some((l) => l.includes("unmilestoned and unboarded")));
|
assert.ok(log.some((l) => l.includes("unmilestoned and unboarded")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("main refreshes an issue whose exposure shrank, without commenting", () => {
|
||||||
|
// Filed when both advisories applied; `v2/main` has since moved to 3.1.0,
|
||||||
|
// which is out of range of the narrow one. Nothing is NEW, so `added` is
|
||||||
|
// empty — but the body still claims an advisory that no longer applies.
|
||||||
|
const [filed] = groupAlerts([
|
||||||
|
alert({ ghsa: "GHSA-narrow", range: ">= 3.1.3, < 3.1.6" }),
|
||||||
|
alert({ ghsa: "GHSA-wide", range: ">= 3.0.0, < 3.1.6" }),
|
||||||
|
]);
|
||||||
|
const spawn = fakeSpawn({
|
||||||
|
alertPages: [
|
||||||
|
[
|
||||||
|
alert({ ghsa: "GHSA-narrow", range: ">= 3.1.3, < 3.1.6" }),
|
||||||
|
alert({ ghsa: "GHSA-wide", range: ">= 3.0.0, < 3.1.6" }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
number: 41,
|
||||||
|
title: buildIssueTitle(filed),
|
||||||
|
body: buildIssueBody(filed, asInstalled()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const log = inTempRepo(
|
||||||
|
{ "package-lock.json": lockWith("fast-uri", "3.1.0") },
|
||||||
|
() => withoutProjectToken(() => captureLog(() => main("o/r", spawn))),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A comment is for genuinely new advisories, and there are none.
|
||||||
|
assert.equal(ghCall(spawn, "comment"), undefined);
|
||||||
|
|
||||||
|
const edit = ghCall(spawn, "edit");
|
||||||
|
assert.ok(edit, "the stale body is rewritten");
|
||||||
|
const body = edit.args[edit.args.indexOf("--body") + 1];
|
||||||
|
assert.ok(!body.includes("| [GHSA-narrow]"), "no longer in the table");
|
||||||
|
assert.match(body, /`3\.1\.0`/, "the affected version is refreshed");
|
||||||
|
assert.match(edit.args[edit.args.indexOf("--title") + 1], /\(1 advisory\)$/);
|
||||||
|
// The marker stays monotonic: it records what has been announced, so the
|
||||||
|
// dropped advisory cannot be re-announced later.
|
||||||
|
assert.deepEqual(parseMarker(body).ghsas, ["GHSA-narrow", "GHSA-wide"]);
|
||||||
|
assert.ok(log.some((l) => l.includes("refreshed #41")));
|
||||||
|
});
|
||||||
|
|
||||||
test("main reads every page of open dependabot issues", () => {
|
test("main reads every page of open dependabot issues", () => {
|
||||||
// The second page holds the matching marker. Truncating the lookup would
|
// The second page holds the matching marker. Truncating the lookup would
|
||||||
// file a duplicate issue rather than recognising this one.
|
// file a duplicate issue rather than recognising this one.
|
||||||
|
|
@ -942,7 +1018,13 @@ test("main reads every page of open dependabot issues", () => {
|
||||||
alertPages: [[alert({ ghsa: "GHSA-a" })]],
|
alertPages: [[alert({ ghsa: "GHSA-a" })]],
|
||||||
issues: [
|
issues: [
|
||||||
[{ number: 1, body: "an unrelated dependabot issue" }],
|
[{ number: 1, body: "an unrelated dependabot issue" }],
|
||||||
[{ number: 41, body: buildIssueBody(group, nested()) }],
|
[
|
||||||
|
{
|
||||||
|
number: 41,
|
||||||
|
title: buildIssueTitle(group),
|
||||||
|
body: buildIssueBody(group, asInstalled()),
|
||||||
|
},
|
||||||
|
],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const log = inTempRepo(
|
const log = inTempRepo(
|
||||||
|
|
@ -950,7 +1032,7 @@ test("main reads every page of open dependabot issues", () => {
|
||||||
() => withoutProjectToken(() => captureLog(() => main("o/r", spawn))),
|
() => withoutProjectToken(() => captureLog(() => main("o/r", spawn))),
|
||||||
);
|
);
|
||||||
assert.equal(ghCall(spawn, "create"), undefined);
|
assert.equal(ghCall(spawn, "create"), undefined);
|
||||||
assert.ok(log.some((l) => l.includes("#41 already covers")));
|
assert.ok(log.some((l) => l.includes("#41 is up to date")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("main drops a pull request returned by the issues endpoint", () => {
|
test("main drops a pull request returned by the issues endpoint", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue