Skip to content

fix(add): stop the similar-name gate refusing popular packages - #658

Merged
colinhacks merged 1 commit into
mainfrom
fix-typosquat-gate
Aug 2, 2026
Merged

fix(add): stop the similar-name gate refusing popular packages#658
colinhacks merged 1 commit into
mainfrom
fix-typosquat-gate

Conversation

@colinhacks

@colinhacks colinhacks commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Three failures, one root cause chain. The nightly lockfile-mutation differential has been red since 2026-07-30, and every Build leg of the release now dies in build.rs. Both trace to the popular-name similarity gate that arrived with the aube v1.35.0 sync (#621).

The gate refuses popular packages

find_similar_package_name skipped only an exact self-match and kept scanning, so being in the popularity corpus was no defence. Measured against the real 100,000-name corpus, that rejects almost everything worth installing — and the name it points at is frequently the squatter:

nub add … before after
react refused, "did you mean preact?" ok
lodash refused, "did you mean loadash?" ok
express refused, "did you mean expres?" ok
debug refused, "did you mean dbug?" ok
axios refused, "did you mean gaxios?" ok
ms refused, "did you mean ws?" ok
lodahs raect axois refused, correct suggestion refused, correct suggestion

A package among the 100,000 most-downloaded on npm is not a typosquat, so a request the corpus lists at all is now cleared outright instead of being compared against the rest of it. A name the corpus does not list is still scanned against all of it.

The blunt form of that rule is what the precision requires, and the intermediate version is worth recording because it looks sufficient and is not. Restricting candidates to names that outrank the request fixes every row above — but still refuses 847 of the top 10,000, measured by running the gate over the corpus itself:

rule false positives in the top 10,000
compare against the whole corpus substantially everything popular
candidates must outrank the request 847 (8.5%) — wsms, qsms, micromatchpicomatch, safer-buffersafe-buffer
clear any request the corpus lists 0

Those are all legitimate packages, and this gate hard-fails a non-interactive install. What the strict rule gives up is a squat popular enough to be in the corpus itself (expres, #58196) — one with real download volume, which is the OSV advisory check's and the downloads floor's territory rather than a spelling heuristic's.

The corpus was not a ranking

The gate reads absence from the corpus as "nobody installs this", which is only true of the real 100,000-name list. When popular-top100000-v1.json is absent, build.rs falls back to the metadata-primer names — which write_package_blob derives from a BTreeMap, alphabetically, and which number 100 in dev and 2000 in release. There, absence means nothing, and the reported "top-100,000 popularity rank #N" is an alphabetical position.

That is how it came to refuse nub add ms@2.1.3 in the nightly mutation harness: in the primer's top 100 sorted alphabetically, qs sits at position 70, which is verbatim the "popularity rank #70" in the failing run. Now build.rs reports whether the corpus is genuinely download-ranked, and the gate declines to run when it is not.

The corpus was never shipped

Release builds set AUBE_REQUIRE_PRIMER=1, under which build.rs treats the corpus as mandatory — but nothing produced it. Upstream aube's release workflow passes --popular-names-out; nub's copy was not updated in the sync, so four Build legs of run 30727744051 died with:

thread 'main' panicked at vendor/aube/crates/aube-resolver/build.rs:262:13:
popular package names are required, but …/data/popular-top100000-v1.json was missing

This was latent until the version-check fix in fdd18fe unblocked the verify gate that had been failing ahead of it. scripts/remote-build.ts already records hitting the same panic.

mac-build.yml sets the same flag and is broken identically. Having Node on the runner is not enough: build.rs regenerates the primer by shelling out to generate-primer.mjs without --popular-names-out, so nothing there ever writes the corpus. Both workflows now generate it explicitly.

Shipping the corpus is what makes the gate live for released binaries for the first time — which is why it lands together with the two fixes above rather than after them.

Verification

Against a binary built from this branch with the unranked corpus, which is what cargo build -p nub-cli produces in CI: ms react lodash express debug axios chalk all install, and the exact failing fixture nub add ms@2.1.3 exits 0. lodahs reactt expresss are still refused — by the weekly-downloads floor, not the similarity gate. The gates are defence in depth, so declining to run this one on an unranked build does not leave squatters unprotected.

Against a binary built with the ranked corpus embedded, so the gate is live: the same popular packages install, and lodahs reactt expresss are refused by the similarity gate itself with the correct suggestion. Both false-positive counts in the table above are measured by running each rule over the corpus.

Also verified: the new corpus step run against the live source writes exactly 100,000 rank-ordered names; option_env! picks up a conditional cargo:rustc-env and leaves no stale value when the flag is removed; both workflow files parse.

Unit tests cover the exemption, a lookalike appearing earlier in the corpus than the request it must not refuse, the unlisted-request scan, and the ranked-flag invariant.

Copilot AI review requested due to automatic review settings August 2, 2026 02:09
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 2, 2026 2:26am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes the add supply-chain similar-name (typosquat) gate so it no longer blocks popular packages, and makes the gate depend on a genuinely download-ranked popularity corpus that is correctly generated and shipped in release builds.

Changes:

  • Stop the similar-name scan once it reaches the requested package’s own popularity entry, so only more popular lookalikes can be suggested.
  • Add a build-time “ranked corpus” signal (popular_package_names_are_ranked) and skip the similar-name gate when the build only has the unranked primer-name fallback.
  • Update release/mac build workflows to generate and ship popular-top100000-v1.json so AUBE_REQUIRE_PRIMER=1 builds no longer panic.

Reviewed changes

Copilot reviewed 2 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
vendor/aube/crates/aube/src/commands/add_supply_chain.rs Fixes similar-name candidate selection (stop at self) and skips the gate on unranked corpora; adds unit tests.
vendor/aube/crates/aube-resolver/src/primer.rs Introduces popular_package_names_are_ranked() and documents ranked vs fallback corpora; adds invariant test.
vendor/aube/crates/aube-resolver/src/lib.rs Re-exports popular_package_names_are_ranked() for consumers.
vendor/aube/crates/aube-resolver/build.rs Emits a “ranked corpus” signal during build when embedding the popularity corpus.
.github/workflows/release.yml Generates + uploads the popularity corpus alongside the primer for release builds.
.github/workflows/mac-build.yml Generates the popularity corpus so mac-build doesn’t fail under AUBE_REQUIRE_PRIMER=1.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

The typosquat gate compared the requested name against every entry in the
popularity corpus except an exact self-match, so being IN the corpus was no
defence. Measured against the real 100,000-name corpus, that rejects almost
every package worth installing — and the name it points at is frequently the
squatter:

  react   -> "did you mean preact?"     lodash  -> "did you mean loadash?"
  express -> "did you mean expres?"     debug   -> "did you mean dbug?"
  axios   -> "did you mean gaxios?"     ms      -> "did you mean ws?"

A package among the 100,000 most-downloaded on npm is not a typosquat, so a
request the corpus lists at all is now cleared outright. A name the corpus does
not list is still scanned against all of it, so lodahs -> lodash is untouched.

The blunt form of that rule is what the precision requires. Restricting
candidates to names that OUTRANK the request looks sufficient — it fixes every
line above — but still refuses 847 of the top 10,000, measured by running the
gate over the corpus itself: ws for ms, qs for ms, micromatch for picomatch,
safer-buffer for safe-buffer. All legitimate, and this gate hard-fails a
non-interactive install. Clearing corpus members outright takes that to 0 of the
top 10,000. What it gives up is a squat popular enough to rank (expres, #58196),
which has real download volume and is the OSV check's and the downloads floor's
territory rather than a spelling heuristic's.

The gate reads absence from the corpus as "nobody installs this", which is only
true of the real list. When popular-top100000-v1.json is absent, build.rs falls
back to the metadata-primer names, which write_package_blob derives from a
BTreeMap — ALPHABETICALLY — and which number 100 in dev and 2000 in release.
There, absence means nothing and the reported "top-100,000 popularity rank #N"
is an alphabetical position: that is how the gate came to refuse `nub add ms` in
the nightly lockfile-mutation differential, where qs sits at alphabetical
position 70. build.rs now reports whether the corpus is the real download-ranked
one, and the gate declines to run when it is not.

Release builds set AUBE_REQUIRE_PRIMER=1, under which build.rs treats the corpus
as mandatory — but nothing produced it, so every Build leg of the release died
in write_popular_names_blob (run 30727744051; the same panic remote-build.ts
already records hitting). release.yml generates and ships it in the primer
artifact now, and mac-build.yml generates its own: having node is not enough
there, because build.rs regenerates the primer by shelling out to
generate-primer.mjs WITHOUT --popular-names-out. This is also what makes the
gate live for released binaries for the first time, so it lands together with
the two fixes above rather than after them.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The break fix is correct and a large improvement, but the rank comparison is necessary rather than sufficient. Measured against the real 100,000-name corpus, the post-fix algorithm still refuses ws, qs, micromatch, safer-buffer and http-proxy-agent from the top 300 — and this PR is what makes the gate live on release binaries. Worth resolving the residue, or the sequencing, before merge.

Reviewed changes — full read of the 6-file diff, plus the surrounding gate, build.rs, generate-primer.mjs, and both workflows.

  • Similar-name scan stops at the request's own rankfind_similar_package_name now breaks instead of continueing on an exact corpus hit, so only names that outrank the request are candidates.
  • New ranked-corpus flagbuild.rs emits cargo:rustc-env=AUBE_POPULAR_NAMES_RANKED=1 when popular-top100000-v1.json is present, surfaced as aube_resolver::popular_package_names_are_ranked().
  • The gate declines to run on an unranked buildsimilar_name_gate returns early, so the alphabetical metadata-primer fallback can no longer be reported as a popularity rank. This is the direct fix for the nightly nub add ms@2.1.3 failure.
  • The corpus is generated and shipped in CI — new steps in release.yml (with 3× retry, uploaded on the metadata-primer artifact) and mac-build.yml, unblocking the Build legs that were panicking under AUBE_REQUIRE_PRIMER=1.
  • Six new unit tests covering the rank comparison both ways, the unlisted-request scan, and the ranked-flag invariant.

⚠️ The corpus goes live in the same change that fixes the algorithm

Shipping popular-top100000-v1.json is what turns this gate on for released binaries for the first time, and the PR body says so explicitly. That makes the residual false-positive rate a release-blocking property rather than a follow-up: on a binary built from this branch, nub add ws in CI exits non-zero. Whether to land all three parts together, or to land the two fixes now and the corpus once the residue is closed, is a call for the maintainer.

Technical details
# Sequencing: algorithm fix vs. corpus enablement

## Context
- `.github/workflows/release.yml:363-381` and `.github/workflows/mac-build.yml:97-104` generate the ranked corpus.
- `.github/workflows/release.yml:393-395` adds it to the `metadata-primer` artifact, which the `build` job downloads into `vendor/aube/crates/aube-resolver/data/`.
- Consequence: `popular_package_names_are_ranked()` is true for every released binary, so `similar_name_gate` runs for real for the first time.

## Required outcome
- A decision, recorded in the PR, on one of:
  1. Close the residual false positives (see the inline comment on `add_supply_chain.rs:873`) in this PR, then ship the corpus.
  2. Ship the two fixes now and gate the corpus behind a follow-up.
  3. Ship as-is, accepting that ~6% of the top 2000 npm packages hard-fail `nub add` in non-interactive contexts.

## Open questions for the human
- Is a hard refusal the right default for this gate at all, given the OSV advisory check and the weekly-downloads floor already run? A warning plus a prompt-only-when-interactive posture would make a false positive recoverable rather than blocking.

ℹ️ Nitpicks

  • ranked_flag_matches_the_embedded_corpus (vendor/aube/crates/aube-resolver/src/primer.rs:438) never executes its assertion in CI. No job both embeds the corpus and runs cargo testrelease.yml's test and conformance jobs and all of ci.yml build without it. The invariant is worth stating, but it currently only fires on a developer's release-style build.
  • mac-build.yml's corpus step has no retry, while release.yml's wraps the same command in a 3× loop with 30s backoff. A transient registry.npmjs.org blip fails the whole mac build. Low stakes given it is manually dispatched, but the asymmetry is unintentional-looking.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

for (index, candidate) in corpus.lines().enumerate() {
if name == candidate {
continue;
break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

break is the right change, but "the lookalike outranks the request" is necessary rather than sufficient — a legitimate package that happens to be outranked by a similar name is still refused. Measured over the real 100,000-name corpus, this goes from 154 of the top 300 refused down to 5, but the survivors are wsms, qsms, micromatchpicomatch, safer-buffersafe-buffer, http-proxy-agenthttps-proxy-agent, and 118 of the top 2000 including globbyglob, vitevitest, chaichalk and expectexeca. Since confirm_similar_package hard-refuses without a TTY, nub add ws fails outright in CI.

Technical details
# Residual false positives after the rank comparison

## Affected sites
- `vendor/aube/crates/aube/src/commands/add_supply_chain.rs:873` — the scan stops at the request's own rank, which removes candidates *less* popular than the request but leaves every more-popular lookalike eligible regardless of how popular the request itself is.
- `vendor/aube/crates/aube/src/commands/add_supply_chain.rs:1459-1468``similar_name_still_flags_a_request_outranked_by_its_lookalike` asserts `qs``ms`, which is one of the false positives above. Any fix has to change this test.
- `vendor/aube/crates/aube/src/commands/add_supply_chain.rs:977-983``LowDownloadPrompt::Terminal` without a TTY on both stdin and stderr is an unconditional `Err(ERR_AUBE_SIMILAR_PACKAGE_NAME)`, so this is a hard failure in CI, not a prompt.

## How this was measured
Fetched the live corpus (`https://raw.githubusercontent.com/jdx/aube-primer-packages/main/data/popular.json`, exactly 100,000 download-ranked names — the same source `generate-primer.mjs --popular-names-out` writes) and reimplemented `comparable_name_parts` + the bounded OSA distance with the same thresholds (2 when `max(len) >= 5`, else 1), running each of the top N corpus names back through the scan as the requested name.

| top N | refused with `continue` | refused with `break` |
| --- | --- | --- |
| 300 | 154 | 5 |
| 2000 || 118 |

Ranks in that corpus: `ms` #9, `chalk` #13, `ws` #56, `qs` #119, `vite` #788, `vitest` #768.

## Required outcome
- A request that is itself unambiguously popular must not be refused as a typosquat of another popular package. `ws`, `qs`, `micromatch`, `safer-buffer`, `http-proxy-agent`, `globby` and `vite` should all install without a prompt on a ranked build.
- `lodahs`, `raect`, `axois`, `expres` must stay refused.

## Suggested approach (optional)
Add a second condition alongside the rank stop: skip the gate when the requested name is itself inside some high-confidence prefix of the corpus. A package in the top few thousand by weekly downloads is not plausibly a squat, whatever it resembles. The threshold is a judgement call, but the measurement above gives a way to calibrate it — pick the smallest cutoff that empties the top-N residue while leaving the known squats flagged.

A relative-distance rule (require `distance` to be small *relative to name length*) would separately kill the 2-character cases like `ws`/`ms` and `qs`/`ms`, where a single-character edit is half the name.

/// gates itself on this flag instead.
fn write_popular_names_blob(out_dir: &Path, source: &Path, fallback_names: &[String]) {
if source.is_file() {
println!("cargo:rustc-env=AUBE_POPULAR_NAMES_RANKED=1");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag is emitted on file existence alone, before the JSON is parsed and before the names.len() != POPULAR_NAMES_TOP check below — and that length check only runs under primer_required(). So a short, truncated or hand-made popular-top100000-v1.json in a non-release build claims a ranking it cannot back, which is exactly what the doc comment above says the flag must never do. Emitting it after validation, gated on the length matching POPULAR_NAMES_TOP, would make the flag mean what it says in every build configuration.

#[test]
fn similar_name_still_flags_a_request_outranked_by_its_lookalike() {
assert_eq!(
find_similar_package_name("qs", "ms\nws\nqs\n"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting this test locks in one of the residual false positives: qs is the #119 package on npm by downloads and ms is #9, so this asserts that nub add qs is refused. The mirror-image behaviour is right to pin, but a lower-profile pair than qs/ms would express it without also freezing a bug.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new exemption rule is correct and the false-positive problem is genuinely solved. One premise it rests on does not survive measurement: the weekly-downloads floor cannot be the backstop for an in-corpus squat, because top-100,000 membership implies download volume far above the default floor. Measured live: dbug 107,160/wk, loadash 38,910/wk, expres 6,194/wk, crossenv 2,219/wk, against a lowDownloadThreshold default of 1000.

Reviewed changes since b624a484 — the rank comparison was replaced with an outright exemption, and the tests and prose were rewritten around it.

  • breakreturn None on an exact corpus hit, so any request the corpus lists is cleared regardless of what surrounds it. Returning immediately (rather than breaking) correctly discards a lookalike already accumulated in best, which is what makes the exemption depend on the whole corpus rather than on scan order.
  • Tests rewrittensimilar_name_clears_any_request_the_corpus_lists, similar_name_clears_a_listed_request_found_after_its_lookalike (the picomatch/micromatch ordering case), and similar_name_flags_a_request_the_corpus_does_not_list. All three fail against the previous commit's code, so they pin the new behavior rather than restate it.
  • Prose updated in the module header, similar_name_gate, and the find_similar_package_name doc comment, including the measured 847 → 0 comparison.

The build.rs comment from the previous review (AUBE_POPULAR_NAMES_RANKED emitted on file existence, before the length check) is unaddressed and still stands.

ℹ️ For the record: a middle ground exists, at a cost you may well reject

Since the previous rule's 847-in-10,000 number is what motivated the switch, it is worth having the third data point on file. Exempting a corpus member unless some candidate outranks it by a large factor — 50× on rank — refuses 1,151 of the 100,000 corpus members (1.15%) while catching all four in-corpus squats I found. That is two orders of magnitude better than the outrank rule and still 1,151 hard non-interactive failures, so choosing 0 over 1,151 is defensible. No change requested; recording it so the decision does not have to be re-derived.

Technical details
# Measured comparison of the three candidate rules

Corpus: https://raw.githubusercontent.com/jdx/aube-primer-packages/main/data/popular.json
(100,000 download-ranked names, the same source `generate-primer.mjs --popular-names-out` writes).
Method: reimplement `comparable_name_parts` + the bounded OSA distance with the same thresholds
(2 when `max(len) >= 5`, else 1) and run every corpus name back through the scan as the request.

| rule | false positives | in-corpus squats caught |
| --- | --- | --- |
| scan whole corpus (pre-PR) | 154 of top 300 ||
| candidates must outrank the request (`b624a484`) | 5 of top 300, 118 of top 2000, ~9% of ranks 10k–11k | `expres`, `loadash`, `dbug`, `crossenv` |
| clear any corpus member (current) | 0 by construction | none |
| clear unless a candidate outranks by ≥50× | 1,151 of 100,000 (1.15%) | `expres`, `loadash`, `dbug`, `crossenv` |

At 100× the count drops to 742 but `crossenv` is missed. At 20× it is 2,051.

## Open questions for the human
- Is 0 false positives worth exempting every squat that has already accrued traffic, given the
  downloads floor demonstrably does not cover them?
- If yes, no code change is needed — only the justification in the doc comment.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

///
/// What it gives up is a squat popular enough to be in the corpus itself
/// (`expres`, #58196). That one has real download volume, which is the OSV
/// advisory check's and the downloads floor's territory rather than a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one claim I could falsify. The default lowDownloadThreshold is 1000 (vendor/aube/crates/aube-settings/settings.toml:583), and every in-corpus squat I checked sits well above it — live last-week counts: expres 6,194, loadash 38,910, dbug 107,160, crossenv 2,219. Top-100,000 membership is a download ranking, so a squat that qualifies for the exemption has by construction cleared the floor; only the OSV MAL-* check is left, and that covers confirmed advisories only.

Technical details
# The downloads floor is not a backstop for an in-corpus squat

## Affected sites
- `vendor/aube/crates/aube/src/commands/add_supply_chain.rs:872-875` — attributes the exempted case to "the OSV advisory check's and the downloads floor's territory". The downloads-floor half is false for every case the exemption creates.
- The PR body carries the same sentence.
- `vendor/aube/crates/aube/src/commands/add_supply_chain.rs:14-20` — the module header lists the three gates in signal order; a reader following it will infer the same false coverage.

## Evidence
`lowDownloadThreshold` default is 1000 (`vendor/aube/crates/aube-settings/settings.toml:583`).
Weekly downloads via `api.npmjs.org/downloads/point/last-week/<pkg>`, the same endpoint the floor
queries: `dbug` 107,160 (rank #25236) · `loadash` 38,910 (#30758) · `expres` 6,194 (#58196) ·
`crossenv` 2,219 (#96565). `crossenv` and `loadash` are the 2017 npm credential-stealing squats.

## Required outcome
- The comment states accurately what covers the exempted case. If OSV is the only remaining
  layer, say that — a future maintainer weighing this tradeoff should not be told a gate covers
  it when it cannot.
- `expres` is a poorly chosen illustration: at 6,194 weekly downloads it is the weakest of the
  four, and it reads as if the exempted set were marginal. `dbug` at 107,160/wk is the honest
  example.

@colinhacks
colinhacks merged commit e3ab583 into main Aug 2, 2026
55 checks passed
@colinhacks
colinhacks deleted the fix-typosquat-gate branch August 2, 2026 03:14
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0

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