Skip to content

fix: check announced releases against advisories (hold, no Run toward malware); workspace members' deps are direct - #983

Merged
runyourempire merged 4 commits into
mainfrom
fix/release-advisory-gate
Oct 18, 2026
Merged

runyourempire merged 4 commits into
mainfrom
fix/release-advisory-gate

Conversation

@runyourempire

@runyourempire runyourempire commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Two defects, fixed in two commits, plus two commits that address the review.

A (HIGH, security): a release is checked against advisories before it is announced or offered a command

Until now scoring::release_grade never asked whether the announced release was safe to move to, and the advisory lanes only check the copies a project already runs. So a newly published malicious minor (OSV MAL-, or a GitHub malware advisory) inside a project's declared range showed up in the stack-change stream with a ready-to-run npm update <pkg>, which is the command that installs it. The Brief's upgrade facts had the same blind spot.

New module osv/release_advisories.rs (ReleaseChecker) checks the announced release itself, in this order:

  1. Affected. The same predicate as "this version is clear" (parent_hint's is_clear): every stored, non-withdrawn advisory for the package must leave the version outside its window. A window the matcher cannot read counts as affected. An affecting advisory is reported even when the mirror is stale.
  2. Withdrawn. fix(fix-target): never recommend a yanked or deprecated release (FX-H5) #976's release_facts: yanked, or npm-deprecated with a later clean release.
  3. Unknown when the ecosystem's advisory data is not ready (osv::readiness::classify: no successful sync inside the 72 h trust horizon, failed, or still syncing). Also unknown when the advisory rows cannot be read: any row error makes the whole read unknown, never a silent skip. Also unknown when the registry gave no publish date.
  4. Held when the release is younger than RELEASE_HOLD_DAYS = 3. A bare date counts from its last second, not from midnight.
    • The hold matches this repo's pnpm-workspace.yaml minimumReleaseAge: 4320 (a test pins it) and the MCP server's WAIT_DAYS.
    • The hold is never shorter than the trust horizon (also pinned), so a release past the hold always predates the sync it was checked against.
  5. Clear. Only a clear release can carry a run_command.

Package names are matched the way the matcher does it (package_key, plus crates.io treating - and _ as the same name).

What the stack-change stream does with each verdict:

  • Affected: the item is kept as a warning. There is no run_command. The title becomes pkg 4.2.0: malicious release, do not install (or affected by <ids>). The explanation names the advisory ids. Where the declared range admits the release, it also says that a lockfile refresh or an install without the lockfile would pull it in. The item gets osv citations and a review_security "Read the advisory" action. Malware is urgency High.
    • Exception: a non-malware release is left to the advisory lane only when every affecting advisory also hits (version-confirmed) every active project's installed copy. In that case the advisory lane already reports each one with a target that clears it.
    • Coverage is judged only on the copies of working projects. Scratch and dormant projects are filtered out before the check.
    • Malware is never dropped, whatever else is true.
    • An affected dev-only minor is no longer dropped as "tooling news", because install scripts run for dev dependencies too. Its range is read from the project's own devDependencies.
    • The "a refresh would pull it in" warning also reads the ranges declared in npm-family workspace members' manifests.
    • A refresh command is still only named for a runtime pin whose own manifest admits a release checked clear.
  • Held: no command. The explanation says: "pkg 4.2.0 was published 1 day ago (2026-10-17); 4DA names no command for a release younger than 3 days, so a compromised publish can be caught and pulled first."
  • Withdrawn: no command, and the item says the registry withdrew the release.
  • Unknown: no command, plus a line saying the advisory status is unknown and why (stale, failed or never-synced mirror, unreadable data, or no publish date).
  • Clear: unchanged.

release_item is split into release_explanation, release_citations and release_actions.

The Brief (brief_facts_build.rs, a 6-line hunk): an upgrade fact whose release is affected or withdrawn is dropped.

  • Upgrade facts are only Breaking or Yanked releases, which a lockfile refresh does not pull in, and they carry no command, so dropping the nudge is the honest option.
  • Annotating them instead would mean adding a field to UpgradeFact in brief_facts.rs, which is at 997 of 1000 lines and shared with three open PRs.
  • Held and unknown releases are not dropped there: the Brief names no command for an upgrade, and it already prints its own "advisory check not complete" line when the mirror is not ready.

The npm 0.0.N-security placeholder handling is unchanged: it is still a prerelease that is never announced (test added), and the matcher's holding rule is untouched.

B (MEDIUM): workspace members' declared dependencies are direct

direct_names for JavaScript read only the lockfile directory's own package.json. A dependency that only an npm, yarn or pnpm workspace member declared was stored as transitive, so the parent walk (parent_hint, which stops at the first direct copy) found no lever for anything under it.

The direct set now also includes the members' dependencies and devDependencies. Members are found by ace::npm_local::workspace_member_dirs from:

  • the root workspaces (array, or yarn's {packages: [...]});
  • pnpm-workspace.yaml packages:, in block or flow style (packages: ['a/*']).

Negations are honoured with real glob matching: !**/test/** excludes packages/test, because an excluded package is not a member. dir/** still expands one level deep, which is documented in the code. The members' own package names are excluded (they are local code), as are protocol specs such as workspace:. Single-root projects are unchanged.

Attribution:

Coordination

Follow-ups (not in this PR)

  • The pipeline_v2 "New minor: X a -> b" feed row still announces a release without the check. It carries no command, and the file is fix(advisories): unversioned matches are never grounded; uncovered ecosystems say so (audit A2/E4) #974's.
  • Knowledge Gaps' "behind" lane grades releases without the check.
  • release_changelog::release_target grades releases (and fetches the new version's archive) without the check.
  • release_grade::concerned_pins picks the largest gap before the scratch/dormant filter. When a scratch project is a major behind and an active project only a minor behind, active_pins is empty and the release says nothing for the active project, malware included. This was already the case before this PR; the fix is to filter liveness before picking the top gap.

Tests

All run after rebasing onto origin/main (which includes #974 and #982):

  • cargo test --lib -- scoring:: evidence:: brief_facts ace_commands osv:: ace::npm_local briefing: 1703 passed, 0 failed (30 ignored).
  • osv/release_advisories_tests.rs (16 tests):
    • malware detection: MAL- ids, MAL- aliases, GHSA "Malicious code in" summaries;
    • a window the matcher cannot read counts as affected;
    • an advisory is reported even when the mirror is stale;
    • coverage requires every active copy to be hit;
    • the hold: 1 day old is Held, 4 days old is Clear, a bare date ages from its last second;
    • an undated release is Unknown;
    • the hold is pinned to minimumReleaseAge (4320 minutes) and to the trust horizon;
    • a stale, failed or never-synced mirror is Unknown;
    • a withdrawn release is never clear;
    • an undecodable advisory row makes the read Unreadable;
    • package-name matching follows the matcher;
    • a database-backed checker test.
  • evidence/stack_change_tests.rs:
    • a malicious minor gets no Run action, a "do not install" warning and urgency High;
    • a clean minor keeps its npm update;
    • an affected release names its advisories and gets no command;
    • unknown, held and withdrawn releases get no command;
    • review B2's exact shape: a scratch project on 2.0.5 and an active project on 2.0.0 (^2.0.0), with MAL- listing 2.0.5 and 2.1.0, still produces the warning;
    • malware is never left to the advisory lane;
    • an affected dev-only minor is announced, with its range read;
    • a range declared by a workspace member triggers the refresh warning, with no command;
    • the npm placeholder is unchanged.
  • ace_commands/dependencies_tests.rs: npm array workspaces, yarn object form, pnpm block style with a negation, pnpm flow style plus wildcard negations, a single root unchanged, and an end-to-end walk where a member's dependency is stored as a direct row and its child stays transitive.
  • cargo fmt --check: clean. cargo clippy --lib -- -D warnings: clean. check-file-sizes: warnings only, no errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7

runyourempire and others added 4 commits October 18, 2026 20:01
…aming a command toward it

A newly published malicious (OSV MAL- / GitHub malware) or advisory-affected
minor inside a declared range was announced in the stack-change stream with a
ready-to-run `npm update`, the command that installs it; the Brief's upgrade
facts had the same blind spot (release_grade never consults advisories).

osv/release_advisories.rs checks the release itself with the predicate behind
every "this version is clear" (check_version_affected over the package's
stored advisories, conservative on unreadable windows), never a registry-
withdrawn release (#976), and an all-clear only from a mirror that is ready
(osv::readiness) and synced after the release was published; otherwise the
status is unknown.

Stack-change stream: affected -> no command, title/explanation name the
advisories (malware: "do not install", urgency High, says a refresh would
pull it in when the range admits it), osv citations; when every affecting
advisory already hits the installed copy the item is left to the advisory
lane; withdrawn / unknown -> no command, said so. Brief: affected or
withdrawn releases are not presented as upgrades.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
The JavaScript direct set read only the lockfile directory's own
package.json, so a dependency only an npm/yarn/pnpm workspace member declared
(runtime or dev) was stored transitive and the parent walk (parent_hint stops
at the first direct copy) found no lever for anything under it.

direct_names now adds every member's dependencies + devDependencies, members
found from the root `workspaces` (array or yarn's {packages: [...]}) and
pnpm-workspace.yaml `packages:` (literal !negations honoured), via the new
ace::npm_local::workspace_member_dirs. Member packages themselves (local
code) and protocol specs are excluded. Rows stay on the workspace's lockfile
project (no declared-in column); single-root projects are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
…ies, never drop malware (#983 review)

B1: a clean release younger than RELEASE_HOLD_DAYS (3, pinned to
pnpm-workspace.yaml minimumReleaseAge 4320 and >= the trust horizon) is
Held: no command, "published N days ago; 4DA names no command for a
release younger than 3 days, so a compromised publish can be caught and
pulled first". An undated release is Unknown ("the registry gave no
publish date"), never Clear. A bare date ages from 23:59:59 (S6).

B2: "covered by the advisory lane" is judged on the ACTIVE sites' copies
(every active copy confirmed-affected by every hit) and never drops a
malicious release.

S3: an affected dev-only minor is announced; `admitted` is read for dev
pins from the project's own devDependencies (commands stay runtime-only).
S4: workspace members' manifests are read for the refresh warning.
S7: any undecodable advisory row makes the read unknown (no silent skip).
S8: an unreadable mirror has its own reason. S9: release_item split into
explanation / citations / actions.

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

`!**/test/**`-style negations now exclude every member directory they
match (an excluded package is not a member, so its declarations are not
direct), and pnpm-workspace.yaml `packages: ['a/*', ...]` is read.
`dir/**` stays one level deep, documented.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hjX3pEMmC7CHfG41PiPj7
@runyourempire
runyourempire force-pushed the fix/release-advisory-gate branch from 781de6c to 98510ae Compare October 18, 2026 10:16
@runyourempire runyourempire changed the title fix: check announced releases against advisories (no Run toward malware); workspace members' deps are direct fix: check announced releases against advisories (hold, no Run toward malware); workspace members' deps are direct Oct 18, 2026
@runyourempire
runyourempire added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 9280f06 Oct 18, 2026
25 checks passed
@runyourempire
runyourempire deleted the fix/release-advisory-gate branch October 18, 2026 10:58
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