Skip to content

Commit 989f571

Browse files
fix(arborist): audit the non-isolated tree under the linked strategy (#9625)
In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](WordPress/gutenberg#75814), which Under `install-strategy=linked`, `npm install --audit` reported `found 0 vulnerabilities` even with a known-vulnerable package installed, while standalone `npm audit` reported it correctly. Only the install-time audit was affected. ## Why A linked reify swaps `idealTree` for the isolated tree (`createIsolatedTree()`) before the quick audit runs, so `_submitQuickAudit()` audited the isolated tree. That tree cannot be audited: its inventory had a stub `query()` that always returned `[]`, and its edges route through symlink `Link`s instead of real package nodes. So `AuditReport.prepareBulkData()` produced an empty bulk request and the registry was never asked about any installed version. Standalone `npm audit` was unaffected because it audits the regular tree loaded from the lockfile. ## How `reify.js` stashes the original non-isolated ideal tree in `#linkedIdealForAudit` during the linked swap, and `_submitQuickAudit()` now audits `this.#linkedIdealForAudit || this.idealTree` — the same tree standalone `npm audit` uses, with a queryable inventory and real package nodes. The `_diffTrees()`/`#reifyPackages()`/orphan-sweep block is wrapped in `try/finally` that restores `idealTree` and clears the stashed references even if reify throws, so a reused Arborist never audits or diffs a stale isolated tree. `isolated-classes.js` drops the now-unused `IsolatedInventory` class (its only caller was the rerouted audit path) in favor of a plain `Map`; the `query()` stub returning `[]` was the silent-empty behavior behind this bug. ## References Fixes #9609 Part of #9608
1 parent 971500f commit 989f571

3 files changed

Lines changed: 40 additions & 20 deletions

File tree

workspaces/arborist/lib/arborist/reify.js

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ module.exports = cls => class Reifier extends cls {
7777
#sparseTreeDirs = new Set()
7878
#sparseTreeRoots = new Set()
7979
#linkedActualForDiff = null
80+
// Under the linked strategy the audit runs against this non-isolated ideal tree.
81+
// The isolated tree's inventory has no queryable indexes and its edges route through symlinks, so auditing it reports no vulnerabilities.
82+
#linkedIdealForAudit = null
8083

8184
constructor (options) {
8285
super(options)
@@ -123,21 +126,29 @@ module.exports = cls => class Reifier extends cls {
123126
this.idealTree, this.actualTree
124127
)
125128
}
129+
// Keep the non-isolated tree so the quick audit can run against it.
130+
this.#linkedIdealForAudit = oldTree
126131
}
127-
await this[_diffTrees]()
128-
await this.#reifyPackages()
129-
if (linked) {
130-
// The sweep mutates node_modules on disk, so skip it for dry runs and lockfile-only installs (those modes also short-circuit #reifyPackages).
131-
// The sweep itself scopes to in-filter workspaces when a filter is active, so it's safe to run for filtered installs too.
132-
if (!this.options.dryRun && !this.options.packageLockOnly) {
133-
await this.#cleanOrphanedStoreEntries()
132+
try {
133+
await this[_diffTrees]()
134+
await this.#reifyPackages()
135+
if (linked) {
136+
// The sweep mutates node_modules on disk, so skip it for dry runs and lockfile-only installs (those modes also short-circuit #reifyPackages).
137+
// The sweep itself scopes to in-filter workspaces when a filter is active, so it's safe to run for filtered installs too.
138+
if (!this.options.dryRun && !this.options.packageLockOnly) {
139+
await this.#cleanOrphanedStoreEntries()
140+
}
141+
}
142+
} finally {
143+
// Restore the non-isolated tree so the lockfile is preserved and a reused Arborist never sees the isolated tree, even if reify throws.
144+
if (linked) {
145+
this.idealTree = oldTree
134146
}
135-
// swap back in the idealTree
136-
// so that the lockfile is preserved
137-
this.idealTree = oldTree
147+
// The quick audit has captured its tree synchronously by now, so drop the stashed references even on throw.
148+
this.#linkedIdealForAudit = null
149+
this.#linkedActualForDiff = null
138150
}
139151
await this[_saveIdealTree](options)
140-
this.#linkedActualForDiff = null
141152
// clean inert
142153
for (const node of this.idealTree.inventory.values()) {
143154
if (node.inert) {
@@ -1169,7 +1180,8 @@ module.exports = cls => class Reifier extends cls {
11691180
// with the reification, and be resolved at a later time.
11701181
const timeEnd = time.start('reify:audit')
11711182
const options = { ...this.options }
1172-
const tree = this.idealTree
1183+
// Under the linked strategy idealTree is the isolated tree, which the audit cannot traverse; audit the non-isolated tree instead.
1184+
const tree = this.#linkedIdealForAudit || this.idealTree
11731185

11741186
// if we're operating on a workspace, only audit the workspace deps
11751187
if (this.options.workspaces.length) {

workspaces/arborist/lib/isolated-classes.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@
22
const CaseInsensitiveMap = require('./case-insensitive-map.js')
33
const { resolve } = require('node:path')
44

5-
// fake lib/inventory.js
6-
class IsolatedInventory extends Map {
7-
query () {
8-
return []
9-
}
10-
}
11-
125
// fake lib/node.js
136
class IsolatedNode {
147
binPaths = []
@@ -17,7 +10,7 @@ class IsolatedNode {
1710
edgesOut = new CaseInsensitiveMap()
1811
fsChildren = new Set()
1912
integrity = null
20-
inventory = new IsolatedInventory()
13+
inventory = new Map()
2114
isInStore = false
2215
inBundle = false
2316
isRegistryDependency = false

workspaces/arborist/test/arborist/reify.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,21 @@ t.test('packageLockOnly with linked strategy in workspaces', async t => {
223223
t.throws(() => fs.statSync(path + '/node_modules'), { code: 'ENOENT' })
224224
})
225225

226+
t.test('linked strategy audits the non-isolated tree', async t => {
227+
// The isolated tree has no queryable inventory, so auditing it reports nothing. The install-time audit must run against the non-isolated tree, matching standalone npm audit. https://github.com/npm/cli/issues/9609
228+
const src = resolve(fixtures, 'audit-one-vuln')
229+
// Copy into a throwaway dir since packageLockOnly rewrites the lockfile.
230+
const path = t.testdir({
231+
'package.json': fs.readFileSync(join(src, 'package.json'), 'utf8'),
232+
'package-lock.json': fs.readFileSync(join(src, 'package-lock.json'), 'utf8'),
233+
})
234+
const registry = createRegistry(t, true)
235+
registry.audit({ convert: true, results: require(join(src, 'audit.json')) })
236+
const arb = newArb({ path, audit: true, packageLockOnly: true, installStrategy: 'linked' })
237+
await arb.reify()
238+
t.ok(arb.auditReport.has('minimist'), 'vulnerable package reported under linked strategy')
239+
})
240+
226241
t.test('malformed package.json should not be overwritten', async t => {
227242
t.plan(2)
228243

0 commit comments

Comments
 (0)