Skip to content

feat(bin): add verified Dependabot alert remediation count - #4399

Open
dbeihl wants to merge 5 commits into
kunchenguid:mainfrom
dbeihl:fm/fm-alert-count
Open

dbeihl wants to merge 5 commits into
kunchenguid:mainfrom
dbeihl:fm/fm-alert-count

Conversation

@dbeihl

@dbeihl dbeihl commented Sep 13, 2026

Copy link
Copy Markdown

Intent

On 2026-09-13 a security pull request claimed it remediated 76 advisories. A rough sum by package name came to 87. Both numbers were arrived at honestly and only one was right.

The difference was precisely the set a careless count sweeps in: packages already resolved on the integration branch, a rejected major, a major-only advisory. The exclusions are what made the final figure credible - not the total.

Two facts were nearly reported wrongly that day and both must survive into any output: advisories attach to the DEFAULT branch, so a fix merged into an integration branch closes NONE of them until release; and a figure that is merely consistent with a plausible total is not a verified figure.

What Changed

  • Added bin/fm-alert-count.py <base-ref> <head-ref>. It pulls the live open Dependabot alerts for origin's GitHub repo through gh-axi (all pages) and checks each alert's package-lock.json at both refs against every npm vulnerable range in the advisory, including copies installed under an alias name. An alert counts as remediated only when base has a vulnerable copy and head has none. Other alerts go into a per-alert exclusion list with a reason: already resolved on base, head reintroduces a vulnerable copy, no patched version published, the patch needs a rejected major, or head never reaches a patched version.
  • The script fails closed. Unsupported manifests, lockfiles without a packages object, non-semver or prerelease installed versions, unparseable ranges or patched versions, and malformed alert records are listed under NOT CHECKED and make the script exit 1. They are never counted. Every non-empty run also prints a DEFAULT-BRANCH CAVEAT: advisories attach to the default branch, so fixes on a branch close none of them until they are released there. Output is empty only when the live alert list is empty.
  • Added tests/fm-alert-count.test.sh with a stubbed gh-axi and fixture repos. It covers the verified count and exclusions, the silent empty list, a failed alert fetch that produces no count, and the unverifiable-evidence cases. The test is registered in bin/fm-test-run.sh, and the tool has a new row in docs/scripts.md.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: This is a new read-only script that fails closed. Every prior-round fix was checked in the current code: alias copies, zero-only partial bounds, the reintroduced-copy label, the malformed-record #N, and removal of the duplicate section. The gh-axi output contract was confirmed from source: no line wrapping, a 10MB buffer limit, and a non-zero exit on error. The only issue left is a docs wording mislabel.

Testing

I drove the CLI live with real gh-axi 0.1.35 and the real Dependabot API. The main run used a private work repo's 44 open alerts: I made local branches with lockfile-only edits (nothing pushed) and ran three ref pairs. All 44 verdicts matched what I derived by hand from the published advisory ranges. The branch counted 7 of the 21 alerts on packages it bumped. The run separately labeled already resolved on integration, rejected major, reintroduced, alias and nested copies left behind, and a bump that lands inside another advisory range. It sent pip uv.lock alerts and a missing lockfile to NOT CHECKED with exit 1, and printed the default-branch caveat every time. The 44-alert --full response parsed without truncation. Real prerelease range bounds (&gt;= 5.0.0-beta.1, &lt; 5.0.0-rc.2) and &lt;= bounds also parsed live. Other live checks: a forbidden alert list and a non-GitHub origin both went to NOT CHECKED with exit 1, a repo with no open alerts gave silent output and exit 0, and real multi-page gh-axi output (2 pages) parsed correctly. That paging check used a different endpoint, because the alert endpoint fit on one page. Some labels never came up live: no patched version published, unparseable or partial range, prerelease install, no packages object, malformed record, and a complete exit-0 count. Those two scenarios are marked untested here; the executable test covers them and passed through the project runner, and the coverage guard is ok. The work repo's name, npm package names and product paths are replaced with stable tokens in evidence files 04 and 05, because evidence goes to a public branch. Line shapes are unchanged. This is a CLI, so there is no visual surface; the evidence is CLI transcripts.

  • Live validation: ✅ go - 12 of 14 scenarios driven live against the product
Scenario Result Live Evidence
Operator runs the count for a security branch against the real open alert list and sees only the alerts it verifiably fixes (7 of 21 alerts on bumped packages) ✅ pass live 04-live-real-alerts-verified-count-redacted.txt (fm-alert-count.py integration security)
Every verified count, even 0, comes with the caveat that it closes no default-branch advisory until release ✅ pass live 04-live-real-alerts-verified-count-redacted.txt: DEFAULT-BRANCH CAVEAT line printed in all three runs
Alert already fixed on the integration base is excluded as 'already resolved on integration', not counted ✅ pass live 04: #66, #51 and #41 labeled 'already resolved on integration'
Branch that stays below a major-only patch is excluded as 'rejected major', while a taken major that leaves every range is counted ✅ pass live 04: #70/#69 'rejected major, patch requires 4.1.11'; #44/#43 (6.30.4 to 7.18.0) in VERIFIED
Adversarial: bumping the root copy but leaving an npm alias copy or nested copies vulnerable is not counted ✅ pass live 04: #58/#47 (alias copy at 8.5.15) and brace-expansion #62/#61/#50/#49/#48/#45/#28/#27/#26 (nested copies) labeled 'does not reach a patched version'
Adversarial: bumping into a version inside the advisory's other vulnerable range is not counted ✅ pass live 04: #60/#56 (3.3.12 to 4.0.0) excluded
Adversarial: head that reintroduces a vulnerable copy fixed on base is labeled as reintroduced, not resolved ✅ pass live 04: #67 'security reintroduces a vulnerable copy'
Adversarial: a branch that changes nothing produces count 0, even though a total 'consistent' with the alert list is available ✅ pass live 04: fm-alert-count.py main main gives VERIFIED BRANCH REMEDIATION COUNT: 0 (none)
Unsupported manifest (pip uv.lock) and a missing head lockfile go to NOT CHECKED with exit 1, never counted ✅ pass live 04: 13 uv.lock alerts 'unsupported manifest'; integration nolock lists 6 api alerts as 'unavailable at nolock', exit=1
Inaccessible live alert list or non-GitHub origin gives NOT CHECKED with exit 1, no count ✅ pass live 01-live-forbidden-alert-list.txt, 02-live-non-github-origin.txt
Repo with no open default-branch alerts gives silent output, exit 0 ✅ pass live 05-live-empty-alert-list-silent.txt
Real gh-axi multi-page --paginate/--jq/@base64/--full output is split into one decodable page per API page ✅ pass live 03-live-gh-axi-multipage-toon-parse.txt (2 pages, 7 records, matches unpaginated count)
'No patched version published' label, zero-bound (> 0 / >= 0) acceptance, and rejection of non-zero partial bounds (e.g. '> 8.0, < 9.0', '<= 1.3') ⏸️ untested no The prior payload did not establish a live result: this was only exercised by bin/fm-test-run.sh tests/fm-alert-count.test.sh with a PATH gh-axi stub, not the live product. No readable live repo pub…
Complete evidence (no NOT CHECKED) prints 'NOT CHECKED: none' and exits 0; prerelease installs, v1 lockfiles and malformed records go to NOT CHECKED ⏸️ untested no The prior payload did not establish a live result: this was only exercised by bin/fm-test-run.sh tests/fm-alert-count.test.sh (test_verified_remediation_and_exclusions_are_explicit, test_unverifiabl…
Evidence: Live: inaccessible alert list goes to NOT CHECKED, exit 1

Source: Live: inaccessible alert list goes to NOT CHECKED, exit 1

$ bin/fm-alert-count.py b182d0f f2f2d70   # origin = https://github.com/kunchenguid/firstmate.git (no Dependabot read permission)
NOT CHECKED: gh-axi api: error: Insufficient permissions for this action
code: FORBIDDEN
exit=1
Evidence: Live: non-GitHub origin goes to NOT CHECKED, exit 1

Source: Live: non-GitHub origin goes to NOT CHECKED, exit 1

$ bin/fm-alert-count.py main main   # origin = /private/tmp/not-github
NOT CHECKED: origin is not a GitHub repository, so the live alert list was not checked
exit=1
Evidence: Live: real gh-axi multi-page TOON body parsed into per-page base64

Source: Live: real gh-axi multi-page TOON body parsed into per-page base64

real gh-axi 0.1.35 output for: gh-axi api '~/repos?per_page=5' --paginate --jq '[.[] | {name}] | @base64' --full
raw TOON (first 160 chars): api_response:\n  body: "W3sibmFtZSI6ImNvbG9yLWFuYWx5c2lzIn0seyJuYW1lIjoiZGJlaWhsLmdpdGh1Yi5pbyJ9LHsibmFtZSI6ImZpcnN0bWF0ZSJ9LHsibmFtZSI6ImphcGFuLWl0aW5lcmFyeSJ9L
pages extracted by gh_axi_pages(): 2
page 1: 5 records
page 2: 2 records
total records decoded: 7
Evidence: Live: 44 real alerts, verified count 7 with labeled exclusions, caveat and NOT CHECKED (redacted)

Source: Live: 44 real alerts, verified count 7 with labeled exclusions, caveat and NOT CHECKED (redacted)

LIVE RUN — real gh-axi 0.1.35, real GitHub Dependabot API, real open alert list (44 alerts) of a
PRIVATE work repository. Repository identity, npm package names and product paths are redacted with stable
tokens (pkg-A..pkg-L) because this evidence is published to a public branch. Line shapes are unmodified.

Setup (local only, nothing pushed): `gh repo clone <private-repo> -- --depth 1`, origin left at
https://github.com/<private-repo>.git. Three local commits edit only package-lock.json "version" fields:
  integration (off main):  api lock pkg-J + pkg-C -> first patched (4.1.11);  web lock pkg-G -> 3.4.13 (past both ranges);
                           web lock adds npm alias copy "node_modules/pkg-I-alias": {"name":"pkg-I","version":"8.5.15"}
  security (off integration):
    web pkg-H 4.3.0 -> 4.3.2 (patched)          web pkg-A 2.10.40 -> 2.11.0 (patched)
    web pkg-F 6.30.4 -> 7.18.0 (TAKEN MAJOR)    web pkg-K 3.3.12 -> 4.0.0 (lands inside the advisory's other range)
    web root pkg-D 1.1.14 -> 1.1.18 (nested 2.1.0 / 5.0.6 copies left vulnerable)
    web root pkg-I 8.5.15 -> 8.5.23 (alias copy left at 8.5.15)
    api pkg-I 8.5.15 -> 8.5.23 (patched)        api pkg-J 4.1.11 -> 3.2.6 (REINTRODUCED)
  nolock (off security): deletes the api package-lock.json

Naive "alerts on packages the branch bumped" tally for integration..security = 21. Verified count below = 7.

$ fm-alert-count.py integration security
VERIFIED BRANCH REMEDIATION COUNT: 7 (#71 pkg-H (apps/web/package-lock.json), #68 pkg-A (apps/web/package-lock.json), #57 pkg-I (apps/web/api/package-lock.json), #52 pkg-H (apps/web/package-lock.json), #46 pkg-I (apps/web/api/package-lock.json), #44 pkg-F (apps/web/package-lock.json), #43 pkg-F (apps/web/package-lock.json))
DEFAULT-BRANCH CAVEAT: 7 verified branch remediation(s) close none now. Dependabot advisories attach to the default branch and close only after release to it.
EXCLUDED FROM THE VERIFIED COUNT:
- #70 pkg-J (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #69 pkg-C (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #67 pkg-J (apps/web/api/package-lock.json): security reintroduces a vulnerable copy
- #66 pkg-C (apps/web/api/package-lock.json): already resolved on integration
- #65 pkg-B (apps/web/package-lock.json): security does not reach a patched version
- #64 pkg-E (apps/web/package-lock.json): security does not reach a patched version
- #63 pkg-E (apps/web/package-lock.json): security does not reach a patched version
- #62 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #61 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #60 pkg-K (apps/web/package-lock.json): rejected major, patch requires 5.1.6
- #59 pkg-K (apps/web/api/package-lock.json): security does not reach a patched version
- #58 pkg-I (apps/web/package-lock.json): security does not reach a patched version
- #56 pkg-K (apps/web/package-lock.json): rejected major, patch requires 5.1.16
- #55 pkg-K (apps/web/api/package-lock.json): security does not reach a patched version
- #51 pkg-G (apps/web/package-lock.json): already resolved on integration
- #50 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #49 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #48 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #47 pkg-I (apps/web/package-lock.json): security does not reach a patched version
- #45 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #41 pkg-G (apps/web/package-lock.json): already resolved on integration
- #28 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #27 pkg-D (apps/web/package-lock.json): security does not reach a patched version
- #26 pkg-D (apps/web/package-lock.json): security does not reach a patched version
NOT CHECKED:
- #40 pkg-L (<redacted>/uv.lock): unsupported manifest
- #39 pkg-L (<redacted>/uv.lock): unsupported manifest
- #38 pkg-L (<redacted>/uv.lock): unsupported manifest
- #37 pkg-L (<redacted>/uv.lock): unsupported manifest
- #36 pkg-L (<redacted>/uv.lock): unsupported manifest
- #35 pkg-L (<redacted>/uv.lock): unsupported manifest
- #34 pkg-L (<redacted>/uv.lock): unsupported manifest
- #33 pkg-L (<redacted>/uv.lock): unsupported manifest
- #32 pkg-L (<redacted>/uv.lock): unsupported manifest
- #31 pkg-L (<redacted>/uv.lock): unsupported manifest
- #30 pkg-L (<redacted>/uv.lock): unsupported manifest
- #29 pkg-L (<redacted>/uv.lock): unsupported manifest
- #25 pkg-L (<redacted>/uv.lock): unsupported manifest
exit=1

$ fm-alert-count.py main main   # adversarial: branch changed nothing
VERIFIED BRANCH REMEDIATION COUNT: 0 (none)
DEFAULT-BRANCH CAVEAT: 0 verified branch remediation(s) close none now. Dependabot advisories attach to the default branch and close only after release to it.
EXCLUDED FROM THE VERIFIED COUNT:
- #71 pkg-H (apps/web/package-lock.json): main does not reach a patched version
- #70 pkg-J (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #69 pkg-C (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #68 pkg-A (apps/web/package-lock.json): main does not reach a patched version
- #67 pkg-J (apps/web/api/package-lock.json): rejected major, patch requires 4.1.11
- #66 pkg-C (apps/web/api/package-lock.json): rejected major, patch requires 4.1.11
- #65 pkg-B (apps/web/package-lock.json): main does not reach a patched version
- #64 pkg-E (apps/web/package-lock.json): main does not reach a patched version
- #63 pkg-E (apps/web/package-lock.json): main does not reach a patched version
- #62 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #61 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #60 pkg-K (apps/web/package-lock.json): main does not reach a patched version
- #59 pkg-K (apps/web/api/package-lock.json): main does not reach a patched version
- #58 pkg-I (apps/web/package-lock.json): main does not reach a patched version
- #57 pkg-I (apps/web/api/package-lock.json): main does not reach a patched version
- #56 pkg-K (apps/web/package-lock.json): main does not reach a patched version
- #55 pkg-K (apps/web/api/package-lock.json): main does not reach a patched version
- #52 pkg-H (apps/web/package-lock.json): main does not reach a patched version
- #51 pkg-G (apps/web/package-lock.json): main does not reach a patched version
- #50 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #49 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #48 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #47 pkg-I (apps/web/package-lock.json): main does not reach a patched version
- #46 pkg-I (apps/web/api/package-lock.json): main does not reach a patched version
- #45 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #44 pkg-F (apps/web/package-lock.json): rejected major, patch requires 7.18.0
- #43 pkg-F (apps/web/package-lock.json): rejected major, patch requires 7.18.0
- #41 pkg-G (apps/web/package-lock.json): main does not reach a patched version
- #28 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #27 pkg-D (apps/web/package-lock.json): main does not reach a patched version
- #26 pkg-D (apps/web/package-lock.json): main does not reach a patched version
NOT CHECKED:
- #40 pkg-L (<redacted>/uv.lock): unsupported manifest
- #39 pkg-L (<redacted>/uv.lock): unsupported manifest
- #38 pkg-L (<redacted>/uv.lock): unsupported manifest
- #37 pkg-L (<redacted>/uv.lock): unsupported manifest
- #36 pkg-L (<redacted>/uv.lock): unsupported manifest
- #35 pkg-L (<redacted>/uv.lock): unsupported manifest
- #34 pkg-L (<redacted>/uv.lock): unsupported manifest
- #33 pkg-L (<redacted>/uv.lock): unsupported manifest
- #32 pkg-L (<redacted>/uv.lock): unsupported manifest
- #31 pkg-L (<redacted>/uv.lock): unsupported manifest
- #30 pkg-L (<redacted>/uv.lock): unsupported manifest
- #29 pkg-L (<redacted>/uv.lock): unsupported manifest
- #25 pkg-L (<redacted>/uv.lock): unsupported manifest
exit=1

$ fm-alert-count.py integration nolock   # adversarial: head lockfile missing
VERIFIED BRANCH REMEDIATION COUNT: 5 (#71 pkg-H (apps/web/package-lock.json), #68 pkg-A (apps/web/package-lock.json), #52 pkg-H (apps/web/package-lock.json), #44 pkg-F (apps/web/package-lock.json), #43 pkg-F (apps/web/package-lock.json))
DEFAULT-BRANCH CAVEAT: 5 verified branch remediation(s) close none now. Dependabot advisories attach to the default branch and close only after release to it.
EXCLUDED FROM THE VERIFIED COUNT:
- #70 pkg-J (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #69 pkg-C (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #65 pkg-B (apps/web/package-lock.json): nolock does not reach a patched version
- #64 pkg-E (apps/web/package-lock.json): nolock does not reach a patched version
- #63 pkg-E (apps/web/package-lock.json): nolock does not reach a patched version
- #62 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #61 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #60 pkg-K (apps/web/package-lock.json): rejected major, patch requires 5.1.6
- #58 pkg-I (apps/web/package-lock.json): nolock does not reach a patched version
- #56 pkg-K (apps/web/package-lock.json): rejected major, patch requires 5.1.16
- #51 pkg-G (apps/web/package-lock.json): already resolved on integration
- #50 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #49 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #48 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #47 pkg-I (apps/web/package-lock.json): nolock does not reach a patched version
- #45 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #41 pkg-G (apps/web/package-lock.json): already resolved on integration
- #28 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #27 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
- #26 pkg-D (apps/web/package-lock.json): nolock does not reach a patched version
NOT CHECKED:
- #67 pkg-J (apps/web/api/package-lock.json): apps/web/api/package-lock.json is unavailable at nolock
- #66 pkg-C (apps/web/api/package-lock.json): apps/web/api/package-lock.json is unavailable at nolock
- #59 pkg-K (apps/web/api/package-lock.json): apps/web/api/package-lock.json is unavailable at nolock
- #57 pkg-I (apps/web/api/package-lock.json): apps/web/api/package-lock.json is unavailable at nolock
- #55 pkg-K (apps/web/api/package-lock.json): apps/web/api/package-lock.json is unavailable at nolock
- #46 pkg-I (apps/web/api/package-lock.json): apps/web/api/package-lock.json is unavailable at nolock
- #40 pkg-L (<redacted>/uv.lock): unsupported manifest
- #39 pkg-L (<redacted>/uv.lock): unsupported manifest
- #38 pkg-L (<redacted>/uv.lock): unsupported manifest
- #37 pkg-L (<redacted>/uv.lock): unsupported manifest
- #36 pkg-L (<redacted>/uv.lock): unsupported manifest
- #35 pkg-L (<redacted>/uv.lock): unsupported manifest
- #34 pkg-L (<redacted>/uv.lock): unsupported manifest
- #33 pkg-L (<redacted>/uv.lock): unsupported manifest
- #32 pkg-L (<redacted>/uv.lock): unsupported manifest
- #31 pkg-L (<redacted>/uv.lock): unsupported manifest
- #30 pkg-L (<redacted>/uv.lock): unsupported manifest
- #29 pkg-L (<redacted>/uv.lock): unsupported manifest
- #25 pkg-L (<redacted>/uv.lock): unsupported manifest
exit=1
Evidence: Live: empty open alert list prints nothing, exit 0 (redacted)

Source: Live: empty open alert list prints nothing, exit 0 (redacted)

LIVE RUN — real gh-axi against a private work repository (identity redacted) whose live open Dependabot alert list is empty.
$ gh api "/repos/<private-repo-2>/dependabot/alerts?state=open&per_page=100" --jq length
0
$ fm-alert-count.py main security   # local repo, origin=https://github.com/<private-repo-2>.git
stdout bytes=0 exit=0
Evidence: Verified count and caveat excerpt (redacted)
$ fm-alert-count.py integration security
VERIFIED BRANCH REMEDIATION COUNT: 7 (#71 pkg-H (apps/web/package-lock.json), #68 pkg-A (...), #57 pkg-I (apps/web/api/package-lock.json), #52 pkg-H (...), #46 pkg-I (apps/web/api/package-lock.json), #44 pkg-F (...), #43 pkg-F (...))
DEFAULT-BRANCH CAVEAT: 7 verified branch remediation(s) close none now. Dependabot advisories attach to the default branch and close only after release to it.
EXCLUDED FROM THE VERIFIED COUNT:
- #70 pkg-J (apps/web/package-lock.json): rejected major, patch requires 4.1.11
- #67 pkg-J (apps/web/api/package-lock.json): security reintroduces a vulnerable copy
- #66 pkg-C (apps/web/api/package-lock.json): already resolved on integration
- #58 pkg-I (apps/web/package-lock.json): security does not reach a patched version <- alias copy left at 8.5.15
...
NOT CHECKED:
- #40 pkg-L (<redacted>/uv.lock): unsupported manifest
exit=1

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • 🚨 bin/fm-alert-count.py:151 - version_state decides safe/vulnerable only by checking version &gt;= first_patched_version. It never reads the alert's security_vulnerability.vulnerable_version_range, and it ignores the advisory's other ranges in security_advisory.vulnerabilities. GitHub's alert security_vulnerability holds ONE vulnerable range, so a version on a later, still-vulnerable release line counts as patched. That lets an unverified remediation into the VERIFIED count without any error, which is exactly what the intent forbids: 'a figure that is merely consistent with a plausible total is not a verified figure'. Concrete trace: an advisory for semver has ranges &lt;5.7.2 (patched 5.7.2) and &gt;=7.0.0 &lt;7.5.2 (patched 7.5.2). The alert carries the &lt;5.7.2 range. Base lock has semver 5.7.1 and head has 7.0.0. requires_major_upgrade([5.7.1], 5.7.2) is False, base is vulnerable, and head 7.0.0 >= 5.7.2 comes back 'safe', so the alert is counted as remediated even though 7.0.0 is still vulnerable and the alert stays open after release. The same happens when a lock holds several copies (5.7.1 and 7.3.8) and head bumps only one. The reverse error also exists: a copy below first_patched but outside every vulnerable range forces a false 'does not reach a patched version'. Separately, prerelease ordering collapses to a single flag (1.0.1-beta.1 compares equal to 1.0.1-beta.2), another silent overcount path. Fix: add security_advisory.vulnerabilities to the jq projection. Treat an installed version as vulnerable if it falls inside any npm range for that package, and parse ranges fail-closed: an unparseable range goes to NOT CHECKED.
  • ⚠️ bin/fm-alert-count.py:55 - Any repo with more than 100 open alerts always ends in NOT CHECKED, so the --paginate path the script opts into can never produce a count. gh applies --jq ... | @base64 to each page separately and prints one base64 line per page. gh-axi can't JSON-parse that raw output, so it wraps it as a single string, and TOON encodes a string containing a newline as body: &#34;W3si...\nW3si...&#34; (verified against the installed gh-axi 0.1.35 encoder). The gh_axi_bodies regex [A-Za-z0-9+/=]+ can't match the literal backslash (verified: fullmatch is False), so bodies is empty and the script prints 'gh-axi returned no readable alert data' and exits 1. It fails closed, but the multi-body: loop is dead code and the test suite has no multi-page fixture. Fix: accept the quoted scalar, unescape it (JSON string decoding), and split it on newlines into one base64 page each.
  • ⚠️ bin/fm-alert-count.py:227 - requires_major_upgrade(base_versions, patched) is checked before head_state, so a branch that actually takes the major fix is still excluded. Trace: base foo@1.0.0, patched 2.0.0, head foo@2.0.0. The alert is reported as 'major-only advisory, patch requires 2.0.0' even though the head lockfile verifiably reaches the patched version. The intent names 'a rejected major' as an exclusion, meaning a major the branch did not take, so whether an accepted major counts is a product decision. Two related label defects sit in the same component: (a) line 211 labels first_patched_version: null as 'major-only advisory, no non-major patched version', but GitHub returns null when no patched version exists at all, which has nothing to do with majors; (b) the module docstring (line 11) says these advisories are 'reported as not checked', but the code puts them in excluded and exits 0. Please decide whether a taken major counts as remediated, and whether the null-patch case needs its own label (e.g. 'no patched version published').
  • ⚠️ bin/fm-alert-count.py:236 - Simplification: the 'OPEN DEFAULT-BRANCH ADVISORIES BY PACKAGE' section (per-package tallies built at line 205) isn't needed for the intent, which is about a verified remediation count, its exclusions, and the default-branch caveat. The per-advisory identifiers already appear under PER-ADVISORY VERDICTS. The section also brings back the 'rough sum by package name' shape the intent describes as the misleading figure, and it counts alerts (one per manifest) under an 'ADVISORIES' header. In a repo with several lockfiles, one GHSA shows up as several 'advisories'. Recommended remedy: remove the section (and its test assertion). If you keep it, relabel it as alerts.
  • ℹ️ bin/fm-alert-count.py:124 - Simplification: the lockfileVersion 1 dependencies fallback (visit) is a second parsing path that nothing in the intent requires. v2/v3 lockfiles always carry packages. Recommended remedy: remove it and reject a lock without a packages object as NOT CHECKED. Don't just delete the branch: lock_versions would then return an empty map, and empty head versions are treated as 'safe' (counted as remediated).

🔧 Fix applied.
4 issues (1 error, 2 warnings, 1 info) still open:

  • 🚨 bin/fm-alert-count.py:125 - lock_versions names each copy by its path only, so an npm alias copy is filed under the alias and the vulnerable copy it holds is never checked. This breaks the docstring's rule that head must hold no vulnerable copy, and it can silently raise the VERIFIED count. In v2/v3 lockfiles an aliased install looks like &#34;node_modules/strip-ansi-cjs&#34;: {&#34;name&#34;: &#34;strip-ansi&#34;, &#34;version&#34;: &#34;6.0.1&#34;}. Every lockfile that pulls in @isaacs/cliui (glob@10+, rimraf@5) has these entries, and so does any repo with a user alias like &#34;lodash4&#34;: &#34;npm:lodash@4&#34;. Checked: lock_versions returns {&#39;strip-ansi-cjs&#39;: [&#39;6.0.1&#39;]}. Example: the advisory range is &lt; X for package P. Base has node_modules/P and alias node_modules/P-alias (name P), both in range. Head bumps node_modules/P past X and leaves the alias alone. head_copies for P is empty, so the alert is counted as remediated while head still ships a vulnerable P. Fix at line 125: keep package_name_from_path(path) as the node_modules gate, then use item[&#34;name&#34;] in its place when it is a string. Don't just prefer name everywhere: the root &#34;&#34; entry and workspace packages/foo entries also carry name and must stay excluded. If GitHub's dependency graph turns out to ignore aliases, the fix only undercounts, which is the fail-closed direction.
  • ⚠️ bin/fm-alert-count.py:147 - parse_range accepts only full X.Y.Z bounds, but GitHub publishes shorter bounds for real npm advisories. A live pass over reviewed npm advisories found &gt;= 0 99 times, plus &lt; 8.0, &gt;= 6.0, &lt;= 1.3, = 12.0 and &gt;= 9.22, &lt; 11.5.0. Every sampled malware advisory uses &gt; 0. All of these raise 'unparseable advisory range', so the alert goes to NOT CHECKED and the run exits 1. This fails closed, so nothing is overcounted, but it has two effects. (a) A &gt;= 0 range with first_patched_version: null is exactly the 'no patched version published' case you asked to label, and it never gets that label. (b) A repo with even one malware alert can never get a complete exit-0 count. vulnerable_ranges also raises when any single range for the package fails to parse, and line 166-167 reports a bad first_patched_version.identifier as a bad range. The remedy widens what the parser accepts, which is why this needs your call rather than an auto-fix. Narrowest safe option: accept a bare 0 bound, which covers the &gt;= 0 / &gt; 0 bulk. Zero-padding is exact for &gt;= and &lt; but ambiguous for &lt;= 1.3 and = 12.0 (does 1.3.5 fall in?), so those should stay NOT CHECKED.
  • ⚠️ bin/fm-alert-count.py:246 - if not base_copies: prints 'already resolved on <base>' without looking at head, so a branch that brings a vulnerable copy back is described as resolved. Example: an alert is open on the default branch for foo &lt; 1.0.1. The integration lock has foo 1.0.1. The security branch's lock ends up with a nested foo 1.0.0 after a dependency bump or dedupe change. base_copies is empty, so the output says '#N foo: already resolved on integration', and a reviewer reading the verdicts won't see that the branch brings the vulnerability back. The count is unaffected because the alert is excluded, but the label is wrong. Fix: when base_copies is empty and head_copies is not, add a distinct exclusion such as '<head> reintroduces a vulnerable copy'.
  • ℹ️ bin/fm-alert-count.py:227 - unchecked.append(&#34;malformed live alert record&#34;) drops the alert number even when raw_alert.get(&#34;number&#34;) is an int. Every other NOT CHECKED line carries #N, which the intent needs as a per-advisory identifier. Example: an alert whose security_advisory.vulnerabilities projects to null prints an anonymous 'malformed live alert record', and the operator can't tell which alert was skipped. Fix: include #&lt;number&gt; when the number is an int.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ bin/fm-alert-count.py:155 - The fix round now accepts partial bounds, but it rejects them only for &lt;= and =. A partial &gt; bound with a non-zero component is ambiguous in the same way, and it is accepted with zero padding. That can overcount on the base side. Example: advisory range &gt; 8.0, &lt; 9.0, patched 9.0.0, base lock 8.0.5, head lock 9.0.0. Zero padding reads the bound as &gt; 8.0.0, so base counts as vulnerable, head is clean, and the alert is added to VERIFIED. Under npm's partial-version reading, &gt; 8.0 means &gt;= 8.1.0: base was never vulnerable, and the right label is 'already resolved on <base>'. This is the ambiguity the prior round cited to keep &lt;= 1.3 and = 12.0 NOT CHECKED, and your instruction was to keep genuinely ambiguous syntax NOT CHECKED. &gt; 0 is unaffected, because nothing sorts below 0.0.0, so the malware and no-patch case you asked for still works. I can't confirm GitHub's range grammar from source here, so treat this as an ambiguity, not a proven miscount. Narrower form: accept a partial &gt; bound only when it pads to 0.0.0, and send any other partial &gt; to NOT CHECKED.
  • ⚠️ bin/fm-alert-count.py:269 - Simplification: the 'PER-ADVISORY VERDICTS' section repeats every line already printed in the VERIFIED count, EXCLUDED and NOT CHECKED sections. The intent doesn't need a second copy, and the copy is labeled worse than the originals. (a) Remediated rows show only an identifier with no verdict (- #101 foo (package-lock.json)). (b) NOT CHECKED rows (- #201 java-lib (pom.xml): unsupported manifest) are mixed in with no NOT CHECKED marker. (c) Every row is a Dependabot alert, not an advisory: the query at line 70 never fetches ghsa_id. In a repo with two lockfiles, one GHSA fixed in both shows up as two 'per-advisory' verdicts and adds 2 to the count. That repeats the alert-vs-advisory mix-up that got the by-package section removed last round, and the intent's '76 advisories' story is exactly that kind of miscount. Recommended remedy: remove the section, since the three sections below already list every alert. If advisory-level accounting is wanted, that is a product call: fetch security_advisory.ghsa_id and label rows and the count as alerts, or group them by GHSA.

🔧 Fix applied.
1 info still open:

  • ℹ️ docs/scripts.md:44 - The docs row says the tool counts 'with per-advisory identifiers', but every identifier it prints is a Dependabot alert number (#N). The jq query at bin/fm-alert-count.py:70 never fetches ghsa_id. Round 3 removed the PER-ADVISORY section for this same alert-vs-advisory mix-up, and the docs line still carries it. Example: a repo with package-lock.json and web/package-lock.json gets two alerts for one GHSA. A reader of the docs would take VERIFIED BRANCH REMEDIATION COUNT: 2 as two advisories, which is the '76 advisories' overclaim the intent warns about. Fix is a wording change only: 'per-alert identifiers'.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 12 of 14 scenarios driven live against the product
Scenario Result Live Evidence
Operator runs the count for a security branch against the real open alert list and sees only the alerts it verifiably fixes (7 of 21 alerts on bumped packages) ✅ pass live 04-live-real-alerts-verified-count-redacted.txt (fm-alert-count.py integration security)
Every verified count, even 0, comes with the caveat that it closes no default-branch advisory until release ✅ pass live 04-live-real-alerts-verified-count-redacted.txt: DEFAULT-BRANCH CAVEAT line printed in all three runs
Alert already fixed on the integration base is excluded as 'already resolved on integration', not counted ✅ pass live 04: #66, #51 and #41 labeled 'already resolved on integration'
Branch that stays below a major-only patch is excluded as 'rejected major', while a taken major that leaves every range is counted ✅ pass live 04: #70/#69 'rejected major, patch requires 4.1.11'; #44/#43 (6.30.4 to 7.18.0) in VERIFIED
Adversarial: bumping the root copy but leaving an npm alias copy or nested copies vulnerable is not counted ✅ pass live 04: #58/#47 (alias copy at 8.5.15) and brace-expansion #62/#61/#50/#49/#48/#45/#28/#27/#26 (nested copies) labeled 'does not reach a patched version'
Adversarial: bumping into a version inside the advisory's other vulnerable range is not counted ✅ pass live 04: #60/#56 (3.3.12 to 4.0.0) excluded
Adversarial: head that reintroduces a vulnerable copy fixed on base is labeled as reintroduced, not resolved ✅ pass live 04: #67 'security reintroduces a vulnerable copy'
Adversarial: a branch that changes nothing produces count 0, even though a total 'consistent' with the alert list is available ✅ pass live 04: fm-alert-count.py main main gives VERIFIED BRANCH REMEDIATION COUNT: 0 (none)
Unsupported manifest (pip uv.lock) and a missing head lockfile go to NOT CHECKED with exit 1, never counted ✅ pass live 04: 13 uv.lock alerts 'unsupported manifest'; integration nolock lists 6 api alerts as 'unavailable at nolock', exit=1
Inaccessible live alert list or non-GitHub origin gives NOT CHECKED with exit 1, no count ✅ pass live 01-live-forbidden-alert-list.txt, 02-live-non-github-origin.txt
Repo with no open default-branch alerts gives silent output, exit 0 ✅ pass live 05-live-empty-alert-list-silent.txt
Real gh-axi multi-page --paginate/--jq/@base64/--full output is split into one decodable page per API page ✅ pass live 03-live-gh-axi-multipage-toon-parse.txt (2 pages, 7 records, matches unpaginated count)
'No patched version published' label, zero-bound (> 0 / >= 0) acceptance, and rejection of non-zero partial bounds (e.g. '> 8.0, < 9.0', '<= 1.3') ⏸️ untested no The prior payload did not establish a live result: this was only exercised by bin/fm-test-run.sh tests/fm-alert-count.test.sh with a PATH gh-axi stub, not the live product. No readable live repo pub…
Complete evidence (no NOT CHECKED) prints 'NOT CHECKED: none' and exits 0; prerelease installs, v1 lockfiles and malformed records go to NOT CHECKED ⏸️ untested no The prior payload did not establish a live result: this was only exercised by bin/fm-test-run.sh tests/fm-alert-count.test.sh (test_verified_remediation_and_exclusions_are_explicit, test_unverifiabl…
  • bin/fm-alert-count.py b182d0f f2f2d70 in the worktree (origin kunchenguid/firstmate, real gh-axi returns FORBIDDEN)
  • bin/fm-alert-count.py main main in a temp repo whose origin is a local path (non-GitHub origin)
  • gh-axi api &#39;~/repos?per_page=5&#39; --paginate --jq &#39;[.[] | {name}] | @base64&#39; --full fed through the script's gh_axi_pages() and base64/JSON decoded page by page
  • Shallow gh repo clone of a private work repo with 44 real open alerts; local commits integration, security and nolock edited only package-lock.json versions (nothing pushed)
  • fm-alert-count.py integration security against the live alert list (count 7, all exclusion labels, caveat, NOT CHECKED for pip, exit 1)
  • fm-alert-count.py main main against the live alert list (a branch that changes nothing counts 0)
  • fm-alert-count.py integration nolock against the live alert list (missing head lockfile goes to NOT CHECKED)
  • fm-alert-count.py main security in a local repo whose origin is a private work repo with 0 open alerts (silent, exit 0)
  • bin/fm-test-run.sh tests/fm-alert-count.test.sh (4 behavior tests pass)
  • bin/fm-test-run.sh --check-coverage and --list --family pure-contract-unit (new test is registered)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants