Skip to content

feat(pnpr): block vulnerable versions with local OSV index#12506

Merged
zkochan merged 33 commits into
mainfrom
pnpr-sec
Jun 20, 2026
Merged

feat(pnpr): block vulnerable versions with local OSV index#12506
zkochan merged 33 commits into
mainfrom
pnpr-sec

Conversation

@zkochan

@zkochan zkochan commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Adds optional pnpr OSV protection backed by a local npm OSV dump instead of live API calls in the resolver hot path.

  • adds osv.enabled / osv.path config and --osv / --osv-db CLI flags
  • loads the local OSV npm all.zip or extracted JSON directory at startup and fails closed when enabled but the database is missing, not a regular file, or contains no npm advisories
  • rejects vulnerable npm versions during resolution via a resolver-time guard, letting the normal picker choose another compatible version — or failing with a distinct error when every matching version is blocked
  • checks frozen, cached, verified, and freshly resolved lockfiles against the local index, gating tarball entries on the tarball URL rather than the tamper-prone gitHosted flag
  • records a content-based OSV DB fingerprint in pnpr's lockfile-verdict cache so refreshed vulnerability data invalidates prior cached passes
  • adds fallible try_router / try_router_with_auth constructors so embedders can surface an invalid OSV database as a recoverable error instead of a panic

No TypeScript CLI port is included: pnpr has no pnpm-CLI counterpart, and the pacquet-side resolver guard is the infrastructure pnpr uses, so both sides live here.

Squash Commit Body

Add local OSV npm database support to pnpr. When enabled, pnpr loads an OSV npm dump (an `all.zip` or an extracted JSON directory) from disk at startup and fails before serving requests if the configured database is missing, not a regular file, empty of npm advisories, or otherwise invalid. Resolution then uses the in-memory index rather than querying OSV during package selection. Package names are matched case-insensitively, OSV range events are sorted before evaluation, and per-record reads are size-bounded.

Add a resolver-time package version guard to pacquet's npm resolver. The guard can reject a concrete package version, after which the picker filters that version out of the packument and tries the normal selection flow again. pnpr uses this hook to avoid vulnerable versions while preserving existing semver selection semantics. When every matching version is rejected, the resolver returns a distinct AllVersionsBlockedError rather than reporting the spec as unsupported.

Check frozen, cached, verified, and freshly produced lockfiles against the local OSV index, gating tarball entries on the tarball URL rather than the tamper-prone gitHosted flag. The pnpr lockfile-verdict cache records a content-based OSV database fingerprint in its policy snapshot, so a changed vulnerability database invalidates previous cached verification passes and forces rechecking under the new data.

Add fallible try_router / try_router_with_auth constructors so callers that build the router directly can handle an invalid OSV database recoverably instead of panicking.

Add tests for OSV range matching (including out-of-order events), exact-version and case-insensitive matching, withdrawn handling, zip and directory loading, empty/non-regular-file rejection, pnpr config parsing, guarded re-pick and all-versions-blocked behavior, and tarball OSV-checkability.

Checklist

  • The change is implemented in both the TypeScript CLI and the Rust
    pacquet/ port, or the description notes what still needs porting.
  • Added or updated tests.
  • Updated the documentation if needed.

Written by an agent (Codex, GPT-5); description updated by an agent (Claude Code, claude-opus-4-8).

Summary by CodeRabbit

  • New Features
    • Added local OSV npm vulnerability checks with CLI options: --osv to enable scanning and --osv-db to specify the OSV database path.
    • Vulnerability-aware version selection is now enforced during both dependency resolution and lockfile verification.
  • Refactor
    • Introduced an optional “package version guard” mechanism to constrain which versions can be selected, including cache usage.
  • Bug Fixes
    • Resolution can now fail explicitly when all candidate versions are rejected by the guard.
  • Tests
    • Expanded OSV config and end-to-end resolve/verify coverage, plus new scenarios for guarded version rejection.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a PackageVersionGuard trait to the pacquet npm resolver enabling post-selection rejection and repicking of package versions via a blocked_versions loop. In pnpr, implements this guard using an OSV vulnerability database (loaded from zip or JSON directory), integrates OSV checks into resolve and lockfile verification endpoints, and adds policy-fingerprint caching and violation reporting.

Changes

PackageVersionGuard mechanism in pacquet resolvers

Layer / File(s) Summary
PackageVersionGuard trait and ResolveOptions extension
pacquet/crates/resolving-resolver-base/src/resolve.rs, pacquet/crates/resolving-resolver-base/src/lib.rs, pacquet/crates/package-manager/src/resolution_observer.rs
Introduces PackageVersionGuardDecision, boxed future/error aliases, and the PackageVersionGuard trait; extends ResolveOptions with an optional guard field; adds a default package_version_guard() hook to ResolutionObserver.
filter_pkg_metadata_versions helper and repopulate_dist_tags refactor
pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs
Extracts filter_pkg_metadata_versions from filter_pkg_metadata_by_publish_date, making version-predicate filtering reusable; rewires repopulate_dist_tags to operate on the filtered PackageVersions view and return only the dist-tags map.
blocked_versions filtering in pick_package
pacquet/crates/resolving-npm-resolver/src/pick_package.rs, pacquet/crates/resolving-npm-resolver/src/pick_package/tests.rs
Adds PickPackageOptions::blocked_versions and internal helpers filter_blocked_versions, pick_from_meta, pick_from_meta_fast; applies the blocklist across all pick paths: offline, disk-fallback, version-spec fast path, publishedBy shortcut, network fallback, and cache-hit.
pick_from_registry_with_guard retry loop and error handling
pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs, pacquet/crates/resolving-npm-resolver/src/named_registry_resolver.rs, pacquet/crates/resolving-npm-resolver/src/errors.rs
Introduces PickFromRegistryOptions and pick_from_registry_with_guard, which calls pick_package, invokes guard.check(), tracks rejected versions in a blocked_versions set, and terminates on re-selection of an already-blocked version. Adds AllVersionsBlockedError. Both registry resolvers delegate to this helper.
Guard wiring into install/add paths and tests
pacquet/crates/package-manager/src/add.rs, pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs, pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs
Threads package_version_guard from ResolutionObserver into ResolveOptions in the fresh-lockfile install path; sets blocked_versions: None in add.rs; adds RejectVersions guard and three resolver tests asserting repicking behavior when latest is rejected and error handling when all versions are blocked.

OSV vulnerability index integration in pnpr

Layer / File(s) Summary
OsvConfig, YAML parsing, and CLI flags
pnpr/crates/pnpr/Cargo.toml, pnpr/crates/pnpr/src/config.rs, pnpr/crates/pnpr/src/config/tests.rs, pnpr/crates/pnpr/src/lib.rs, pnpr/crates/pnpr/src/main.rs
Adds OsvConfig with enabled/path fields, YAML OsvFile representation, build_osv_config path resolver, --osv/--osv-db CLI flags, node-semver and zip dependencies, and a config parsing test for defaults and relative-path resolution.
OsvIndex: loading, advisory evaluation, and PackageVersionGuard
pnpr/crates/pnpr/src/resolver/osv.rs, pnpr/crates/pnpr/src/resolver/osv/tests.rs
Implements OsvIndex with SHA-256 fingerprinting, zip and directory loading, OSV JSON deserialization, semver range event evaluation, explicit version membership checks, policy trust comparison, and PackageVersionGuard::check returning Allow/Reject decisions. Adds tests for semver range, explicit versions, case-insensitive names, deduplication, async guard behavior, and configuration errors.
Resolver and server OSV wiring and violation reporting
pnpr/crates/pnpr/src/resolver.rs, pnpr/crates/pnpr/src/resolver/tests.rs, pnpr/crates/pnpr/src/server.rs
Adds osv_index to Resolver state and get_or_init signature; derives package_version_guard for StreamObserver; gates frozen-lockfile fast path, cached responses, streaming terminal output, and verify-lockfile paths on OSV violations; merges OSV policy into verdict cache; wires osv_index through AppState, serve, serve_listener, serve_resolve, and serve_verify_lockfile.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server as serve_resolve
  participant Resolver
  participant StreamObserver
  participant PickGuard as pick_from_registry_with_guard
  participant OsvIndex

  Client->>Server: POST /v1/resolve
  Server->>Resolver: get_or_init~config, osv_index~
  Resolver->>StreamObserver: new~package_version_guard = osv_index~

  rect rgba(100, 150, 255, 0.5)
    Note over Resolver,OsvIndex: Frozen-lockfile fast path
    Resolver->>OsvIndex: vulnerability_ids per locked package
    alt violations found
      Resolver-->>Client: violations NDJSON frame
    end
  end

  rect rgba(100, 200, 100, 0.5)
    Note over Resolver,PickGuard: Streaming resolve with guard
    Resolver->>PickGuard: pick_from_registry_with_guard~opts~
    loop until allowed or blocked loop detected
      PickGuard->>PickGuard: pick_package~blocked_versions~
      PickGuard->>OsvIndex: check~name, version~
      OsvIndex-->>PickGuard: Allow | Reject~vuln-id~
      alt Reject
        PickGuard->>PickGuard: add to blocked_versions, retry
      else Allow
        PickGuard-->>Resolver: Ok~Some~picked~~
      end
    end
    Resolver->>OsvIndex: re-check final resolved lockfile
    alt violations remain
      Resolver-->>Client: violations frame
    else clean
      Resolver-->>Client: done frame
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • pnpm/pnpm#11785: Both PRs modify pacquet/crates/resolving-npm-resolver/src/named_registry_resolver.rs's named-registry version selection (pick_from_registry), where the main PR changes it to use the new guard-aware pick_from_registry_with_guard path.
  • pnpm/pnpm#12227: Both PRs modify pnpr's lockfile verification flow; the retrieved PR introduces the POST /v1/verify-lockfile handler, while the main PR layers OSV-derived package_version_guard guarding into that verification path.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pnpr-sec

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.01      8.9±0.59ms   489.3 KB/sec    1.00      8.8±0.13ms   492.2 KB/sec

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.779 ± 0.176 3.545 4.038 1.75 ± 0.14
pacquet@main 3.723 ± 0.140 3.475 3.972 1.72 ± 0.13
pnpr@HEAD 2.197 ± 0.127 1.970 2.369 1.02 ± 0.09
pnpr@main 2.160 ± 0.147 1.939 2.373 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.77929627388,
      "stddev": 0.17592110392384822,
      "median": 3.71520301438,
      "user": 3.5873603000000003,
      "system": 3.2935554600000003,
      "min": 3.5447367358800004,
      "max": 4.03794142788,
      "times": [
        3.9412288928800003,
        3.5447367358800004,
        3.7415845768800002,
        3.6506903208800003,
        4.00563084888,
        4.03794142788,
        3.68882145188,
        3.89828604288,
        3.61778998688,
        3.66625245388
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.72330693588,
      "stddev": 0.140212723346024,
      "median": 3.73274631088,
      "user": 3.6242045999999988,
      "system": 3.2430264600000003,
      "min": 3.4748588548800003,
      "max": 3.97229639188,
      "times": [
        3.4748588548800003,
        3.78534334788,
        3.76808983488,
        3.68418409488,
        3.97229639188,
        3.6240155248800003,
        3.69740278688,
        3.8110350838800002,
        3.8243910368800003,
        3.5914524018800003
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.19650243038,
      "stddev": 0.12694122913890757,
      "median": 2.22590151088,
      "user": 2.7356004999999994,
      "system": 2.8479946600000003,
      "min": 1.9695319638800002,
      "max": 2.36932296788,
      "times": [
        2.09847719888,
        2.27697503488,
        2.27723521788,
        2.03186474388,
        2.24448454188,
        2.3033481738800003,
        2.20731847988,
        1.9695319638800002,
        2.18646598088,
        2.36932296788
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.16024535598,
      "stddev": 0.1471414124046399,
      "median": 2.1479110068800003,
      "user": 2.7176210999999997,
      "system": 2.86421486,
      "min": 1.9386273048800002,
      "max": 2.37317107788,
      "times": [
        2.21658402488,
        2.37317107788,
        2.12952230588,
        2.06268301288,
        2.3274530108800002,
        2.16629970788,
        2.31633618988,
        1.9386273048800002,
        2.07759214288,
        1.99418478188
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 638.7 ± 8.7 630.4 660.9 1.00 ± 0.03
pacquet@main 638.6 ± 14.7 617.0 658.3 1.00
pnpr@HEAD 694.4 ± 38.0 652.4 790.4 1.09 ± 0.06
pnpr@main 692.1 ± 15.1 668.1 711.0 1.08 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6387057114,
      "stddev": 0.008740309327898921,
      "median": 0.6360895310000001,
      "user": 0.37119453999999996,
      "system": 1.3188551400000001,
      "min": 0.6303576725000001,
      "max": 0.6609313635,
      "times": [
        0.6333812975,
        0.6398334395,
        0.6390009145000001,
        0.6609313635,
        0.6341864685,
        0.6333317795000001,
        0.6438551165,
        0.6376145765000001,
        0.6345644855,
        0.6303576725000001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6386409484000001,
      "stddev": 0.014719587116125744,
      "median": 0.6429621240000001,
      "user": 0.37120704000000004,
      "system": 1.32222334,
      "min": 0.6170171625,
      "max": 0.6582654195000001,
      "times": [
        0.6170171625,
        0.6257712805000001,
        0.6541208875000001,
        0.6487979235000001,
        0.6411653525000001,
        0.6447588955000001,
        0.6317173595000001,
        0.6582654195000001,
        0.6468746605000001,
        0.6179205425000001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6944316351000002,
      "stddev": 0.038046613770136274,
      "median": 0.6875403725,
      "user": 0.38684614000000006,
      "system": 1.3455187400000002,
      "min": 0.6523991645,
      "max": 0.7903572155,
      "times": [
        0.7158559725000001,
        0.6936268005,
        0.6813348615,
        0.6878290435000001,
        0.6590365785000001,
        0.6899616245000001,
        0.6523991645,
        0.6866633885000001,
        0.6872517015,
        0.7903572155
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6920508202000001,
      "stddev": 0.015064247773236495,
      "median": 0.6930731655,
      "user": 0.38564403999999997,
      "system": 1.35543784,
      "min": 0.6680630755000001,
      "max": 0.7110352775000001,
      "times": [
        0.7027929095000001,
        0.6740710635,
        0.7045079585,
        0.6930677035,
        0.6745400285,
        0.6930786275,
        0.7062984615000001,
        0.6680630755000001,
        0.6930530965,
        0.7110352775000001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.202 ± 0.050 4.093 4.271 1.98 ± 0.11
pacquet@main 4.217 ± 0.039 4.170 4.291 1.99 ± 0.11
pnpr@HEAD 2.124 ± 0.118 1.979 2.291 1.00
pnpr@main 2.229 ± 0.154 2.017 2.445 1.05 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.201662724319999,
      "stddev": 0.05039624538164557,
      "median": 4.21111332182,
      "user": 3.7807196,
      "system": 3.302732,
      "min": 4.0934825763200005,
      "max": 4.27129733732,
      "times": [
        4.24274864932,
        4.20864786932,
        4.15916231132,
        4.0934825763200005,
        4.19190182432,
        4.21357877432,
        4.24342122832,
        4.17719187432,
        4.27129733732,
        4.215194798320001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.2170165075199995,
      "stddev": 0.03898269463677993,
      "median": 4.20519594082,
      "user": 3.7652476,
      "system": 3.2986818999999996,
      "min": 4.16972120832,
      "max": 4.29130182732,
      "times": [
        4.29130182732,
        4.24119373132,
        4.24873525832,
        4.20708436632,
        4.24883472632,
        4.203307515320001,
        4.18482242632,
        4.19904633532,
        4.17611768032,
        4.16972120832
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.12387479432,
      "stddev": 0.11803726143039499,
      "median": 2.1320591658199994,
      "user": 2.5640123000000004,
      "system": 2.7974074,
      "min": 1.9787571733200002,
      "max": 2.29128264432,
      "times": [
        1.9787571733200002,
        2.19876861932,
        2.28112415432,
        2.02427112532,
        2.0855931863199997,
        2.1848457773199996,
        2.02938139632,
        2.1785251453199996,
        2.29128264432,
        1.98619872132
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.2290029364199997,
      "stddev": 0.15380304271650982,
      "median": 2.17120579182,
      "user": 2.5728108,
      "system": 2.8068101,
      "min": 2.01690745232,
      "max": 2.44529160832,
      "times": [
        2.3348821593199998,
        2.1865079663199998,
        2.4241143353199996,
        2.3765821783199996,
        2.15590361732,
        2.01690745232,
        2.05889164132,
        2.14955554932,
        2.44529160832,
        2.14139285632
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.357 ± 0.016 1.336 1.382 2.03 ± 0.17
pacquet@main 1.374 ± 0.029 1.330 1.432 2.05 ± 0.18
pnpr@HEAD 0.689 ± 0.069 0.652 0.882 1.03 ± 0.13
pnpr@main 0.670 ± 0.057 0.646 0.831 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.35742015306,
      "stddev": 0.01604640374790457,
      "median": 1.3563179115600001,
      "user": 1.3226398199999998,
      "system": 1.7310781,
      "min": 1.33572261156,
      "max": 1.38241917856,
      "times": [
        1.38241917856,
        1.3471921255600001,
        1.36366268356,
        1.34897313956,
        1.37086250956,
        1.37260953456,
        1.36858298656,
        1.3415565445600002,
        1.33572261156,
        1.34262021656
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.37404683496,
      "stddev": 0.029139093611300418,
      "median": 1.36691721356,
      "user": 1.33908122,
      "system": 1.7249868,
      "min": 1.32992000256,
      "max": 1.43222951556,
      "times": [
        1.32992000256,
        1.36486145556,
        1.40575452456,
        1.43222951556,
        1.36082152056,
        1.35159845256,
        1.36897297156,
        1.3605117795600001,
        1.39225657456,
        1.37354155256
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6888770649600001,
      "stddev": 0.06860446679149358,
      "median": 0.66900654956,
      "user": 0.33756781999999996,
      "system": 1.2846052999999997,
      "min": 0.6519194635600001,
      "max": 0.8822440905600001,
      "times": [
        0.6605356585600001,
        0.6519194635600001,
        0.8822440905600001,
        0.6756916435600001,
        0.6796925635600001,
        0.66440330956,
        0.6802172355600001,
        0.6688919855600001,
        0.66912111356,
        0.65605358556
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6699981473600001,
      "stddev": 0.056640057971595555,
      "median": 0.6523050635600001,
      "user": 0.33277782,
      "system": 1.2804619000000002,
      "min": 0.64630375356,
      "max": 0.83065780456,
      "times": [
        0.65207850456,
        0.64630375356,
        0.6499056165600001,
        0.6525316225600001,
        0.64890716656,
        0.6590957155600001,
        0.6465367485600001,
        0.6540619065600001,
        0.6599026345600001,
        0.83065780456
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.031 ± 0.039 2.970 3.125 4.62 ± 0.10
pacquet@main 3.067 ± 0.040 3.012 3.152 4.68 ± 0.10
pnpr@HEAD 0.655 ± 0.011 0.643 0.671 1.00
pnpr@main 0.658 ± 0.019 0.645 0.707 1.00 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0307558831800003,
      "stddev": 0.03916782819931989,
      "median": 3.0254025243799996,
      "user": 1.72666182,
      "system": 2.0250726400000003,
      "min": 2.97041617788,
      "max": 3.12458824588,
      "times": [
        3.01924826588,
        3.02719812388,
        3.0483988808799998,
        3.03811272988,
        3.0236069248799997,
        3.00693599488,
        3.01572060788,
        2.97041617788,
        3.12458824588,
        3.03333287988
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.06732448228,
      "stddev": 0.039937548653473674,
      "median": 3.06500473338,
      "user": 1.75336882,
      "system": 2.02350644,
      "min": 3.01199282288,
      "max": 3.15153498188,
      "times": [
        3.01199282288,
        3.06196011888,
        3.0936191428799997,
        3.0680493478799997,
        3.02249764488,
        3.06026411788,
        3.07829049988,
        3.0374620598799997,
        3.15153498188,
        3.08757408588
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.65535373638,
      "stddev": 0.01073436687485528,
      "median": 0.6565608768800001,
      "user": 0.32975132,
      "system": 1.26738594,
      "min": 0.6431016578800001,
      "max": 0.6714833658800001,
      "times": [
        0.6609377888800001,
        0.6637672948800001,
        0.6431016578800001,
        0.6451853248800001,
        0.6624561638800001,
        0.64465164888,
        0.6714833658800001,
        0.6437961358800001,
        0.6521839648800001,
        0.6659740178800001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6580434983800001,
      "stddev": 0.01853696362203864,
      "median": 0.65181942788,
      "user": 0.34061832,
      "system": 1.26261454,
      "min": 0.6445071358800001,
      "max": 0.7072098118800001,
      "times": [
        0.6486913858800001,
        0.65148201388,
        0.64894444888,
        0.7072098118800001,
        0.6497614608800001,
        0.6445071358800001,
        0.6698363998800001,
        0.6550239338800001,
        0.65282155088,
        0.6521568418800001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12506
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,201.66 ms
(-0.67%)Baseline: 4,230.16 ms
5,076.20 ms
(82.77%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,030.76 ms
(+0.87%)Baseline: 3,004.53 ms
3,605.43 ms
(84.06%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,357.42 ms
(+1.69%)Baseline: 1,334.86 ms
1,601.83 ms
(84.74%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
3,779.30 ms
(-8.19%)Baseline: 4,116.29 ms
4,939.55 ms
(76.51%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
638.71 ms
(+1.80%)Baseline: 627.43 ms
752.91 ms
(84.83%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12506
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,123.87 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
655.35 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
688.88 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,196.50 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
694.43 ms
🐰 View full continuous benchmarking report in Bencher

@zkochan zkochan marked this pull request as ready for review June 19, 2026 23:15
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add local OSV-based vulnerability blocking during npm resolution
✨ Enhancement 🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Description

• Add optional local OSV npm database loading and config/CLI toggles.
• Introduce a resolver-time version guard to reject vulnerable candidates and re-pick.
• Re-verify lockfiles against OSV and invalidate cached passes when OSV DB changes.
Diagram

graph TD
  A["pnpr config/CLI"] --> B{"OSV enabled?"} -->|"yes"| C["Load OSV DB"] --> D[("OsvIndex (in-memory)")]
  B -->|"no"| E["Resolver (no guard)"]
  D --> F["Resolver"] --> G["pacquet npm resolver"] --> H["Picker filters blocked"]
  D --> I["Lockfile verify"] --> J["Verdict cache policy"]

  subgraph Legend
    direction LR
    _cfg["Config/Component"] ~~~ _dec{"Decision"} ~~~ _db[("Data store")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persist OSV index in SQLite (query by name+version)
  • ➕ Lower startup latency and memory footprint than ingesting all JSON into HashMaps
  • ➕ Easier incremental updates and fingerprinting via DB metadata
  • ➕ Potentially faster lookups with proper indexing at high advisory counts
  • ➖ Adds schema/migration and operational complexity
  • ➖ Requires careful query design to handle semver range events efficiently
  • ➖ Introduces an additional local storage artifact to manage
2. Precompute per-package blocked exact versions + compact range representation
  • ➕ Faster guard checks (exact-set lookup and minimal range evaluation)
  • ➕ Smaller in-memory footprint than storing full advisory objects
  • ➕ Less per-request allocation (e.g., avoid returning full ID lists unless needed)
  • ➖ More preprocessing logic and more complex build step for the index
  • ➖ Harder to keep “reason” output rich without extra mapping structures
  • ➖ Still requires full dump ingestion unless paired with streaming/indexing
3. Online OSV API checks with aggressive per-run caching
  • ➕ No need to ship/download OSV dumps or manage local DB files
  • ➕ Always uses the newest vulnerability data
  • ➕ Smaller code footprint around parsing OSV JSON dumps
  • ➖ Network in the resolver hot path (latency, reliability, rate limits)
  • ➖ Harder to guarantee deterministic resolution behavior
  • ➖ More exposed to partial outages and increased operational cost

Recommendation: Keep the PR’s local-index approach: it avoids network calls in the resolver hot path and enables deterministic, reproducible enforcement. If startup time/memory becomes a bottleneck, consider evolving the on-disk format to SQLite or a compact precomputed index, but the introduced guard interface and fingerprinted policy caching are good building blocks for those future implementations.

Files changed (21) +1014 / -116

Enhancement (12) +748 / -92
add.rsThread blocked-versions support into explicit registry resolve options +1/-0

Thread blocked-versions support into explicit registry resolve options

• Extends the resolve options struct literal to include 'blocked_versions: None', aligning with the new picker capability to exclude specific concrete versions when re-picking.

pacquet/crates/package-manager/src/add.rs

install_with_fresh_lockfile.rsPropagate package-version guard from observer into resolver options +3/-0

Propagate package-version guard from observer into resolver options

• Plumbs an optional 'package_version_guard' sourced from the 'ResolutionObserver' into the resolve/install flow so downstream npm resolvers can reject candidates during picking.

pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs

resolution_observer.rsAdd optional package-version guard hook to ResolutionObserver +6/-2

Add optional package-version guard hook to ResolutionObserver

• Introduces 'package_version_guard()' with a default 'None' implementation, allowing pnpr to supply a resolver-time guard without changing all observer implementations.

pacquet/crates/package-manager/src/resolution_observer.rs

named_registry_resolver.rsUse guard-aware pick-from-registry helper +24/-25

Use guard-aware pick-from-registry helper

• Refactors named registry resolution to call 'pick_from_registry_with_guard' and pass through 'ResolveOptions.package_version_guard', enabling re-pick behavior when a candidate is rejected.

pacquet/crates/resolving-npm-resolver/src/named_registry_resolver.rs

npm_resolver.rsImplement guard-driven re-pick loop and prevent infinite spinning +92/-26

Implement guard-driven re-pick loop and prevent infinite spinning

• Adds 'PickFromRegistryOptions' and 'pick_from_registry_with_guard' which repeatedly picks a version, checks it with a 'PackageVersionGuard', and blocks/retries on rejection. Includes a safety stop: if the same version gets re-picked after being blocked, resolution returns 'None' to avoid an attacker-influenced infinite loop.

pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs

pick_package.rsSupport blocked version filtering during picking +66/-15

Support blocked version filtering during picking

• Adds 'blocked_versions' to 'PickPackageOptions' and routes pick paths through 'pick_from_meta(_fast)' helpers that filter packument versions before applying existing selection logic. Uses a shared 'filter_pkg_metadata_versions' helper to keep dist-tags usable after filtering.

pacquet/crates/resolving-npm-resolver/src/pick_package.rs

lib.rsRe-export package version guard types +6/-5

Re-export package version guard types

• Exports the new guard trait, decisions, and associated future/error types from the resolver-base crate for consumption by pacquet and pnpr.

pacquet/crates/resolving-resolver-base/src/lib.rs

resolve.rsIntroduce PackageVersionGuard trait and ResolveOptions hook +40/-0

Introduce PackageVersionGuard trait and ResolveOptions hook

• Defines 'PackageVersionGuard' and 'PackageVersionGuardDecision' for resolver-time candidate rejection, and adds 'ResolveOptions.package_version_guard' to thread an optional guard through resolution.

pacquet/crates/resolving-resolver-base/src/resolve.rs

lib.rsExport OsvConfig from pnpr crate API surface +2/-2

Export OsvConfig from pnpr crate API surface

• Re-exports 'OsvConfig' alongside other configuration types for downstream consumers.

pnpr/crates/pnpr/src/lib.rs

resolver.rsIntegrate OSV guard into resolve/verify flows and cache policy +127/-11

Integrate OSV guard into resolve/verify flows and cache policy

• Threads an optional 'OsvIndex' into the resolver runtime, uses it as a 'PackageVersionGuard' during resolution, and checks lockfiles for OSV violations on frozen/cached/fresh paths. Extends verdict-cache policy snapshots with the OSV fingerprint so changes in vulnerability data invalidate cached passes.

pnpr/crates/pnpr/src/resolver.rs

osv.rsImplement local OSV npm index loader, fingerprinting, and guard +353/-0

Implement local OSV npm index loader, fingerprinting, and guard

• Adds an 'OsvIndex' that loads advisories from an OSV 'all.zip' or a directory of JSON records, computes a sha256 fingerprint, and performs version matching via explicit version sets and OSV event-based semver ranges. Implements 'PackageVersionGuard' so the resolver can reject vulnerable 'name@version' candidates deterministically.

pnpr/crates/pnpr/src/resolver/osv.rs

server.rsLoad OSV index before accepting requests and pass into resolver +28/-6

Load OSV index before accepting requests and pass into resolver

• Loads the OSV index during server/router initialization when enabled, storing it in application state and passing it into the lazily initialized resolver. Ensures misconfigured/missing OSV DB fails startup rather than failing in the request hot path.

pnpr/crates/pnpr/src/server.rs

Refactor (1) +35 / -19
pick_package_from_meta.rsExtract generic version filtering with dist-tag repopulation +35/-19

Extract generic version filtering with dist-tag repopulation

• Factors the publish-date filtering logic into a reusable 'filter_pkg_metadata_versions' function that filters version strings while preserving or repopulating dist-tags to the best remaining candidate. This enables OSV-driven version exclusion without breaking tag semantics.

pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs

Tests (5) +182 / -5
tests.rsAdd tests for guard-based re-picking and dist-tag behavior +69/-2

Add tests for guard-based re-picking and dist-tag behavior

• Introduces a test guard that rejects selected versions and asserts the resolver re-picks a lower compatible version. Adds coverage to ensure dist-tags (like 'latest') are repopulated correctly after filtering.

pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs

tests.rsUpdate picker tests for new blocked_versions option +1/-0

Update picker tests for new blocked_versions option

• Adjusts default options used in tests to set 'blocked_versions: None', keeping existing test expectations intact.

pacquet/crates/resolving-npm-resolver/src/pick_package/tests.rs

tests.rsTest OSV config defaults and relative path resolution +18/-0

Test OSV config defaults and relative path resolution

• Adds test coverage ensuring OSV defaults to disabled and that 'osv.path' is resolved relative to the base config directory when provided.

pnpr/crates/pnpr/src/config/tests.rs

tests.rsTest OSV directory loading, range matching, and guard rejection +79/-0

Test OSV directory loading, range matching, and guard rejection

• Adds unit tests that validate semver introduced/fixed range handling, exact-version matching for non-semver strings, and guard rejection messages containing vulnerability IDs.

pnpr/crates/pnpr/src/resolver/osv/tests.rs

tests.rsAdjust resolver tests for StreamObserver guard plumbing +15/-3

Adjust resolver tests for StreamObserver guard plumbing

• Updates the test observer construction to include a guard implementation, matching the new 'StreamObserver' shape and ensuring existing frame behavior tests continue to compile and run.

pnpr/crates/pnpr/src/resolver/tests.rs

Other (3) +49 / -0
Cargo.tomlAdd dependencies for OSV parsing and zip ingestion +2/-0

Add dependencies for OSV parsing and zip ingestion

• Adds 'node-semver' for semver parsing and 'zip' for reading OSV 'all.zip' dumps, supporting the new local OSV index loader.

pnpr/crates/pnpr/Cargo.toml

config.rsAdd osv.enabled/osv.path config parsing and defaults +32/-0

Add osv.enabled/osv.path config parsing and defaults

• Extends pnpr configuration with an 'OsvConfig' block and YAML parsing ('osv: { enabled, path }'), including resolving relative paths against the config base directory.

pnpr/crates/pnpr/src/config.rs

main.rsAdd --osv and --osv-db CLI flags +15/-0

Add --osv and --osv-db CLI flags

• Adds CLI flags to enable OSV checks and to set the OSV DB path, with CLI overrides applied on top of loaded configuration before server startup.

pnpr/crates/pnpr/src/main.rs

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 19, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Tarball version check bypass 🐞 Bug ⛨ Security
Description
The added tarball URL/version cross-check only extracts a version from URLs ending with lowercase
".tgz" and the conventional "<name>-<version>.tgz" shape, so a tampered lockfile can bypass the
URL-based OSV lookup by using common variants like ".TGZ" or ".tar.gz" while still fetching the
vulnerable artifact under trustLockfile.
Code

pnpr/crates/pnpr/src/resolver.rs[R652-663]

+/// Best-effort extraction of the version from a registry tarball URL of
+/// the conventional `<unscoped-name>-<version>.tgz` shape. Returns `None`
+/// for non-standard naming so a legitimate custom registry isn't
+/// misjudged. Never parses the URL strictly — the lockfile is untrusted.
+fn tarball_url_version<'a>(url: &'a str, name: &str) -> Option<&'a str> {
+    let last = url.rsplit('/').next()?;
+    let last = last.split(['?', '#']).next().unwrap_or(last);
+    let stem = last.strip_suffix(".tgz")?;
+    let unscoped = name.rsplit('/').next().unwrap_or(name);
+    let version = stem.strip_prefix(unscoped)?.strip_prefix('-')?;
+    (!version.is_empty()).then_some(version)
+}
Evidence
osv_violations_for_lockfile() only performs the additional URL-based OSV lookup when
tarball_url_version() returns Some(url_version) and it differs from the lockfile-key version.
The helper’s strip_suffix(".tgz")? is case-sensitive and does not handle other common tarball
suffixes, so those URLs cause None and skip the added cross-check entirely.

pnpr/crates/pnpr/src/resolver.rs[601-663]
pacquet/crates/lockfile/src/resolution.rs[279-343]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`tarball_url_version()` is used to detect `name@version` mismatches between the lockfile key and the tarball URL, and to run an *additional* OSV lookup on the URL-derived version. The helper currently only recognizes lowercase `.tgz` and a strict `<unscoped-name>-<version>.tgz` filename, so trivial URL variations (case, alternate suffixes) skip the additional OSV lookup and re-open an OSV-bypass in the `trustLockfile` threat model.
### Issue Context
- The lockfile is explicitly treated as untrusted on the frozen/trustLockfile paths.
- The code already avoids strict URL parsing to prevent malformed URLs from opting out.
- This fix should keep that “don’t strictly parse” property while recognizing more real-world tarball suffix variants.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver.rs[652-663]
- pnpr/crates/pnpr/src/resolver.rs[601-630]
### Suggested fix
- Make suffix handling case-insensitive for `.tgz`.
- Consider also accepting `.tar.gz` (and possibly `.tgz` with query/fragment already handled) without doing full URL parsing.
- Keep returning `None` for truly non-conventional naming (to avoid false positives), but cover the simple filename/suffix variants attackers can use to bypass the cross-check.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unbounded zip entry names 🐞 Bug ⛨ Security
Description
load_from_zip() clones each zip entry name into a String with no length bound and uses it in
error strings and fingerprinting. A crafted OSV zip with extremely large entry names can force large
allocations (and potentially OOM) during pnpr startup when osv.enabled is set.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R277-305]

+    for index in 0..archive.len() {
+        let mut entry = archive.by_index(index).map_err(|err| {
+            invalid_config(format!("failed to read OSV zip entry in {}: {err}", path.display()))
+        })?;
+        if !entry.is_file() || !entry.name().ends_with(".json") {
+            continue;
+        }
+        let name = entry.name().to_string();
+        if entry.size() > MAX_OSV_RECORD_BYTES {
+            return Err(invalid_config(format!(
+                "OSV zip entry {name} is {} bytes, over the {MAX_OSV_RECORD_BYTES}-byte per-record limit",
+                entry.size(),
+            )));
+        }
+        // Read one past the cap so an underreported `entry.size()` can't
+        // silently truncate a record into still-valid JSON; reject if it
+        // actually exceeds the limit (matching the directory loader).
+        let mut bytes = Vec::with_capacity(entry.size() as usize);
+        (&mut entry)
+            .take(MAX_OSV_RECORD_BYTES + 1)
+            .read_to_end(&mut bytes)
+            .map_err(|err| invalid_config(format!("failed to read OSV zip entry {name}: {err}")))?;
+        if bytes.len() as u64 > MAX_OSV_RECORD_BYTES {
+            return Err(invalid_config(format!(
+                "OSV zip entry {name} is over the {MAX_OSV_RECORD_BYTES}-byte per-record limit",
+            )));
+        }
+        digests.push(record_digest(&name, &bytes));
+        ingest_record_bytes(&mut packages, &bytes)?;
Evidence
The new zip loader explicitly bounds per-record *bytes* but not the entry name, and it clones the
name into an owned String before any parsing/limits beyond the JSON size check, making the
filename itself a resource-exhaustion vector at startup.

pnpr/crates/pnpr/src/resolver/osv.rs[264-306]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`load_from_zip()` does `let name = entry.name().to_string();` for each JSON entry and then interpolates `name` into error strings and hashes it. Zip entry names are not bounded here, so a malicious OSV zip can trigger huge allocations during startup (before pnpr binds its listen socket), causing a startup DoS.
## Issue Context
This code runs when `osv.enabled` is true and the OSV database path points to a zip file.
## Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[264-307]
## Suggested fix
- Introduce a conservative maximum entry-name length (e.g. 4KiB or smaller, since OSV dumps use short names).
- Before cloning, check `entry.name().len()` and return `InvalidConfig` if it exceeds the limit.
- Avoid cloning the full name for normal operation: prefer using `&str` for hashing (`record_digest(entry.name(), &bytes)`) and only allocate a *truncated* display string for error messages (or include only the length + a short prefix).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Zip entry size bypass 🐞 Bug ⛨ Security
Description
load_from_zip() caps reads with take(MAX_OSV_RECORD_BYTES) but never checks whether the
decompressed entry actually exceeded the cap, so a malformed zip with an underreported size can be
silently truncated and still parse as valid JSON. That can drop advisory data while leaving OSV
enabled and non-empty, weakening vulnerability blocking and making the cached fingerprint reflect
only the truncated bytes.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R285-296]

+        if entry.size() > MAX_OSV_RECORD_BYTES {
+            return Err(invalid_config(format!(
+                "OSV zip entry {name} is {} bytes, over the {MAX_OSV_RECORD_BYTES}-byte per-record limit",
+                entry.size(),
+            )));
+        }
+        // `take` caps the read in case the declared size lies.
+        let mut bytes = Vec::with_capacity(entry.size() as usize);
+        (&mut entry)
+            .take(MAX_OSV_RECORD_BYTES)
+            .read_to_end(&mut bytes)
+            .map_err(|err| invalid_config(format!("failed to read OSV zip entry {name}: {err}")))?;
Evidence
The ZIP loader claims it uses take because the declared size may lie, but it reads only
MAX_OSV_RECORD_BYTES and never rejects if the stream contained more. The directory loader in the
same module uses take(MAX+1) and checks bytes.len() to fail closed on oversize content; the zip
loader should do the same to avoid silent truncation.

pnpr/crates/pnpr/src/resolver/osv.rs[264-301]
pnpr/crates/pnpr/src/resolver/osv.rs[303-343]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`load_from_zip()` enforces the per-record limit using `entry.size()` and `.take(MAX_OSV_RECORD_BYTES)`, but does not verify the *actual* decompressed read length stayed under the cap. If the zip metadata lies (the code comment already anticipates this), the loader can truncate a record without noticing; if the truncation still forms valid JSON, pnpr will ingest an incomplete advisory and continue startup with OSV enabled.
## Issue Context
The directory loader already implements the intended pattern: read `MAX+1` bytes and explicitly reject when the cap is exceeded.
## Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[264-301]
### Suggested approach
- Change the zip-entry read to `take(MAX_OSV_RECORD_BYTES + 1)`.
- After `read_to_end`, if `bytes.len() as u64 > MAX_OSV_RECORD_BYTES`, return an `InvalidConfig` error (matching the directory loader semantics).
- Keep the existing `entry.size()` fast-reject, but treat it as an optimization only (not the sole enforcement).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (11)
4. Guard blocks wrong key 🐞 Bug ⛨ Security
Description
pick_from_registry_with_guard() records blocked versions using the parsed manifest version string,
but pick_package() filters by packument version-map keys; if those differ, the rejected candidate is
never excluded and the loop can error with AllVersionsBlockedError even though other acceptable
versions exist.
Code

pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs[R565-584]

+        let version_str = version.version.to_string();
+        match guard.check(&opts.spec.name, &version_str).await? {
+            PackageVersionGuardDecision::Allow => {
+                return Ok(Some(PickedFromRegistry { meta: pick_result.meta, version }));
+            }
+            PackageVersionGuardDecision::Reject { reason } => {
+                tracing::debug!(
+                    target: "pacquet_resolving_npm_resolver",
+                    name = %opts.spec.name,
+                    version = %version_str,
+                    reason = %reason,
+                    "package version rejected by resolver guard",
+                );
+                // A `false` return means the picker re-selected a version we
+                // already blocked, so filtering can't exclude it (e.g. the
+                // parsed version string doesn't match the packument key).
+                // Stop rather than loop forever — every match is blocked.
+                if !blocked_versions.insert(version_str) {
+                    return Err(all_versions_blocked(opts.spec, reason));
+                }
Evidence
The guard loop blocks version.version.to_string() values, but the filtering logic compares against
raw packument version strings (map keys). Since manifest hydration does not validate
key↔manifest-version consistency, a registry can supply mismatched key/version and defeat the
filter, causing repeated selection of the same rejected candidate and a false “all blocked” outcome.

pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs[527-586]
pacquet/crates/resolving-npm-resolver/src/pick_package.rs[764-789]
pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs[320-333]
pacquet/crates/registry/src/package_versions.rs[117-145]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The guard repick loop stores `blocked_versions` as `PackageVersion.version.to_string()`, but filtering is done against packument `versions` map keys. If the registry serves inconsistent metadata (e.g., key "1.0.0+build" with manifest field `version: "1.0.0"`), the rejected candidate won't be filtered out and the resolver can repeatedly re-select it and then raise `AllVersionsBlockedError`, incorrectly failing resolution.
## Issue Context
- The version chooser operates on packument version keys.
- Hydration parses the manifest independently and does not enforce that the parsed `manifest.version` matches the packument key.
- pnpr’s OSV feature enables this guard in a server context where the client controls registry URLs, so a malicious registry response can trigger this denial-of-service / incorrect failure.
## Fix Focus Areas
- pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs[527-588]
- pacquet/crates/resolving-npm-resolver/src/pick_package.rs[764-789]
- pacquet/crates/registry/src/package_versions.rs[117-145]
## Suggested fix approach
1. Change the picker API to return the *selected packument version key* alongside the hydrated `Arc<PackageVersion>` (e.g., `picked_version_key: String`).
2. Store that key in `blocked_versions` (or store both key and canonical string), so the next pick reliably excludes the rejected candidate.
3. Add a regression test using a packument where the `versions` key and manifest `version` differ, ensuring the resolver repicks another allowed version instead of returning `AllVersionsBlockedError`.
(Alternative if API change is too invasive: on rejection, fall back to additionally blocking the packument key by locating it via pointer equality (`Arc::ptr_eq`) against hydrated manifests—only on mismatch paths—to guarantee progress without changing the public picker signature.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Tarball OSV identity spoof ✓ Resolved 🐞 Bug ⛨ Security
Description
For tarball resolutions, OSV checks look up vulnerabilities using the lockfile key’s name@version
without cross-checking the tarball URL that will actually be fetched; when trustLockfile is set, a
tampered lockfile can claim a safe name@version while pointing resolution.tarball at a different
(vulnerable) artifact, bypassing OSV enforcement. This is a trust-boundary mismatch: the URL is the
artifact identity for fetch, but the OSV gate uses only the key identity.
Code

pnpr/crates/pnpr/src/resolver.rs[R601-647]

+fn osv_violations_for_lockfile(index: &OsvIndex, lockfile: &Lockfile) -> Vec<serde_json::Value> {
+    let Some(packages) = lockfile.packages.as_ref() else {
+        return Vec::new();
+    };
+
+    let mut seen = std::collections::HashSet::new();
+    let mut violations = Vec::new();
+    for (package_key, snapshot) in packages {
+        if !is_osv_checkable_resolution(&snapshot.resolution) {
+            continue;
+        }
+        let name = package_key.name.to_string();
+        let version = package_key.suffix.version().to_string();
+        let ids = index.vulnerability_ids(&name, &version);
+        if ids.is_empty() {
+            continue;
+        }
+        // Dedup only the rare vulnerable hits — several lockfile keys can
+        // share one name@version via peer suffixes — so the common
+        // (non-vulnerable) entry never pays for the set.
+        if !seen.insert((name.clone(), version.clone())) {
+            continue;
+        }
+        violations.push(serde_json::json!({
+            "name": name,
+            "version": version,
+            "code": OSV_VULNERABILITY_CODE,
+            "reason": format!(
+                "is listed in the local OSV database as vulnerable ({})",
+                ids.join(", "),
+            ),
+        }));
+    }
+    violations
+}
+
+fn is_osv_checkable_resolution(resolution: &LockfileResolution) -> bool {
+    match resolution {
+        LockfileResolution::Registry(_) => true,
+        // A frozen lockfile is attacker-controlled, so gate on the tarball
+        // URL rather than the tamper-prone `git_hosted` flag or strict URL
+        // parsing — otherwise `gitHosted: true` or a barely-malformed URL
+        // would let a vulnerable package opt out of the OSV scan. Mirrors
+        // the npm verifier's URL-based gate.
+        LockfileResolution::Tarball(tarball) => {
+            is_http_tarball_url(&tarball.tarball) && !is_git_hosted_tarball_url(&tarball.tarball)
+        }
Evidence
The OSV scan chooses the identity from the lockfile key (name/version), while tarball fetching uses
the tarball URL directly for tarball resolutions, so a mismatch can cause OSV to check the wrong
identity when lockfile verification is skipped via trustLockfile.

pnpr/crates/pnpr/src/resolver.rs[601-647]
pacquet/crates/package-manager/src/install_package_by_snapshot.rs[589-613]
pacquet/crates/lockfile/src/resolution.rs[7-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
OSV enforcement for lockfile tarball entries uses `package_key.name` + `package_key.suffix.version()` as the vulnerability identity, but tarball fetch identity is the tarball URL. Under `trustLockfile`, a malicious/tampered lockfile can spoof `name@version` while pointing `resolution.tarball` at a different artifact, causing OSV checks to assess the wrong identity.
### Issue Context
- `osv_violations_for_lockfile()` computes `(name, version)` from the lockfile key and never validates it against `TarballResolution.tarball`.
- For tarball resolutions, the fetch path uses the tarball URL directly, independent of the lockfile key.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver.rs[601-653]
### Suggested fix
- For `LockfileResolution::Tarball` entries that are considered OSV-checkable (http(s) and not git-hosted), add a best-effort extraction of the version (and optionally bare package name) from the tarball URL’s last path segment (strip query/fragment, handle basic percent-encoding, avoid strict URL parsing).
- If a version can be extracted and it *differs* from `package_key.suffix.version()`, treat this as a policy violation (distinct code) or force OSV lookup using the extracted version (and include a mismatch violation) so the OSV gate can’t be bypassed by lying in the lockfile key.
- If extraction fails (nonstandard tarball naming), fall back to current behavior to avoid breaking legitimate registries, but consider emitting a debug log so operators can detect unverifiable tarball identities.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. OSV scan bypassable ✓ Resolved 🐞 Bug ⛨ Security
Description
is_osv_checkable_resolution() skips Tarball entries when tarball.git_hosted == Some(true) and
when reqwest::Url::parse() fails, so OSV enforcement can be suppressed by lockfile-controlled
fields/strings rather than actual dependency identity. This is security-relevant because
fresh_frozen_input_lockfile() can return the client-supplied lockfile unchanged, making the OSV
scan the primary new gate on that path.
Code

pnpr/crates/pnpr/src/resolver.rs[R633-649]

+fn is_osv_checkable_resolution(resolution: &LockfileResolution) -> bool {
+    match resolution {
+        LockfileResolution::Registry(_) => true,
+        LockfileResolution::Tarball(tarball) => {
+            if tarball.git_hosted == Some(true) {
+                return false;
+            }
+            reqwest::Url::parse(&tarball.tarball).is_ok_and(|url| {
+                let scheme = url.scheme();
+                scheme == "http" || scheme == "https"
+            })
+        }
+        LockfileResolution::Directory(_)
+        | LockfileResolution::Git(_)
+        | LockfileResolution::Binary(_)
+        | LockfileResolution::Variations(_) => false,
+    }
Evidence
The OSV scan’s predicate explicitly trusts git_hosted and strict URL parsing to decide whether to
scan a lockfile entry, while other parts of the repo document that gitHosted is tamper-prone and
gate trust using URL-prefix rules instead. pnpr’s resolve handler uses this OSV scan to gate the
frozen-lockfile short-circuit, which can return the client-supplied lockfile unchanged, so skipping
scan directly weakens OSV enforcement on that path.

pnpr/crates/pnpr/src/resolver.rs[261-273]
pnpr/crates/pnpr/src/resolver.rs[633-649]
pnpr/crates/pnpr/src/resolver/resolve.rs[208-273]
pacquet/crates/lockfile/src/resolution.rs[446-461]
pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs[863-888]
REVIEW_GUIDE.md[30-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
pnpr’s OSV lockfile scan currently decides whether a `LockfileResolution::Tarball` is OSV-checkable by trusting (1) the lockfile’s `tarball.git_hosted` boolean and (2) successful `reqwest::Url::parse()`.
Because lockfiles are attacker-controlled input (per repo review rules) and pnpr has a frozen-lockfile short-circuit that can accept the client lockfile largely as-is, an attacker can craft a lockfile that suppresses OSV scanning by setting `gitHosted: true` or providing a tarball URL string that fails URL parsing.
## Issue Context
Other security-sensitive code in this repo explicitly treats `gitHosted` as tamper-prone and gates on URL patterns instead (see `pacquet_lockfile::is_git_hosted_tarball_url`). The npm verifier also does not require `Url::parse()` success to keep an entry checkable.
## Fix Focus Areas
- pnpr/crates/pnpr/src/resolver.rs[633-649]
## Suggested fix
- Stop using `tarball.git_hosted` as an exclusion for OSV scanning (or only treat it as a *hint*).
- Avoid requiring `Url::parse()` success. Prefer a cheap, case-insensitive scheme prefix check (`http://` or `https://`) similar to existing verifier code.
- If you need to exclude git-hosted tarballs from OSV checks, base it on `pacquet_lockfile::is_git_hosted_tarball_url(&tarball.tarball)` (which exists specifically to avoid trusting the tamperable flag), not the lockfile-provided `git_hosted` field.
Example direction:
- `Tarball` is OSV-checkable if it is http(s) **and** `!is_git_hosted_tarball_url(url)`.
Add/adjust tests to cover:
- a tarball with `gitHosted: true` but a non-git-hosted https URL still being scanned
- a tarball URL that is not strictly parseable by `Url::parse()` but still starts with `https://` being scanned

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. OSV event order bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
SemverRange::affects applies OSV range events sequentially and is order-sensitive, but events are
ingested in file order without validation or normalization. A malformed/tampered local OSV dump can
reorder events to yield false negatives (allowing vulnerable versions) or false positives (blocking
safe versions) when OSV is enabled.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R152-180]

+impl SemverRange {
+    fn affects(&self, version: &Version) -> bool {
+        let mut affected = false;
+        for event in &self.events {
+            match event {
+                SemverEvent::Introduced(introduced) => {
+                    if version >= introduced {
+                        affected = true;
+                    }
+                }
+                SemverEvent::Fixed(fixed) => {
+                    if version >= fixed {
+                        affected = false;
+                    }
+                }
+                SemverEvent::LastAffected(last_affected) => {
+                    if version > last_affected {
+                        affected = false;
+                    }
+                }
+                SemverEvent::Limit(limit) => {
+                    if version >= limit {
+                        affected = false;
+                    }
+                }
+            }
+        }
+        affected
+    }
Evidence
Range events are evaluated in sequence (toggling affected), so the outcome depends on event order;
however, events are collected directly from the OSV JSON array without ordering validation/sorting.
That makes the vulnerability decision sensitive to malformed or tampered OSV records.

pnpr/crates/pnpr/src/resolver/osv.rs[152-180]
pnpr/crates/pnpr/src/resolver/osv.rs[397-403]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
OSV SEMVER/ECOSYSTEM range evaluation is order-dependent (`affected` toggles as events are processed), but the loader accepts OSV event arrays as-is. If an OSV record’s events are out of canonical order, vulnerability decisions can be wrong.
### Issue Context
- `semver_range_from_osv` currently collects parsed events without sorting/validating ordering.
- `SemverRange::affects` assumes events are in the correct sequence.
### Fix Focus Areas
- Ensure event ordering is deterministic and correct for evaluation, e.g.:
- validate events are monotonic (by bound version) and fail fast on disorder; or
- sort events by bound version with a defined tie-breaker (e.g. Introduced before Fixed at same version), and optionally emit a warning when normalization changes order.
- Consider including advisory id / package name in warnings/errors to aid debugging.
### Fix Focus Areas (paths/lines)
- pnpr/crates/pnpr/src/resolver/osv.rs[152-180]
- pnpr/crates/pnpr/src/resolver/osv.rs[397-403]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. OSV fingerprint collision ✓ Resolved 🐞 Bug ⛨ Security
Description
load_from_directory() fingerprints the OSV DB by hashing file_name bytes and record bytes
back-to-back without any delimiter/length prefix, so different directory states can produce the same
fingerprint input stream. pnpr uses that fingerprint in can_trust_policy() to accept cached
lockfile-verdict passes, so if the OSV directory is writable by an attacker they can change advisory
contents while preserving the fingerprint and keep stale cache hits trusted.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R288-329]

+fn load_from_directory(path: &Path) -> Result<OsvIndex, RegistryError> {
+    let mut entries = std::fs::read_dir(path)
+        .map_err(|err| {
+            invalid_config(format!("failed to read OSV directory {}: {err}", path.display()))
+        })?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|err| {
+            invalid_config(format!("failed to read OSV directory {}: {err}", path.display()))
+        })?;
+    entries.sort_by_key(std::fs::DirEntry::path);
+
+    let mut packages = HashMap::new();
+    let mut hasher = Sha256::new();
+    for entry in entries {
+        let entry_path = entry.path();
+        if !entry_path.is_file()
+            || entry_path.extension().is_none_or(|extension| extension != "json")
+        {
+            continue;
+        }
+        hasher.update(entry_path.file_name().and_then(|name| name.to_str()).unwrap_or_default());
+        // Bound the read itself with `take` rather than trusting a
+        // pre-read `metadata().len()`, which a concurrent swap could
+        // invalidate (TOCTOU) — matching the zip loader's guarantee.
+        let file = File::open(&entry_path).map_err(|err| {
+            invalid_config(format!("failed to read OSV record {}: {err}", entry_path.display()))
+        })?;
+        let mut bytes = Vec::new();
+        file.take(MAX_OSV_RECORD_BYTES + 1).read_to_end(&mut bytes).map_err(|err| {
+            invalid_config(format!("failed to read OSV record {}: {err}", entry_path.display()))
+        })?;
+        if bytes.len() as u64 > MAX_OSV_RECORD_BYTES {
+            return Err(invalid_config(format!(
+                "OSV record {} is over the {MAX_OSV_RECORD_BYTES}-byte per-record limit",
+                entry_path.display(),
+            )));
+        }
+        hasher.update(&bytes);
+        ingest_record_bytes(&mut packages, &bytes)?;
+    }
+    Ok(OsvIndex { packages, fingerprint: format!("sha256:{:x}", hasher.finalize()) })
+}
Evidence
The directory fingerprint currently hashes an ambiguous concatenation of file-name bytes and file
bytes; that fingerprint is then used as a trusted policy marker for lockfile-verdict cache hits, so
ambiguity can keep cached passes trusted under changed OSV data.

pnpr/crates/pnpr/src/resolver/osv.rs[288-329]
pnpr/crates/pnpr/src/resolver/osv.rs[82-88]
pnpr/crates/pnpr/src/resolver.rs[738-745]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pnpr` computes an OSV *directory* fingerprint by hashing `filename` and then raw file bytes for each record with no delimiter/length prefix. This makes the hashed byte stream ambiguous (it’s just concatenation), so two different directory states can produce the same hash input without breaking SHA-256. That fingerprint is later used as a policy key for the lockfile-verdict cache; a collision/ambiguity can cause pnpr to incorrectly trust cached “clean” results under changed vulnerability data.
Concrete example of ambiguity (same concatenated stream):
- State A: file `"a"` with bytes `"bc"`, then file `"d"` with bytes `"e"` → stream `a bc d e`
- State B: file `"ab"` with bytes `"c"`, then file `"d"` with bytes `"e"` → stream `ab c d e`
## Issue Context
This only matters when the OSV DB directory is attacker-writable (e.g., shared cache dir, multi-tenant host, or other local-write threat model), but in that model it weakens the intended safety property: “OSV DB change ⇒ new fingerprint ⇒ cached verdicts invalidated.”
## Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[288-329]
- pnpr/crates/pnpr/src/resolver/osv.rs[82-88]
- pnpr/crates/pnpr/src/resolver.rs[738-755]
## Suggested fix
When hashing each record, incorporate structure so boundaries are unambiguous:
- hash a fixed tag for each component (e.g., `b"name\0"`, `b"bytes\0"`), **or**
- hash length-prefixed data (e.g., `u64::to_le_bytes(name_len)` + `name_bytes` + `u64::to_le_bytes(bytes_len)` + `bytes`), **and**
- consider hashing the full relative path (or at least include a separator) rather than `file_name()`.
This keeps fingerprints collision-resistant *at the encoding level* and preserves the cache invalidation guarantee.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Invalid OSV semver ignored ✓ Resolved 🐞 Bug ⛨ Security
Description
OSV SEMVER/ECOSYSTEM range events with unparsable versions are silently dropped, which can cause
ranges (or whole advisories) to be ignored and produce false negatives when OSV is enabled. A
corrupted/tampered local OSV dump can therefore selectively disable vulnerability blocking while
pnpr still starts because the npm index remains non-empty.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R392-413]

+fn semver_event_from_osv(event: OsvEvent) -> Option<SemverEvent> {
+    if let Some(introduced) = event.introduced {
+        return parse_osv_version(&introduced).map(SemverEvent::Introduced);
+    }
+    if let Some(fixed) = event.fixed {
+        return parse_osv_version(&fixed).map(SemverEvent::Fixed);
+    }
+    if let Some(last_affected) = event.last_affected {
+        return parse_osv_version(&last_affected).map(SemverEvent::LastAffected);
+    }
+    if let Some(limit) = event.limit {
+        return parse_osv_version(&limit).map(SemverEvent::Limit);
+    }
+    None
+}
+
+fn parse_osv_version(raw: &str) -> Option<Version> {
+    if raw == "0" {
+        return Version::parse("0.0.0").ok();
+    }
+    Version::parse(raw).ok()
+}
Evidence
The ingestion path skips advisories that end up with empty versions and ranges, while range
events are built via filter_map that drops unparsable versions; together this proves malformed
semver can silently remove advisory coverage and lead to missed vulnerability matches.

pnpr/crates/pnpr/src/resolver/osv.rs[342-363]
pnpr/crates/pnpr/src/resolver/osv.rs[384-413]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
OSV semver range parsing currently treats an unparsable event version as “no event” and drops it. If a range becomes empty (or an advisory ends up with neither explicit `versions` nor parsed `ranges`), the advisory is silently skipped, weakening enforcement.
### Issue Context
This OSV index is a security control when `osv.enabled` is true. Silent acceptance of malformed semver data can turn real advisories into non-enforced entries without any startup failure or explicit warning.
### Fix Focus Areas
- Make semver parsing of `SEMVER`/`ECOSYSTEM` ranges strict when OSV is enabled: if an event field is present but cannot be parsed (except the special-case `"0"`), treat the OSV DB as invalid and fail startup.
- If strict failure is too aggressive, at least record and surface validation errors (warn + count, and optionally refuse to start when any are observed) so enforcement can’t silently degrade.
- pnpr/crates/pnpr/src/resolver/osv.rs[342-363]
- pnpr/crates/pnpr/src/resolver/osv.rs[384-413]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Null withdrawn drops advisories ✓ Resolved 🐞 Bug ⛨ Security
Description
OSV record ingestion treats any present withdrawn field as withdrawn, even if it is JSON null,
which can incorrectly skip active advisories and allow vulnerable versions to be treated as safe.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R348-352]

+    let record: OsvRecord = serde_json::from_slice(bytes)
+        .map_err(|err| invalid_config(format!("failed to parse OSV record: {err}")))?;
+    if record.withdrawn.is_some() {
+        return Ok(());
+    }
Evidence
withdrawn is an Option, so a JSON null is still Some(...). The ingestion logic then uses
is_some() to decide to skip the record, which incorrectly treats withdrawn: null as withdrawn.

pnpr/crates/pnpr/src/resolver/osv.rs[191-199]
pnpr/crates/pnpr/src/resolver/osv.rs[344-352]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ingest_record_bytes` skips an OSV record whenever `withdrawn` deserializes as `Some(_)`. Because `withdrawn` is typed as `Option<serde_json::Value>`, an explicit JSON `null` becomes `Some(Value::Null)`, causing the record to be dropped even though it is not meaningfully withdrawn.
### Issue Context
This code gates resolver-time blocking and lockfile verification when OSV is enabled. Incorrectly skipping advisories weakens the protection.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[191-199]
- pnpr/crates/pnpr/src/resolver/osv.rs[344-366]
### Suggested fix
- Change the check to only skip when `withdrawn` is present *and non-null*, e.g.:
- `if record.withdrawn.as_ref().is_some_and(|v| !v.is_null()) { ... }`
- Or model it more strictly (preferred): `withdrawn: Option<String>` (or `Option<DateTime<...>>`) and skip only on `Some(_)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. OSV fingerprint TOCTOU ✓ Resolved 🐞 Bug ⛨ Security
Description
load_from_zip() hashes the OSV zip via one open and then re-opens the path to parse it; if the DB
is replaced between those operations, the in-memory index can contain advisories from different
bytes than the stored fingerprint. Because the verdict cache trusts/records this fingerprint, a
startup-time swap can make pnpr incorrectly trust cached “clean” lockfile verdicts under a changed
vulnerability dataset.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R234-241]

+fn load_from_zip(path: &Path) -> Result<OsvIndex, RegistryError> {
+    let fingerprint = file_fingerprint(path)?;
+    let file = File::open(path).map_err(|err| {
+        invalid_config(format!("failed to open OSV database {}: {err}", path.display()))
+    })?;
+    let mut archive = zip::ZipArchive::new(file).map_err(|err| {
+        invalid_config(format!("failed to read OSV zip {}: {err}", path.display()))
+    })?;
Evidence
The zip loader fingerprints the OSV DB via file_fingerprint(path) and then re-opens path for
ZipArchive, so the fingerprint can be computed from different bytes than the parsed archive if the
file is swapped between opens; this fingerprint is then used to trust/record verdict-cache policy.

pnpr/crates/pnpr/src/resolver/osv.rs[234-266]
pnpr/crates/pnpr/src/resolver/osv.rs[311-330]
pnpr/crates/pnpr/src/resolver.rs[737-755]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`load_from_zip()` computes `fingerprint` from a separate file open than the one used to build `ZipArchive`. If the OSV zip is atomically replaced between these two opens, `OsvIndex.fingerprint` can describe different bytes than the advisories actually loaded. Since this fingerprint is used in `can_trust_policy()` and is recorded into the verdict cache policy snapshot, a mismatch can cause cached lockfile verifications to be trusted under the wrong vulnerability dataset.
### Issue Context
- OSV zip path updates are plausibly done via atomic rename/replace (common for DB refresh jobs).
- Verdict-cache trust explicitly depends on `OsvIndex::can_trust_policy()`.
### Fix
Open the zip file **once**, compute the fingerprint from that same handle (read/seek back to start), then pass the same handle into `ZipArchive::new(...)`. This removes the TOCTOU window where the path can be swapped between fingerprinting and parsing.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[234-266]
- pnpr/crates/pnpr/src/resolver/osv.rs[311-330]
- pnpr/crates/pnpr/src/resolver.rs[737-755]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. OSV path can hang ✓ Resolved 🐞 Bug ⛨ Security
Description
When osv.enabled is set, OsvIndex::load_from_config only checks path.exists() and then treats
any non-directory path as a zip, so pointing osv.path at a FIFO/device/socket can block
indefinitely during File::open() / file_fingerprint() reads at startup. This is a startup-time
DoS surface (and a severe reliability footgun) because pnpr loads the OSV DB before binding the
listen socket.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R33-61]

+    pub(crate) fn load_from_config(config: &Config) -> Result<Option<Arc<Self>>, RegistryError> {
+        if !config.osv.enabled {
+            return Ok(None);
+        }
+        let path = config.osv.path.clone().unwrap_or_else(|| default_osv_path(config));
+        if !path.exists() {
+            return Err(invalid_config(format!(
+                "OSV is enabled but database {} does not exist; download the npm OSV dump to this path or set osv.path",
+                path.display(),
+            )));
+        }
+        let index = Self::load_from_path(&path)?;
+        // An enabled-but-empty index would make the guard a no-op and
+        // silently disable enforcement, so treat it as misconfiguration.
+        if index.packages.is_empty() {
+            return Err(invalid_config(format!(
+                "OSV is enabled but database {} yielded no npm advisories; check that the path points at the npm OSV dump",
+                path.display(),
+            )));
+        }
+        Ok(Some(Arc::new(index)))
+    }
+
+    pub(crate) fn load_from_path(path: &Path) -> Result<Self, RegistryError> {
+        if path.is_dir() {
+            return load_from_directory(path);
+        }
+        load_from_zip(path)
+    }
Evidence
The OSV loader gates only on exists() and then reads the path as a zip when it is not a directory;
the fingerprinting helper reads until EOF, which can block on FIFOs/special files. pnpr invokes this
loader during startup before binding the listener, so a block prevents service availability.

pnpr/crates/pnpr/src/resolver/osv.rs[33-61]
pnpr/crates/pnpr/src/resolver/osv.rs[234-266]
pnpr/crates/pnpr/src/resolver/osv.rs[311-330]
pnpr/crates/pnpr/src/server.rs[270-282]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When OSV is enabled, the configured `osv.path` is only validated with `Path::exists()`. If it points to a special file (FIFO, device node, socket) or otherwise non-regular path, `File::open()` and/or the subsequent read loop used for fingerprinting may block indefinitely, preventing pnpr from starting.
### Issue Context
pnpr loads the OSV database at startup (before binding the server socket). The loader currently treats any existing non-directory path as a zip and streams it to EOF for fingerprinting.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[33-61]
- pnpr/crates/pnpr/src/resolver/osv.rs[234-266]
- pnpr/crates/pnpr/src/resolver/osv.rs[311-330]
- pnpr/crates/pnpr/src/server.rs[270-282]
### Implementation notes
- In `OsvIndex::load_from_path` (or earlier in `load_from_config`), validate the path type:
- if `path.is_dir()` => directory loader
- else if `path.is_file()` => zip loader
- else => return `InvalidConfig` explaining the OSV DB must be a regular file or directory
- This avoids blocking on FIFOs/sockets/devices and makes misconfiguration fail fast with a clear error.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Guard rejection misreported ✓ Resolved 🐞 Bug ≡ Correctness
Description
When PackageVersionGuard rejects all candidate versions, pick_from_registry_with_guard returns
Ok(None), which the resolver chain interprets as “this resolver does not handle this dependency” and
raises SpecNotSupported. This misreports an OSV/policy block (or “no acceptable version”) as an
unsupported spec and can change fallback behavior and user-facing diagnostics.
Code

pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs[R555-575]

+        let version_str = version.version.to_string();
+        match guard.check(&opts.spec.name, &version_str).await? {
+            PackageVersionGuardDecision::Allow => {
+                return Ok(Some(PickedFromRegistry { meta: pick_result.meta, version }));
+            }
+            PackageVersionGuardDecision::Reject { reason } => {
+                tracing::debug!(
+                    target: "pacquet_resolving_npm_resolver",
+                    name = %opts.spec.name,
+                    version = %version_str,
+                    reason,
+                    "package version rejected by resolver guard",
+                );
+                // A `false` return means the picker re-selected a version we
+                // already blocked, so filtering can't exclude it (e.g. the
+                // parsed version string doesn't match the packument key).
+                // Stop rather than loop forever, treating the package as
+                // having no acceptable version.
+                if !blocked_versions.insert(version_str) {
+                    return Ok(None);
+                }
Evidence
The new guard path returns Ok(None) when a rejected version is re-selected (or when no acceptable
version remains), but the resolver contract defines Ok(None) as “resolver does not handle this dep”;
the deps-resolver maps that to SpecNotSupported, yielding a misleading error for guard-driven
rejection.

pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs[526-575]
pacquet/crates/resolving-resolver-base/src/resolve.rs[461-489]
pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1823-1831]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pick_from_registry_with_guard()` returns `Ok(None)` when the guard keeps rejecting candidates until nothing acceptable remains. In the resolver-chain contract, `Ok(None)` means “I don’t claim this dependency”, and the deps-resolver converts that into `SpecNotSupported`, which is misleading for OSV/policy blocks.
### Issue Context
- Resolver contract: `Ok(None)` = defer to next resolver.
- deps-resolver treats `None` as “specifier not supported”.
- The guard feature increases how often “no acceptable version remains” can happen even for otherwise supported npm specs.
### Fix Focus Areas
- pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs[526-579]
- pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs[1823-1831]
- pacquet/crates/resolving-resolver-base/src/resolve.rs[461-489]
### What to change
- Introduce a concrete error for “no acceptable versions remain after applying guard” (and ideally for “no matching version” generally) and return `Err(ResolveError)` instead of `Ok(None)` in that case.
- Preserve `Ok(None)` only for truly “unsupported spec/protocol” situations (parse failures, workspace-path fallthrough, etc.).
- Optionally include the last rejection `reason` in the error message/code so clients can tell this was blocked by OSV/policy rather than “unsupported”.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Empty OSV DB accepted ✓ Resolved 🐞 Bug ⛨ Security
Description
When osv.enabled is true, loading an OSV zip/directory can succeed with an empty packages map
(e.g. wrong directory/zip contents, only withdrawn/non-npm records), which makes the guard always
return Allow and silently disables the intended vulnerability blocking. This creates a false sense
of protection because pnpr starts normally and both resolution-time and lockfile checks will report
no OSV violations.
Code

pnpr/crates/pnpr/src/resolver/osv.rs[R212-235]

+fn load_from_zip(path: &Path) -> Result<OsvIndex, RegistryError> {
+    let fingerprint = file_fingerprint(path)?;
+    let file = File::open(path).map_err(|err| {
+        invalid_config(format!("failed to open OSV database {}: {err}", path.display()))
+    })?;
+    let mut archive = zip::ZipArchive::new(file).map_err(|err| {
+        invalid_config(format!("failed to read OSV zip {}: {err}", path.display()))
+    })?;
+    let mut packages = HashMap::new();
+    for index in 0..archive.len() {
+        let mut entry = archive.by_index(index).map_err(|err| {
+            invalid_config(format!("failed to read OSV zip entry in {}: {err}", path.display()))
+        })?;
+        if !entry.is_file() || !entry.name().ends_with(".json") {
+            continue;
+        }
+        let mut bytes = Vec::with_capacity(entry.size().min(1024 * 1024) as usize);
+        entry.read_to_end(&mut bytes).map_err(|err| {
+            invalid_config(format!("failed to read OSV zip entry {}: {err}", entry.name()))
+        })?;
+        ingest_record_bytes(&mut packages, &bytes)?;
+    }
+    Ok(OsvIndex { packages, fingerprint })
+}
Evidence
Both zip and directory loaders return an index even if no advisories are ingested, and the guard’s
decision path explicitly returns Allow when no vulnerability IDs are found (which will be the case
for all packages if the index is empty).

pnpr/crates/pnpr/src/resolver/osv.rs[212-265]
pnpr/crates/pnpr/src/resolver/osv.rs[65-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `osv.enabled = true`, `OsvIndex::load_from_path` returns `Ok(OsvIndex { packages, ... })` even when no npm advisories were ingested. Because `vulnerability_ids()` then returns an empty list for every lookup, the guard always yields `Allow`, disabling OSV enforcement silently.
## Issue Context
This is a security control; when enabled, it should either enforce or fail loudly. An empty/ineffective DB should be treated as invalid configuration (or at minimum an explicit degraded-mode warning + refusal to enable enforcement).
## Fix Focus Areas
- pnpr/crates/pnpr/src/resolver/osv.rs[212-265]
- pnpr/crates/pnpr/src/resolver/osv.rs[65-87]
- pnpr/crates/pnpr/src/resolver/osv.rs[26-39]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pnpr/crates/pnpr/src/resolver.rs Outdated
Comment thread pacquet/crates/resolving-npm-resolver/src/pick_package.rs
Comment thread pnpr/crates/pnpr/src/server.rs Outdated
@codecov-commenter

codecov-commenter commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.20334% with 170 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.92%. Comparing base (8c3a372) to head (8f20d3e).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
pnpr/crates/pnpr/src/resolver/osv.rs 71.91% 91 Missing ⚠️
pnpr/crates/pnpr/src/resolver.rs 52.63% 54 Missing ⚠️
.../crates/resolving-npm-resolver/src/npm_resolver.rs 92.30% 6 Missing ⚠️
pnpr/crates/pnpr/src/main.rs 0.00% 6 Missing ⚠️
.../crates/resolving-npm-resolver/src/pick_package.rs 90.56% 5 Missing ⚠️
pnpr/crates/pnpr/src/server.rs 82.14% 5 Missing ⚠️
.../crates/package-manager/src/resolution_observer.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12506      +/-   ##
==========================================
- Coverage   88.14%   87.92%   -0.23%     
==========================================
  Files         314      315       +1     
  Lines       43559    44138     +579     
==========================================
+ Hits        38397    38809     +412     
- Misses       5162     5329     +167     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pacquet/crates/package-manager/src/add.rs (1)

411-428: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Apply the version guard before serializing add’s manifest specifier.

This direct pick_package path always uses blocked_versions: None. In an OSV-enabled add flow, a user range like acme@^1.0.0 can pre-pick a rejected 1.1.0 and serialize ^1.1.0; the later guarded install then cannot repick a safe 1.0.0 that satisfied the original range. Thread the same PackageVersionGuard into this pre-resolution path, or route this pick through the guarded retry loop before picked.serialize(pin).

Based on PR objectives, the resolver-time guard is intended to reject vulnerable versions while preserving normal repick semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pacquet/crates/package-manager/src/add.rs` around lines 411 - 428, The
PickPackageOptions struct passed to pick_package has blocked_versions hardcoded
to None, which prevents vulnerable versions from being blocked during the
picking phase. Update the blocked_versions field in the PickPackageOptions
initialization to pass the appropriate PackageVersionGuard information instead
of None, ensuring that version rejection is applied before the spec is
serialized. This will ensure that rejected/vulnerable versions are not picked
and serialized into the manifest, preventing the issue where later guarded
install processes cannot repick valid versions that satisfy the original
user-specified range.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs`:
- Around line 235-251: The test function
package_version_guard_repopulates_latest_tag claims to validate latest-tag
repopulation based on its name and intent, but the current assertion only
verifies the repicked version through
result.name_ver.as_ref().expect("name_ver").suffix.to_string(). Add an explicit
assertion on the result.latest field to actually verify that the latest tag was
repopulated, ensuring the test validates the behavior it claims to test and will
fail if latest-tag rewriting regresses.

In `@pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs`:
- Around line 415-418: The documentation comment for the dist-tag repopulation
function incorrectly states that removed tag targets are rewritten "in the same
family," but the actual implementation at line 472 only applies the major-family
guard to non-latest tags while allowing the latest tag to move across major
versions. Update the doc comment to accurately reflect this behavior by
clarifying that tags whose target was removed are rewritten to the best
remaining version, with the same-family constraint applying only to non-latest
tags, while latest tags can move across major versions to find the best
remaining version.

In `@pnpr/crates/pnpr/src/resolver.rs`:
- Around line 283-292: The code performs an expensive O(packages) OSV scan on
every in-memory resolution-cache hit via osv_violations_for_lockfile even though
the runtime.osv_index is immutable and cached lockfiles are only stored after
passing OSV checks. Remove the unnecessary OSV re-scan in this warm cache path
by eliminating the osv_clean check and its associated
osv_violations_for_lockfile call, since a cache hit already implies the lockfile
is OSV-clean. Simply return the ndjson_single_frame with done_frame directly
without performing the OSV verification again.

In `@pnpr/crates/pnpr/src/resolver/osv.rs`:
- Around line 313-346: The functions `semver_event_from_osv`,
`semver_range_from_osv`, and `parse_osv_version` currently use `Option` types
with `filter_map`, which silently discards invalid OSV data instead of failing.
Change these functions to return `Result` types that propagate errors instead of
silently filtering invalid events. Specifically, update `parse_osv_version` to
return a Result, then modify `semver_event_from_osv` to return a Result and
propagate parse errors from the introduced, fixed, last_affected, and limit
fields. Update `semver_range_from_osv` to return a Result and replace the
`filter_map` with a Result-aware operation that fails if any event is invalid.
Finally, propagate the Result type through the calling functions in
`ingest_record_bytes` so that malformed OSV data returns an appropriate error
instead of silently being ignored.
- Around line 228-232: The entry.read_to_end() call in the OSV zip entry reading
block does not enforce an upper bound on the actual amount of data read, only
initial capacity allocation. Add a maximum size limit (such as 1MB or
appropriate for your use case) and enforce it before or during the read
operation. Either check the entry's reported size against your limit and reject
oversized entries before attempting to read them, or wrap the reader with take()
to limit the bytes read to the maximum allowed. This prevents memory and CPU
exhaustion from maliciously crafted or oversized entries in attacker-controlled
OSV zip files.

---

Outside diff comments:
In `@pacquet/crates/package-manager/src/add.rs`:
- Around line 411-428: The PickPackageOptions struct passed to pick_package has
blocked_versions hardcoded to None, which prevents vulnerable versions from
being blocked during the picking phase. Update the blocked_versions field in the
PickPackageOptions initialization to pass the appropriate PackageVersionGuard
information instead of None, ensuring that version rejection is applied before
the spec is serialized. This will ensure that rejected/vulnerable versions are
not picked and serialized into the manifest, preventing the issue where later
guarded install processes cannot repick valid versions that satisfy the original
user-specified range.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 390e8d78-72e8-4995-aa85-208904e34823

📥 Commits

Reviewing files that changed from the base of the PR and between c12a15f and e27a587.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (21)
  • pacquet/crates/package-manager/src/add.rs
  • pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs
  • pacquet/crates/package-manager/src/resolution_observer.rs
  • pacquet/crates/resolving-npm-resolver/src/named_registry_resolver.rs
  • pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
  • pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs
  • pacquet/crates/resolving-npm-resolver/src/pick_package.rs
  • pacquet/crates/resolving-npm-resolver/src/pick_package/tests.rs
  • pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs
  • pacquet/crates/resolving-resolver-base/src/lib.rs
  • pacquet/crates/resolving-resolver-base/src/resolve.rs
  • pnpr/crates/pnpr/Cargo.toml
  • pnpr/crates/pnpr/src/config.rs
  • pnpr/crates/pnpr/src/config/tests.rs
  • pnpr/crates/pnpr/src/lib.rs
  • pnpr/crates/pnpr/src/main.rs
  • pnpr/crates/pnpr/src/resolver.rs
  • pnpr/crates/pnpr/src/resolver/osv.rs
  • pnpr/crates/pnpr/src/resolver/osv/tests.rs
  • pnpr/crates/pnpr/src/resolver/tests.rs
  • pnpr/crates/pnpr/src/server.rs

Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver/tests.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/pick_package_from_meta.rs Outdated
Comment thread pnpr/crates/pnpr/src/resolver.rs Outdated
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs Outdated
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e27a587

@zkochan

zkochan commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Review triage (CodeRabbit + Qodo)

Pushed four follow-up commits and replied on each inline thread. Summary:

Fixed

  • Bound OSV record reads + reject empty database (a094ba20a5) — 16 MiB cap per zip entry / directory file with a Read::take bound, and an enabled-but-empty index now fails closed instead of silently disabling the guard (+ regression test). Covers CodeRabbit's zip-read finding and Qodo's "unbounded read" / "empty osv db accepted".
  • Drop redundant OSV rescan on resolution-cache hits (cd4a4165a4) — the index is immutable for the resolver's lifetime and entries are only cached after passing OSV, so a hit is already clean. Covers the matching CodeRabbit + Qodo perf findings.
  • Dist-tag repopulation doc (e8cb11fb72) and latest-tag test assertion (c5ff93725d).
  • The blocked-version key-mismatch loop (Qodo) was already addressed in add003fd8c, which terminates the re-pick loop fail-safe instead of hanging.

Declined (rationale on each thread)

  • Packument clone on guard re-pick (rare, opt-in, shallow Arc clone).
  • router_with_auth panic on invalid OSV config (production paths fail gracefully; the panic prevents a silent enforcement-disabled router).
  • Fail-closed on every unparseable OSV semver event (real npm dumps contain non-semver versions; aborting startup on one is worse, and the skip direction is mostly over-blocking).
  • Zip fingerprint double-read (one-time startup cost, not a request path).

Acknowledged follow-up (out of scope here)

  • CodeRabbit's add.rs note: under an OSV-enabled server, pnpm add foo@^1.0.0 can pre-pick and serialize ^1.1.0 from the unguarded client-side manifest pin, after which a guarded install cannot fall back to a safe 1.0.0. The guard currently lives server-side in pnpr and is not plumbed into pacquet's client add pre-resolution at all, so closing this gap is a separate design change (sharing the OSV policy with the client pin path) rather than a one-line fix. Worth a tracked follow-up.

Written by an agent (Claude Code, claude-opus-4-8).

@github-actions github-actions Bot added the reviewed: coderabbit CodeRabbit submitted an approving review label Jun 19, 2026
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c5ff937

Comment thread pnpr/crates/pnpr/src/resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c5ff937

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8c9615d

Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8c9615d

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ef2c499

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 293ef9a

zkochan added 2 commits June 20, 2026 22:01
`load_from_directory` checked `entry_path.is_file()` on the path, then
opened and read it — a concurrent swap to a FIFO/socket in that window
could make `read_to_end` block startup. Verify the opened handle's
metadata is a regular file before reading.

Written by an agent (Claude Code, claude-opus-4-8).
Each guard rejection re-runs the picker over the packument, so an
unbounded run is O(versions²). Cap rejections at 1000 — far beyond any
realistic run of consecutive blocked versions — so a hostile packument
can't turn the guard loop into a resolver-time CPU/memory spike; hitting
the cap surfaces as "no acceptable version".

Written by an agent (Claude Code, claude-opus-4-8).
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0fbc966

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0fbc966

Two untrusted strings from the OSV database were unbounded:

- zip entry names were cloned into errors/fingerprint with no length
  check, so a crafted zip could force large startup allocations. Reject
  entry names over 4 KiB.
- advisory ids (up to the per-record byte cap) were stored verbatim and
  fed into guard/violation reason strings, amplifying memory and NDJSON
  payloads. Truncate stored ids to 256 bytes (the count cap already
  bounds how many appear in a reason).

Written by an agent (Claude Code, claude-opus-4-8).
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c6be244

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c6be244

`load_from_directory` checked `is_file()` on the path and then opened it;
a concurrent swap of the path to a FIFO after the check made the open
itself (O_RDONLY) block until a writer appeared, hanging pnpr before it
binds its listen socket. Open with `O_NONBLOCK` on unix so the open
can't block, then keep the existing regular-file check on the handle
before reading.

Written by an agent (Claude Code, claude-opus-4-8).
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 902b75d

Comment thread pnpr/crates/pnpr/src/resolver.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/pick_package.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 902b75d

…er repick-cap error

- Reject OSV `affected` entries whose `versions`/`ranges`/`events` counts
  exceed generous limits, so a crafted record can't expand into a huge
  persistent set in the index despite the per-record byte cap.
- When the guard re-pick safety cap is hit, return a dedicated
  `GuardRepickLimitError` instead of `AllVersionsBlockedError` — the cap
  is a cutoff, not proof that every matching version is blocked, so the
  message now says so.

Written by an agent (Claude Code, claude-opus-4-8).
Comment thread pnpr/crates/pnpr/src/resolver.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/pick_package.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 5e733b4

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 20, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

…ross-check

`tarball_url_version` only recognized a lowercase `.tgz` suffix, so under
`trustLockfile` a tampered lockfile could point the tarball at a
vulnerable artifact named `.TGZ` or `.tar.gz` and skip the URL-version
OSV cross-check. Strip `.tgz`/`.tar.gz` case-insensitively.

Written by an agent (Claude Code, claude-opus-4-8).
Comment thread pnpr/crates/pnpr/src/resolver.rs
Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
Comment thread pacquet/crates/resolving-npm-resolver/src/npm_resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8f20d3e

Comment thread pnpr/crates/pnpr/src/resolver/osv.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8f20d3e

@zkochan zkochan merged commit 0d24a60 into main Jun 20, 2026
41 checks passed
@zkochan zkochan deleted the pnpr-sec branch June 20, 2026 22:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

product: pacquet product: pnpr reviewed: coderabbit CodeRabbit submitted an approving review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants