chore(deps): sweep Dependabot alerts into board-tracked issues - #2243
chore(deps): sweep Dependabot alerts into board-tracked issues#2243cliffhall wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Alert identity, pagination, board-state, and failure-handling defects can produce incorrect or incomplete tracking.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds scheduled, issue-driven dependency security tracking to replace Dependabot-generated PRs.
Changes:
- Adds a daily Dependabot-alert sweep with lockfile validation and board integration.
- Extends monthly dependency monitoring to npm packages and GitHub Actions.
- Adds tests, documentation, release guidance, and the
semverdependency.
File summaries
| File | Description |
|---|---|
scripts/dependency-refresh.test.mjs |
Tests monthly sweep behavior. |
scripts/dependency-refresh.mjs |
Implements monthly dependency and action checks. |
scripts/dependabot-alerts.test.mjs |
Tests alert grouping and issue formatting. |
scripts/dependabot-alerts.mjs |
Implements the security-alert sweep. |
package.json |
Declares semver. |
package-lock.json |
Locks semver. |
AGENTS.md |
Documents issue-driven dependency updates. |
.github/workflows/dependency-refresh.yml |
Schedules the monthly sweep. |
.github/workflows/dependabot-alerts.yml |
Schedules the daily alert sweep. |
.github/dependabot.yml |
Removes Dependabot update configuration. |
.claude/skills/release/SKILL.md |
Adds release-time audit guidance. |
Review details
- Files reviewed: 10/11 changed files
- Comments generated: 14
- Review effort level: Balanced
- `--slurp` the paginated alert feed; a bare `--paginate` emits one JSON array per page and `JSON.parse` rejects it past 100 open alerts. - Put `fixedIn` in the marker and in the existing-issue lookup, so a second bump of one package cannot merge into the first one's issue. - Only an authorization-shaped failure of the `automated-security-fixes` read becomes UNVERIFIED; a rate limit or 5xx now throws. - Distinguish "card never added" (benign, triage picks it up) from "card added, field not set" — the latter finishes every group, then fails the run. - Don't board an unmilestoned issue at Todo; `Incoming` <=> no milestone. - Comment before rewriting the marker, and give the comment its own marker, so a failed comment cannot be skipped forever. - Ask a direct dependency's range to be raised, not widened to `>=`. - Test `main()` through an injected spawn, as the sibling sweep does. - Correct the docs that named the removed release-time `npm audit fix`, the ones promising an unconditional guard, and the sibling sweep's issue body claiming security updates remain enabled. 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>
Copilot review round 1 — all 14 addressed (13 implemented, 1 declined)Mirrored at PR level because inline replies go hidden once the fix is pushed. Correctness
Content and docs
Declined#9 — Verification
|
There was a problem hiding this comment.
🟡 Changes recommended
Advisory retries can duplicate comments, updated issues retain stale titles, and the source contains text-tool-breaking NUL bytes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 10/11 changed files
- Comments generated: 4
- Review effort level: Balanced
- Remove the literal NUL bytes that were separating the grouping key's fields; they classified the whole source file as binary, so repo searches skipped it. The key is a JSON array now, with no separator left to justify. - De-duplicate advisory comments per GHSA rather than per whole set. A run that comments and then fails before the marker edit left the next run computing a different set, which matched nothing and announced the same advisory twice. - Refresh the issue title on edit; it carries the advisory count, so it went stale as soon as an issue grew past what it was filed with. - Correct the test file's header, which still claimed main() was left to workflow_dispatch. 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>
Copilot review round 2 — all 4 addressed
The NUL one deserves a note: it was real, and it had already bitten me silently — The comment finding is the sharper version of the ordering fix from round 1: reordering made the failure mode "a repeat instead of a permanent skip", and set-comparison then failed to prevent that repeat in exactly the interleaving where it mattered. Per-advisory is the right granularity — it is what a comment actually claims. Verification
|
There was a problem hiding this comment.
🟡 Changes recommended
The workflow uses an invalid permission key, and mixed direct/transitive package copies can produce incorrect remediation guidance.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/dependabot-alerts.mjs:761
- This classifies the package as direct merely because its name is declared at the lockfile root, rather than checking which installed copy is vulnerable. For a valid lock containing a safe direct
pkg@4and vulnerable nestedpkg@3,affectedcontains only v3 butdirectis still true, so the generated issue recommends bumping the already-safe direct range and omits the override needed for the nested copy. Preserve lockfile paths/relationships and derive the fix strategy from the affected entries, including the mixed direct/transitive case.
- Files reviewed: 10/11 changed files
- Comments generated: 1
- Review effort level: Balanced
Derive the remediation from WHICH copies are vulnerable, not from whether the package is declared. A manifest can declare a safe `pkg@4` while a dependency drags a vulnerable `pkg@3` into a nested folder; the old boolean called that "direct" and asked for a range bump that would have changed nothing, omitting the override the nested copy needs. - `lockfileEntries` keeps each copy's tree path and whether it is the hoisted one; `lockfileVersions` is now derived from it. - `remediation(affected, declared)` returns both flags, so the issue can ask for a range bump, an overrides pin, or explicitly both. The body lists the vulnerable copies and their paths. - An undeclared hoisted copy counts as transitive: it got there the same way any other transitive copy did, and no declared range reaches it. (Found by a test written for this change.) The `vulnerability-alerts: read` finding is declined — verified against a real runner, which reports `VulnerabilityAlerts: read` and reads the alerts successfully. See the PR comment for the log. 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>
Copilot review round 3 — 1 implemented, 1 declined with evidenceImplemented — the suppressed finding on
|
| vulnerable copies | issue asks for |
|---|---|
| the declared (hoisted) one | raise the declared range |
| nested ones only | an overrides pin |
| both | both, stated as "neither alone clears every vulnerable copy" |
The body now lists the vulnerable copies with their paths, so the maintainer can see why they are being asked for a given edit.
One thing the change surfaced that neither of us had named: an undeclared hoisted copy. It got there transitively like any other, and no declared range reaches it — but my first cut of remediation returned {direct: false, transitive: false} for it, i.e. an issue with no fix at all. A test written for this change caught it before it shipped; transitive is now "every affected copy that is not the declared one".
Declined — vulnerability-alerts is not a valid permissions key
It is valid, and this was worth checking rather than taking on either side's word: the workflow-syntax docs list it, several older community threads say the opposite, and getting it wrong either way breaks the workflow.
So I pushed a throwaway branch with exactly this permissions: block and a step calling the alerts API with GITHUB_TOKEN. The run started — an invalid permission key is a startup failure, not a warning — and the runner reported:
##[group]GITHUB_TOKEN Permissions
Contents: read
Metadata: read
VulnerabilityAlerts: read
##[endgroup]
with the step returning 1 and READ_OK. Probe branch deleted.
security-events is the code scanning permission; using it here would be both wrong and a wider grant than the sweep needs. The older sources are not wrong about their own era — GITHUB_TOKEN could not read Dependabot alerts for years, and vulnerability-alerts is the distinct permission GitHub said it was working towards.
Verification
scripts/dependabot-alerts.test.mjs: 35 → 39 tests. New:remediation reads the vulnerable copies, not the declaration(all three cases plus the undeclared-hoisted one),buildIssueBody asks for BOTH edits when declared and nested copies are vulnerable,buildIssueBody asks only for an override when the declared copy is safe, andlockfileEntries keeps each copy's path and marks the hoisted one.- Live dry run against the real alert feed:
fast-uriresolves to one hoisted-but-undeclared copy at3.1.5, correctly classified{direct: false, transitive: true}, and the rendered Fix section asks for theoverridespin and names the copy. npm run local:gategreen.
There was a problem hiding this comment.
🟡 Changes recommended
Per-advisory filtering and issue pagination are incomplete, and disjunctive ranges can corrupt generated tables.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/dependabot-alerts.mjs:597
- This cap breaks idempotency at the same scale the alert fetch is designed to support. If a run creates 101 distinct bump issues, the next run can retrieve only 100 of them and will file a duplicate for every omitted marker. Fetch all open Dependabot issues (for example with paginated
gh api) rather than truncating this lookup.
- Files reviewed: 10/11 changed files
- Comments generated: 2
- Review effort level: Balanced
- Narrow each group to the advisories the installed copies are actually in range of. Two advisories can share a package, manifest and patched version while having different vulnerable ranges, so a group-level "does any match?" left the marker, title, severity and table all claiming an advisory that does not apply on this branch. - Escape every free-form Markdown cell, the vulnerable range included. A semver range may contain `||`, which is also the column separator. - Paginate the open-issue lookup instead of capping it at 100. The marker lookup is what makes the sweep idempotent, so a truncated list would file a duplicate for every issue it could not see — at the same scale the alert fetch is built to handle. Pull requests, which the issues endpoint also returns, are dropped. 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>
Copilot review round 4 — all 3 addressed
The grouping one is the sharpest finding of the four roundsIt undermined the grouping contract from the inside.
The pagination one is about the asymmetry, not the scale101 open bump issues is implausible today — but the marker lookup is what makes this sweep idempotent, and a truncated list files a duplicate for every issue it cannot see. It would start doing that at precisely the scale the Verification
|
There was a problem hiding this comment.
🔵 Needs a closer look
Concurrent runs can create duplicates, and undated milestones can be selected instead of the current release milestone.
Review details
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
.github/workflows/dependabot-alerts.yml:56
- The marker check is a non-atomic read-before-create/update, but Actions permits the scheduled and manually dispatched runs to overlap. Two runs can both see no issue and file duplicate issues, or both post the same “new advisory” comment. Serialize this workflow with a fixed concurrency group and
cancel-in-progress: falseso the queued run rechecks state after the first completes.
.github/workflows/dependency-refresh.yml:32 - The issue marker makes sequential runs idempotent, but the lookup-and-create sequence is not atomic and scheduled/manual runs may overlap. Both executions can observe no open tracking issue and create duplicates. Add a fixed concurrency group with cancellation disabled so later runs wait and then observe the first run’s issue.
scripts/dependabot-alerts.mjs:690 - An undated open milestone sorts before every dated milestone because jq orders
nullbefore strings, so this can select an undated bucket instead of the current release milestone. Exclude milestones withoutdue_on(or sort nulls last) before taking[0]; the issue-creation procedure defines “current” as the open milestone with the nearest due date.
scripts/dependency-refresh.mjs:388 - An undated open milestone sorts before every dated milestone because jq orders
nullbefore strings, so the monthly issue can be assigned to an undated bucket rather than the current release milestone. Filter out null due dates (or sort them last) before selecting[0]; the issue-creation procedure defines “current” as the open milestone with the nearest due date.
- Files reviewed: 10/11 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Both findings were "previously missed" suppressed ones; the two against scripts/dependency-refresh.mjs and its workflow are relayed to #2239. - Serialize the sweep with a fixed concurrency group and cancel-in-progress: false. The marker check is a read-before-write and a workflow_dispatch can land on top of the scheduled run, so two runs could both see no open issue and both file one — the duplicate the whole idempotency design exists to prevent. The queued run must wait and re-read, never be cancelled. - Pick the milestone in JS, not in jq. jq sorts null before every string, so sort_by(.due_on) | .[0] returns an UNDATED open milestone in preference to every dated one. An undated bucket has no due date and so cannot be the nearest; pickMilestone drops it, and files the issue unmilestoned if nothing dated is open. 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>
Copilot review round 5 — 0 new comments, 4 suppressed findings triagedRound 5 generated no new comments. It surfaced four "previously missed" findings against unchanged code; two apply here and are fixed, two are Fixed here1. The sweep can race itself. Correct, and it defeats the point of the whole design. The marker check is a read-before-write, and nothing stops a concurrency:
group: dependabot-alert-sweep
cancel-in-progress: false
2. No live impact today — both open milestones are dated — but a Rather than patching the jq, the selection moved into a pure Relayed to #2239The same two findings against Verification
|
Copilot review round 11 — all 4 addressedA non-npm alert would have crashed the sweepThe most serious defect the whole review found, and the only one that takes the job down rather than degrading it. Three changes, defence in depth:
I deliberately did not route non-npm alerts into a generic issue. Filing one usefully means knowing how the fix is expressed — a base-image bump is not an Superseded ≠ fixedA vanished grouping key is not a closed advisory: GitHub can revise Two accuracy fixes
Verification
On the review loop itselfEleven rounds, and the last three surfaced a production crash, a security-issue mis-statement and a remediation that npm would reject — so the rounds after "0 new comments" were not diminishing returns. My earlier claim that the reviewer had gone quiet was my own pagination bug, not the reviewer stopping. |
There was a problem hiding this comment.
🔵 Needs a closer look
Reconciliation can incorrectly mark an unpatched but still-open advisory as fixed or dismissed.
Review details
Suppressed comments (1)
scripts/dependabot-alerts.mjs:1040
openGhsasis derived fromgroups, butgroupAlerts()deliberately drops an open alert whosefirst_patched_versionis null. If GitHub removes that field for an advisory already tracked, its key and GHSA both disappear here, so reconciliation rewrites the existing issue as “fixed or dismissed” even though the alert remains open. Retain the raw alert feed and build this set from every open alert'ssecurity_advisory.ghsa_id; grouping can still omit unpatched advisories from filing. Add a regression for an existing issue whose alert remains open but loses its patched version.
/** Every GHSA still open, in ANY group — the check a vanished key needs. */
const openGhsas = new Set(groups.flatMap((g) => g.ghsas));
- Files reviewed: 8/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Build the open-GHSA set from the raw alert feed, not from the grouped alerts. groupAlerts deliberately drops an alert with no first_patched_version, so an advisory that stays OPEN but loses its patched version vanished from both the keys and that set — and reconciliation would have called it "fixed or dismissed". Same wrong direction as the superseded case fixed last round, reached a different way: a still-open advisory must never stand itself down. 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>
Copilot review round 12 — one finding, and it was a hole in last round's fixZero new inline comments; one suppressed finding, and it was correct.
The invariant worth namingThis is the second time the same wrong direction has been reached by a different route — round 11 was a revised
It is stated in the comment on Verification
|
There was a problem hiding this comment.
🟡 Changes recommended
Reconciliation can falsely clear live alerts and repeatedly edit cleared issues on every daily run.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
scripts/dependabot-alerts.mjs:609
todayis part of the rendered cleared body, but cleared issues are intentionally left open. On tomorrow's scheduled run the regenerated body therefore differs solely by date, so every cleared issue is edited again each day; the no-reclear tests miss this by passing the same date twice. Preserve the original clear date or remove the volatile date from the rendered/idempotency state.
scripts/dependabot-alerts.mjs:1251
- An open alert that loses
first_patched_versionis dropped bygroupAlerts, so it has no replacement group or issue, but this branch still calls it superseded, claims it has its own issue, and passes it tobuildClearedBody, which says the exposure is gone. Distinguish GHSAs represented by another actionable group from still-open unpatched alerts; the latter must not be cleared.
const stillOpen = issue.marker.ghsas.filter((g) => openGhsas.has(g));
const reason =
stillOpen.length > 0
? `this bump was superseded — ${stillOpen.map((g) => `\`${g}\``).join(", ")} ${stillOpen.length === 1 ? "is" : "are"} still open under a different patched version, and ${stillOpen.length === 1 ? "has" : "have"} their own issue`
: "every alert it tracked has been fixed or dismissed";
- Files reviewed: 8/9 changed files
- Comments generated: 1
- Review effort level: Balanced
| } catch { | ||
| // Belt and braces behind the ecosystem filter: whatever this is, it is not | ||
| // an npm lockfile, and one unparseable manifest must not abort the sweep. | ||
| console.log( | ||
| `dependabot-alerts: ${manifestPath} is not JSON — skipping (not an npm lockfile)`, |
There was a problem hiding this comment.
Fixed. You are right, and this is the third route to the same failure — I introduced this null myself last round while hardening against a non-npm manifest, and collapsed two states that mean opposite things.
readManifest now returns {lock}, {absent: true} or {unparseable: true}, and the caller treats them differently:
- absent — real evidence the exposure went away, so the issue is cleared.
- unparseable — evidence of nothing. The group is skipped without clearing, and the log says
skipping … WITHOUT clearing its issueso a truncated lockfile in CI is visible rather than quietly standing down an alert.
Test: main does not clear an issue when the lockfile cannot be parsed writes a truncated package-lock.json and asserts no issue edit, no issue create, and the parse-failure line.
Three findings, all of them the same invariant reached by new routes: a still-open advisory must never stand itself down, and a cleared issue must be written once. - Never clear an advisory that is open but unpatched. groupAlerts drops an alert with no first_patched_version, so it has no replacement group and no replacement issue — calling it "superseded" was false and clearing it removed the only thing tracking a live exposure. Such an issue is now left exactly as it is, and said so in the log. - Stop re-editing cleared issues daily. A cleared issue stays open, so the next run regenerated its body with a new date and edited it again, forever. The date is now read back off the existing body, so only a real change writes — and a real change takes today's date. - Distinguish an absent manifest from an unparseable one. Both returned null, so a malformed lockfile was treated as "the manifest is gone" and cleared the issue. A read error is evidence of nothing. 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>
Copilot review round 13 — all 3 addressedAll three are the same invariant reached by three different routes, which is the useful thing this round exposed. Two of them broke fixes I had made in the two rounds immediately before. 1. An unpatched-but-open alert must not be cleared at allMy round-12 fix was still wrong, just less wrong. There are three states, and the code now says so:
2. Cleared issues were re-edited every day, foreverA cleared issue is deliberately left open, so tomorrow's run sees it again — and with today's date rendered into the body, the regenerated body differed by the date alone. Every cleared issue would have taken one edit a day indefinitely. You also named exactly why my tests missed it: the no-reclear cases passed the same date twice. 3. An unparseable manifest is not an absent oneThe one I introduced myself last round, hardening against non-npm manifests: both returned The pattern worth statingThree rounds, three routes, one rule:
The only facts that qualify are: the manifest is genuinely gone, every installed copy is out of range, or no tracked advisory is open any more. Verification
|
There was a problem hiding this comment.
🟡 Changes recommended
GHSA-only reconciliation can incorrectly clear tracking for an alert that remains open in the same manifest without a patched version.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
AGENTS.md:125
- This guarantee is not currently true for the monthly sweep.
scripts/dependency-refresh.mjs:380-388sorts open milestones without filteringdue_on, so an undated milestone sorts first and is assigned instead of the nearest dated release milestone; only the new daily sweep filters this case. Apply the same dated-milestone selection to the monthly script before documenting the invariant for both.
- Files reviewed: 8/9 changed files
- Comments generated: 2
- Review effort level: Balanced
| // has NO replacement issue, so calling it "superseded" would be false and | ||
| // clearing it would stand down a live exposure with nothing tracking it | ||
| // (Copilot). Leave the issue exactly as it is and say so. | ||
| const unpatched = stillOpen.filter((g) => !filableGhsas.has(g)); |
| assert.equal(ghCall(spawn, "edit"), undefined); | ||
| }); | ||
|
|
||
| test("a cleared issue keeps its original date when its reason changes", () => { |
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>
- `--slurp` the paginated alert feed; a bare `--paginate` emits one JSON array per page and `JSON.parse` rejects it past 100 open alerts. - Put `fixedIn` in the marker and in the existing-issue lookup, so a second bump of one package cannot merge into the first one's issue. - Only an authorization-shaped failure of the `automated-security-fixes` read becomes UNVERIFIED; a rate limit or 5xx now throws. - Distinguish "card never added" (benign, triage picks it up) from "card added, field not set" — the latter finishes every group, then fails the run. - Don't board an unmilestoned issue at Todo; `Incoming` <=> no milestone. - Comment before rewriting the marker, and give the comment its own marker, so a failed comment cannot be skipped forever. - Ask a direct dependency's range to be raised, not widened to `>=`. - Test `main()` through an injected spawn, as the sibling sweep does. - Correct the docs that named the removed release-time `npm audit fix`, the ones promising an unconditional guard, and the sibling sweep's issue body claiming security updates remain enabled. 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>
- Remove the literal NUL bytes that were separating the grouping key's fields; they classified the whole source file as binary, so repo searches skipped it. The key is a JSON array now, with no separator left to justify. - De-duplicate advisory comments per GHSA rather than per whole set. A run that comments and then fails before the marker edit left the next run computing a different set, which matched nothing and announced the same advisory twice. - Refresh the issue title on edit; it carries the advisory count, so it went stale as soon as an issue grew past what it was filed with. - Correct the test file's header, which still claimed main() was left to workflow_dispatch. 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>
Derive the remediation from WHICH copies are vulnerable, not from whether the package is declared. A manifest can declare a safe `pkg@4` while a dependency drags a vulnerable `pkg@3` into a nested folder; the old boolean called that "direct" and asked for a range bump that would have changed nothing, omitting the override the nested copy needs. - `lockfileEntries` keeps each copy's tree path and whether it is the hoisted one; `lockfileVersions` is now derived from it. - `remediation(affected, declared)` returns both flags, so the issue can ask for a range bump, an overrides pin, or explicitly both. The body lists the vulnerable copies and their paths. - An undeclared hoisted copy counts as transitive: it got there the same way any other transitive copy did, and no declared range reaches it. (Found by a test written for this change.) The `vulnerability-alerts: read` finding is declined — verified against a real runner, which reports `VulnerabilityAlerts: read` and reads the alerts successfully. See the PR comment for the log. 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>
- Narrow each group to the advisories the installed copies are actually in range of. Two advisories can share a package, manifest and patched version while having different vulnerable ranges, so a group-level "does any match?" left the marker, title, severity and table all claiming an advisory that does not apply on this branch. - Escape every free-form Markdown cell, the vulnerable range included. A semver range may contain `||`, which is also the column separator. - Paginate the open-issue lookup instead of capping it at 100. The marker lookup is what makes the sweep idempotent, so a truncated list would file a duplicate for every issue it could not see — at the same scale the alert fetch is built to handle. Pull requests, which the issues endpoint also returns, are dropped. 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>
Both findings were "previously missed" suppressed ones; the two against scripts/dependency-refresh.mjs and its workflow are relayed to #2239. - Serialize the sweep with a fixed concurrency group and cancel-in-progress: false. The marker check is a read-before-write and a workflow_dispatch can land on top of the scheduled run, so two runs could both see no open issue and both file one — the duplicate the whole idempotency design exists to prevent. The queued run must wait and re-read, never be cancelled. - Pick the milestone in JS, not in jq. jq sorts null before every string, so sort_by(.due_on) | .[0] returns an UNDATED open milestone in preference to every dated one. An undated bucket has no due date and so cannot be the nearest; pickMilestone drops it, and files the issue unmilestoned if nothing dated is open. 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>
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>
Both findings are consequences of round 6's staleness fix, in the two places it did not reach. - Resolve the matching open issue BEFORE the skip paths, and rewrite it to a cleared state when the exposure is gone. Yesterday's issue is still open today, so a manifest that has since been removed or copies that have moved out of range left its body asserting a vulnerability that no longer exists and its Todo/High card live indefinitely. The marker is retained so the issue is reused if the advisory returns; the issue is not auto-closed, because whether the card belongs in Done or should be deleted depends on why the exposure went away. - The body's prose counted the marker's monotonic history rather than the advisories that apply, so it could claim two open alerts while the title and table correctly showed one. 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>
Three reviews I missed: my poll called the reviews endpoint without --paginate, so it only ever read page one — the same pagination bug this PR was reviewed for. - Reconcile marked issues whose bump has left the open alert feed entirely. openAlerts asks for state=open, so a fixed or dismissed alert simply vanishes: its group is never built, the loop never visits it, and its issue kept asserting a vulnerability with a live Todo/High card. Both cases are covered — the zero-alert run, which used to return before loading issues at all, and a vanished group while other groups remain. - Ask for a PARENT-SCOPED overrides entry when the package is also declared directly. npm rejects an override contradicting a direct dependency with EOVERRIDE, so the guidance for the mixed case would not have applied. The issue now prints the exact nested JSON. - Correct the body's NOTE: severity, range, GHSA and CVE are the advisory's own. Only the installed versions, their paths and range applicability are verified against v2/main. - Stop the new-advisory comment claiming the body marker "now covers" the advisory; it is posted before the edit, so that is false at posting time. It speaks for its own marker instead. - Cover the successful two-field board placement, not only its failure modes. - AGENTS.md: the monthly sweep NEVER boards; only the security sweep does, and only with a PAT. The old wording promised a card the monthly job cannot create. 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>
- Filter to the npm ecosystem. Dependabot alerts are not npm-only and this repo has a Dockerfile, so an alert against it would have been parsed as a lockfile, thrown, and aborted the whole daily sweep before any npm group ran. A non-npm alert is now reported loudly with its GHSAs so a human can file it, and readManifest no longer lets one unparseable manifest take the run down. - Distinguish a superseded bump from a closed one. GitHub can revise first_patched_version, moving an advisory to a different key while it stays open; reporting that as "fixed or dismissed" would stand down a live exposure. The reason now reads from the open GHSA set. - Only claim security-update PRs are off when the run actually read the setting. The guard degrades to UNVERIFIED, and an issue asserting what the run could not confirm is worse than one that stays quiet. - README.md and AGENTS.md: scripts/ now holds repo automation run from CI, not only build/verify tooling. 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>
Build the open-GHSA set from the raw alert feed, not from the grouped alerts. groupAlerts deliberately drops an alert with no first_patched_version, so an advisory that stays OPEN but loses its patched version vanished from both the keys and that set — and reconciliation would have called it "fixed or dismissed". Same wrong direction as the superseded case fixed last round, reached a different way: a still-open advisory must never stand itself down. 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>
Three findings, all of them the same invariant reached by new routes: a still-open advisory must never stand itself down, and a cleared issue must be written once. - Never clear an advisory that is open but unpatched. groupAlerts drops an alert with no first_patched_version, so it has no replacement group and no replacement issue — calling it "superseded" was false and clearing it removed the only thing tracking a live exposure. Such an issue is now left exactly as it is, and said so in the log. - Stop re-editing cleared issues daily. A cleared issue stays open, so the next run regenerated its body with a new date and edited it again, forever. The date is now read back off the existing body, so only a real change writes — and a real change takes today's date. - Distinguish an absent manifest from an unparseable one. Both returned null, so a malformed lockfile was treated as "the manifest is gone" and cleared the issue. A read error is evidence of nothing. 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>
dfc8657 to
1c8d78d
Compare
Closes #2233
The alert-consuming half of #2229, and the last piece of it. #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 they used to act on into ordinary board items.Stacked on #2239. It edits files #2232 introduced and #2239 modified —
scripts/dependency-refresh.mjsand.github/workflows/dependency-refresh.yml, whose headers currently say security updates are still on — so its base isv2/chore/2235-remove-dependabot-config. GitHub retargets it tov2/mainautomatically as the stack merges.What lands
scripts/dependabot-alerts.mjs.github/workflows/dependabot-alerts.ymlworkflow_dispatch,vulnerability-alerts: readscripts/dependabot-alerts.test.mjsAGENTS.mdscripts/dependency-refresh.{mjs,yml}package.jsonsemver, declared at the rootOne issue per bump, not per advisory
Alerts are per-advisory; a fix is per-bump. Grouping by
(package, manifest_path, first_patched_version)is what keeps today's 7 open alerts from becoming 7 issues for what is really 3overridesentries. Dry-run against the live alert feed:Two advisories on the same package needing different patched versions are different bumps and stay apart — that is why
first_patched_versionis in the key and not just(package, manifest).The issue body's first line is a marker naming the package, the manifest and every GHSA covered. That is the idempotency key: a second run the same day is a complete no-op, and a new advisory for a package with an open issue lands as a comment and rewrites the marker rather than filing a second issue.
Alerts are computed from
main; we ship fromv2/mainThis is the one thing the design rests on that isn't obvious. GitHub builds the dependency graph — and therefore every alert — from the default branch. So an alert is not trusted on its face: the sweep checks out
v2/mainand re-tests the vulnerable range against that branch's own lockfile before filing. Already-fixed-there means the alert is only waiting on a milestone merge to close, and it is skipped silently.v2/mainand not yet merged tomainproduces no alert at all. The release-timenpm audit fixfrom #2232 is a partial second signal. Closing it fully wants a schedulednpm audit --audit-level=highoverv2/main's lockfiles, which is a separable follow-up rather than something to bolt on here.toSemverRange— a silent failure worth its own functionGitHub writes a vulnerable range as
>= 3.1.3, < 3.1.6. node-semver reads that comma as nothing at all and returnsfalsefor a version that is squarely in range — so a naivesemver.satisfies(installed, alert.range)would skip every multi-conjunct advisory and the sweep would file nothing while looking perfectly healthy. Space is semver's AND, so the fix is a split/join; it is exported and tested rather than inlined precisely because the failure is invisible.Two things
GITHUB_TOKENcannot do, and neither is a prerequisitevulnerability-alerts: readis the one non-default permission, andGITHUB_TOKENsupports it — reading alerts needs no PAT. Two side steps are outside its reach, andPROJECT_TOKENcovers them when present:v2+chore+dependabot) and milestoned, and the next/issue-triagesweep boards it — its documented exception moves an unboarded-but-milestoned issue straight into Todo.automated-security-fixes.administration: read, andpermissions:has no key for it, soGITHUB_TOKENcan never have it. The issue asked the sweep to "fail loudly if security PRs have been re-enabled"; taken literally that would make every scheduled run red on a 403 for a reason unrelated to the alerts. So it is reported as UNVERIFIED when the token can't see the setting, and only an explicitenabled: truethrows. GivePROJECT_TOKENthe extra scope and it becomes the real assertion the issue wanted.Board Status is Todo, not Incoming — arriving through this pipeline is the approval — and Priority is High as a standing rubric override, recorded in the body so it doesn't read as a mis-scored routine bump.
Option ids are resolved by name at run time rather than hardcoded, because a single-select field's option ids are regenerated whenever its option list is edited. A hardcoded id would turn an unrelated board edit into a silently mis-set field.
semveris declared at the root#2233 observed
semveris already resolvable at the repo root and called it "no new dependency". It is resolvable only transitively, and per Dependency placementscripts/is root-owned code with no manifest of its own, so what it imports is declared at the root. It is adevDependency: nothing consumed at runtime by the published package, and the tarball ships only each client'sbuild/. The lockfile grew by one line — the copy was already in the tree.The workflow installs with
npm ci --ignore-scriptsrather thannpm install: the sweep reads every lockfile as JSON and never needs a client's tree on disk, so the postinstall cascadedependency-refresh.ymlgenuinely needs would be minutes of nothing here.Verification
scripts/dependabot-alerts.test.mjs— 18 tests. Beyond the happy paths they pin the three behaviors that would fail silently:toSemverRangeon real GitHub ranges;parseMarkerrejecting a marker that is not the first line (anything else is not an idempotency key); andgroupAlertsdropping an alert with no patched version, since an issue asking for an unavailable upgrade is noise.v2/main's lockfile (fast-uri@3.1.5,qs@6.15.3,browserslist@4.28.2) and all three correctly classified transitive.npm run local:gategreen.Turning the security-update PRs off is a repo setting, not a file —
DELETE /repos/modelcontextprotocol/inspector/automated-security-fixes, or Settings → Code security. No PR can make that change, so it is a maintainer step at merge time. It currently reports{"enabled":true,"paused":false}.vulnerability-alertsmust stay on (it is, and returns204) — turning alerts off would blind the sweep that replaces the PRs.Two consequences of the same "settings and default branch" theme:
v2/maindoes not start the sweep; it starts at the next milestone merge intomain. Same caveat chore(deps): remove dependabot.yml, fold action bumps into the monthly sweep #2239 carries fordependabot.yml.workflow_dispatchafter that merge.🤖 Generated with Claude Code
https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq