Skip to content

fix(upgrade-plan): name the parent release that fixes a nested advisory (audit H1) - #981

Merged
runyourempire merged 3 commits into
mainfrom
fix/h1-parent-release
Oct 18, 2026
Merged

runyourempire merged 3 commits into
mainfrom
fix/h1-parent-release

Conversation

@runyourempire

@runyourempire runyourempire commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

Audit H1. When a vulnerable transitive dependency can only be fixed by moving its parent, the app names the exact parent release that fixes it. One release per dependency appears on every row and every surface: the Upgrade Plan, Preemption and the Brief. The rules follow @4da/mcp-server (upgrade-planner-fixpaths.ts, fix/planner-advice-625):

  • (a) It picks the smallest parent release whose own requirement resolves to a clean copy at or above the target.
  • (b) That release must be clear of the parent's own advisories.
    • A parent with its own vulnerable row has that row raised to the highest release the transitive rows need from it. This includes rows it pins itself and rows pinned by parents it requires.
    • The lower rows, and the hops of a declared dependency, are lifted to that release when it still admits a clean copy.
  • (c) When the new requirement still admits the installed copy, the text also names the refresh to run after the install (npm 10.9.2 keeps that copy on install).
  • (d) Every declared dependency a transitive parent arrives through is named (up to 3, then "N more"). When any path ends at a package no manifest declares, the lever is unknown and no command is named.
  • (e) Commands go through osv::direct_upgrade, so the declared operator is kept.
  • (f) A move that crosses a major version is flagged.

Doctrine: the output stays inside EvidenceItem (explanation text plus a dependency-path citation). ParentStanding gains Admits and NoneAdmits.

Review of #981, addressed

  • B1. The raise lives in osv::raised_lines::raised_line_targets, which all three surfaces read: the plan, Preemption's alert_fix_target (it now takes the raised lines) and the Brief's facts.
    • Tests: NodeGoat's express shows 4.22.3 on the plan row, the Preemption alert and the Brief fact.
  • S2. osv::parent_route replaces direct_through.
    • Tests: one with two declared dependencies, and one where a path ends at an undeclared package (no lever, no command).
  • S3. The Rust caret-line deprecation rule stays as decided. Vector pr-008 holds both engines to it: glob 11.0.3's target stays 11.1.0. The MCP line to change, and the MCP bug where the refresh is not recomputed after a lift, were sent to the TS terminal.
  • S4. A declared dependency's release agrees with its own row (osv::parent_release_hop).
    • Tests: one fixture for this, and one shaped like the persisted vitest plan (own row, a child that needs more, a child nothing admits). Both give vitest 4.1.11 on every row, the alert and the Brief.
  • S5. mode: "graph" vectors run the full pipeline from RAW npm documents, with deprecation notices kept and nothing pre-filtered:
    • pr-007: NodeGoat, including send pinned by serve-static via express;
    • pr-008: glob under @nestjs/cli.
  • Parity check (newest release). A parent's "newest release" in the text is the highest stable release that is not withdrawn and that no stored advisory affects (test included). The own-advisory "fixable" check uses the highest installable release, not the latest tag. NoneAdmits sentences name no second release.
  • Nits:
    • the override option is restored on dead ends;
    • pre-releases are not kept as parent releases;
    • the stored document is parsed once per build (60 s reuse; tests always re-read);
    • hop, admits_move and parent_move are split.

Review round 3, addressed

  • B2. One predicate decides whether a release can be named (osv::parent_release::Advisories). The same predicate is used by newest(), the parent's own-advisory check, the child copy's check and the parent's "newest release" text.
    • A malicious-package record (MAL- id or alias) that affects a release rules it out: never clean, never a target, never "the newest". An installed malicious copy, parent or declared dependency is to be removed or replaced, never moved to.
    • An advisory with a published fix rules a release out.
    • An advisory no release fixes does not block the move. The release named then says "remains affected by (no fix published)".
    • An advisory whose ranges cannot be read leaves the decision undecided: no release, no command, and the reason is stated.
    • Tests: a malicious release is never named and is never the newest (alias included); an advisory with no, empty or unparseable ranges gives undecided; an unfixed advisory still lets the release be named, with the note.
  • B1. A declared dependency lifted to its own row keeps its own smallest release in smallest and the parent's admitting release in a new floor field.
    • Wording: "express 4.21.0 is the smallest express release that brings in a serve-static that admits it, and express 4.22.3, the release express's own upgrade moves to, also brings in serve-static 1.16.3 (>= serve-static 1.16.1)".
    • The pr-007 and pr-008 release objects carry floor.
  • Should-fix:
    • The Signal item's "Fixed in" now names the release the plan names (security_verdict raises a declared package's fix the same way as the plan row).
    • The release-facts generation counter is bumped after the write, and the 60 s cache is tested with an injected clock and generation.
  • Nits:
    • release_facts.rs is down to 641 lines (the test helpers moved out);
    • the declared-dependency walk counts the project's own requirement edge alongside the other parents, and says "lever not determined (dependency graph too large)" when it stops at its 40-entry limit.

Before / after: NodeGoat (express 4.16.4, path-to-regexp 0.1.7, qs 6.5.2)

Before (path-to-regexp row):

express must move (4DA holds no record of an express release that admits path-to-regexp >= 0.1.13): upgrade express if one does, or replace it

After:

express 4.22.0 is the smallest express release that admits path-to-regexp 0.1.13, and express 4.22.3, the release express's own upgrade moves to, admits it too (its requirement "~0.1.13" resolves to path-to-regexp 0.1.13), so one bump serves both, and no advisory of express affects it: upgrade express to 4.22.3 (npm install "express@^4.22.3")

The express row, the express alert and the Brief now say 4.22.3, not 4.20.0. qs is what forces 4.22.3 over 4.22.0. This matches the reference engine run on the real NodeGoat lockfile, whose express is 4.16.4.

Data / network

There is no new request. The npm abbreviated document that #976 already fetches now also keeps:

  • the latest tag;
  • for the parents that pin a vulnerable npm copy, and every declared dependency they arrive through, each stable release's requirement on that package.

The cap, cache and npm source toggle are unchanged, and only names are sent. NETWORK.md is updated and check-privacy-egress passes. Scope is npm only; crates.io would need per-version dependency data the app does not read.

Tests

  • cargo test --lib -- osv:: evidence:: brief_facts preemption:: (plus scoring::security_verdict) on the rebased head 2e59ea9: 572 passed, 0 failed.
  • Rule vectors: parent_release 8/8, no skip-list change.
  • cargo clippy --lib -D warnings and fmt are clean; file sizes are within limits.

Not verified live in the running app. fourda.exe was not started. The live check: run an OSV sync, then look at the NodeGoat path-to-regexp and express rows.

Shared-file notes:

  • brief_facts_copies.rs: one test-helper line (lever_unknown: None);
  • preemption.rs: the alert_fix_target signature (a peer has a claim on this file);
  • brief_facts.rs is smaller than before.

🤖 Generated with Claude Code

https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7

runyourempire added a commit that referenced this pull request Oct 18, 2026
…w of #981)

B1: the raise of a declared parent's own target (express 4.20.0 -> 4.22.3)
moves into osv::raised_lines, read by the Upgrade Plan, Preemption's alert
(alert_fix_target now takes the raised lines) and the Brief's facts.

S2: every declared dependency a transitive parent arrives through is named
(up to 3, "and N more"), from a walk to the first declared package on every
path (osv::parent_route, the MCP's findRoute); a path ending at an
undeclared package makes the lever unknown and names no command.

S4: a declared lever's release agrees with its own row: its row is raised
by what the parents it requires pin (lever_needs), and a hop lifts to it
when that release still brings a fixed parent in (osv::parent_release_hop).

S5: graph-mode rule vectors run the whole pipeline (matcher, raised lines,
load_routes, hops, raised_row_target) from RAW npm documents: pr-007
(NodeGoat incl. serve-static/send via express), pr-008 (glob's whole
deprecated 11.x keeps 11.1.0 as the target; @nestjs/cli 11.0.11).

Parity: a parent's "newest release" is the highest stable release that is
not withdrawn and clear of its stored advisories; newest() for own-advisory
checks is the highest installable release, not the latest tag; the NoneAdmits
sentences name no second release. The persisted-plan vitest shape (own row,
a child needing more, a child nothing admits) names vitest 4.1.11 on every
row, the alert and the Brief.

Nits: the override option is back on dead ends; pre-releases are not kept
as parent releases; the stored document is parsed once per build; hop,
admits_move and parent_move are split.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
runyourempire and others added 3 commits October 18, 2026 20:14
…ry (audit H1)

A vulnerable transitive copy whose parent pins it said "upgrade express
(check that it admits path-to-regexp >= 0.1.13)" and stopped. The registry
already knew the answer. The plan, Preemption and the Brief now name it,
by the rules of @4da/mcp-server fix/planner-advice-625 (1840dab):

- the smallest parent release above the installed one whose own
  requirement resolves to a clean copy at or above the target, clear of
  the parent's own advisories (osv/parent_release.rs, pure);
- one release per dependency: a parent with its own vulnerable row has
  that row raised to the highest release the transitive rows need from it
  (no major crossed, no own advisory affects it), and a lower row is
  lifted to it when it still admits a clean copy;
- the refresh after the install when the new requirement still admits
  the installed copy (npm 10.9.2 keeps it);
- a parent that cannot be tied to a declared dependency gets its release
  but no command; a command goes through osv::direct_upgrade (declared
  operator kept); a major move is flagged;
- a parent reached through a declared dependency names that dependency's
  release that brings a fixed parent in (adjacent lever).

Data: the npm abbreviated document #976 already reads now also keeps,
for the parents that pin a vulnerable copy (and their declared lever),
each release's requirement on that package plus the latest tag. Same
request, same cap, cache, source toggle; NETWORK.md says so. npm only.

NodeGoat (express 4.16.4): the express, path-to-regexp and qs rows all
name express 4.22.3 (qs forces it over 4.22.0).

New shared vector kind parent_release (pr-001..pr-006), pinned to live
registry and OSV data of 2026-10-18, run against the real functions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
…w of #981)

B1: the raise of a declared parent's own target (express 4.20.0 -> 4.22.3)
moves into osv::raised_lines, read by the Upgrade Plan, Preemption's alert
(alert_fix_target now takes the raised lines) and the Brief's facts.

S2: every declared dependency a transitive parent arrives through is named
(up to 3, "and N more"), from a walk to the first declared package on every
path (osv::parent_route, the MCP's findRoute); a path ending at an
undeclared package makes the lever unknown and names no command.

S4: a declared lever's release agrees with its own row: its row is raised
by what the parents it requires pin (lever_needs), and a hop lifts to it
when that release still brings a fixed parent in (osv::parent_release_hop).

S5: graph-mode rule vectors run the whole pipeline (matcher, raised lines,
load_routes, hops, raised_row_target) from RAW npm documents: pr-007
(NodeGoat incl. serve-static/send via express), pr-008 (glob's whole
deprecated 11.x keeps 11.1.0 as the target; @nestjs/cli 11.0.11).

Parity: a parent's "newest release" is the highest stable release that is
not withdrawn and clear of its stored advisories; newest() for own-advisory
checks is the highest installable release, not the latest tag; the NoneAdmits
sentences name no second release. The persisted-plan vitest shape (own row,
a child needing more, a child nothing admits) names vitest 4.1.11 on every
row, the alert and the Brief.

Nits: the override option is back on dead ends; pre-releases are not kept
as parent releases; the stored document is parsed once per build; hop,
admits_move and parent_move are split.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
…g (review of #981, round 3)

B2: osv::parent_release::Advisories carries each advisory's id and a
malware flag (MAL- id or alias) and holds THE predicate every reader
shares: newest() is the highest stable release no malicious record
affects; usable() rules out malware and any advisory with a published
fix, while an advisory no release fixes does not block a move and is
reported on the release named ("remains affected by <id> (no fix
published)"). An advisory whose ranges cannot be read makes the decision
undecided (no release, no command, the reason said). An installed
malicious copy, parent or lever is to be removed or replaced, never moved
to. parent_standing's "newest release" uses the same predicate.

B1: a lever lifted to its own row keeps its own smallest release in
`smallest` and the parent's admitting release in a new `floor` field:
"express 4.21.0 is the smallest express release that brings in a
serve-static that admits it, and express 4.22.3, the release express's
own upgrade moves to, also brings in serve-static 1.16.3 (>= 1.16.1)".
pr-007/pr-008 release objects carry `floor`.

Also: the Signal item's fix agrees with the plan (security_verdict raises
a declared package's fix like the plan row); the release-facts generation
bumps after the write and the 60 s cache is tested with an injected clock
and generation; the test helpers move out of release_facts.rs (641 lines);
the lever walk counts the project's own requirement edge with the other
parents and says "lever not determined (dependency graph too large)" when
it stops at its bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
@runyourempire
runyourempire added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 2a2e2bc Oct 18, 2026
25 checks passed
@runyourempire
runyourempire deleted the fix/h1-parent-release branch October 18, 2026 11:29
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.

1 participant