Skip to content

Commit d72c9ed

Browse files
committed
fix(cli): validate .npm-extension hash in npm ci without executing it
1 parent 4f84f56 commit d72c9ed

4 files changed

Lines changed: 65 additions & 18 deletions

File tree

lib/commands/ci.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ class CI extends ArboristWorkspaceCmd {
6666
save: false, // npm ci should never modify the lockfile or package.json
6767
workspaces: this.workspaceNames,
6868
allowScripts: allowScriptsPolicy,
69+
// npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension.
70+
// The extension file hash is still validated below, independent of execution.
71+
ignoreExtension: true,
6972
}
7073

7174
// generate an inventory from the virtual tree in the lockfile
@@ -93,7 +96,15 @@ class CI extends ArboristWorkspaceCmd {
9396
// Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree.
9497
errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree))
9598
// Verifies that the root .npm-extension file matches the lockfile hash.
96-
errors.push(...validateNpmExtension(virtualArb.virtualTree, arb.idealTree))
99+
// The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts.
100+
const { NpmExtension } = require('@npmcli/arborist')
101+
let fileHash = null
102+
try {
103+
fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash
104+
} catch (err) {
105+
errors.push(`Invalid: ${err.message}`)
106+
}
107+
errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash))
97108
if (errors.length) {
98109
throw this.usageError(
99110
'`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' +

lib/utils/validate-lockfile.js

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,23 +98,31 @@ function validatePackageExtensions (virtualTree, idealTree) {
9898
return errors
9999
}
100100

101-
// validates that the root .npm-extension state recorded in the lockfile still matches the selected extension file.
101+
// validates that the .npm-extension state recorded in the lockfile still matches the selected extension file.
102102
// Validation is hash-based: arbitrary code has no selector to re-check, so a matching hash is trusted and a mismatch fails.
103+
// fileHash is computed from the on-disk file (discovery only, no execution), so this holds even under ignore-extension/ignore-scripts.
104+
// The lockfile carries extension state if it records a root hash or any per-package npmExtensionApplied provenance.
103105
// Returns an array of human-readable error strings, empty when valid.
104-
function validateNpmExtension (virtualTree, idealTree) {
105-
const lockHash = virtualTree.meta?.npmExtensionHash || null
106-
const idealHash = idealTree.meta?.npmExtensionHash || null
106+
function validateNpmExtension (virtualTree, fileHash) {
107+
const lockHash = virtualTree?.meta?.npmExtensionHash || null
108+
const hasProvenance = !!virtualTree &&
109+
[...virtualTree.inventory.values()].some(node => node.npmExtensionApplied)
110+
fileHash = fileHash || null
107111

108-
if (idealHash === lockHash) {
112+
if (fileHash) {
113+
if (!lockHash) {
114+
return ['Missing: .npm-extension state from lock file']
115+
}
116+
if (lockHash !== fileHash) {
117+
return ['Invalid: .npm-extension file does not match the lock file']
118+
}
109119
return []
110120
}
111-
if (idealHash && !lockHash) {
112-
return ['Missing: .npm-extension state from lock file']
113-
}
114-
if (!idealHash && lockHash) {
121+
// no extension file present
122+
if (lockHash || hasProvenance) {
115123
return ['Invalid: lock file records .npm-extension state but no .npm-extension file is present']
116124
}
117-
return ['Invalid: .npm-extension file does not match the lock file']
125+
return []
118126
}
119127

120128
module.exports = validateLockfile

test/lib/commands/ci.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ t.test('fails when packageExtensions are out of sync with the lock file', async
139139
)
140140
})
141141

142+
t.test('fails when both .npm-extension files are present', async t => {
143+
const { npm } = await loadMockNpm(t, {
144+
config: { audit: false },
145+
prefixDir: {
146+
abbrev,
147+
'package.json': JSON.stringify(packageJson),
148+
'package-lock.json': JSON.stringify(packageLock),
149+
'.npm-extension.mjs': 'export function transformManifest (p) { return p }\n',
150+
'.npm-extension.cjs': 'module.exports = { transformManifest (p) { return p } }\n',
151+
},
152+
})
153+
await t.rejects(
154+
npm.exec('ci', []),
155+
/keep only one/,
156+
'ci surfaces the ambiguous extension file error'
157+
)
158+
})
159+
142160
t.test('--no-audit and --ignore-scripts', async t => {
143161
const { npm, joinedOutput, registry } = await loadMockNpm(t, {
144162
config: {

test/lib/utils/validate-lockfile.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,28 +212,38 @@ t.test('missing virtualTree inventory', async t => {
212212

213213
const { validateNpmExtension } = require('../../../lib/utils/validate-lockfile.js')
214214

215-
// build mock virtual/ideal trees for validateNpmExtension (hash-only)
216-
const extTree = (hash) => ({ meta: { npmExtensionHash: hash } })
215+
// build a mock virtual tree with a root hash and optional provenance-bearing nodes
216+
const extVirtual = (hash, nodes = []) => ({
217+
meta: { npmExtensionHash: hash },
218+
inventory: { values: () => nodes },
219+
})
217220

218221
t.test('npmExtension: matching hashes', async t => {
219-
t.strictSame(validateNpmExtension(extTree('h'), extTree('h')), [], 'no errors when hashes match')
222+
t.strictSame(validateNpmExtension(extVirtual('h'), 'h'), [], 'no errors when hashes match')
220223
})
221224

222225
t.test('npmExtension: both absent', async t => {
223-
t.strictSame(validateNpmExtension(extTree(null), extTree(null)), [], 'no errors when both absent')
226+
t.strictSame(validateNpmExtension(extVirtual(null), null), [], 'no errors when both absent')
224227
})
225228

226229
t.test('npmExtension: missing from lock file', async t => {
227-
const errors = validateNpmExtension(extTree(null), extTree('h'))
230+
const errors = validateNpmExtension(extVirtual(null), 'h')
228231
t.match(errors[0], /Missing: \.npm-extension state from lock file/, 'reports missing lock state')
229232
})
230233

231234
t.test('npmExtension: present in lock but no file', async t => {
232-
const errors = validateNpmExtension(extTree('h'), extTree(null))
235+
const errors = validateNpmExtension(extVirtual('h'), null)
233236
t.match(errors[0], /records \.npm-extension state but no \.npm-extension file is present/, 'reports stray lock state')
234237
})
235238

236239
t.test('npmExtension: hash mismatch', async t => {
237-
const errors = validateNpmExtension(extTree('h1'), extTree('h2'))
240+
const errors = validateNpmExtension(extVirtual('h1'), 'h2')
238241
t.match(errors[0], /\.npm-extension file does not match the lock file/, 'reports a hash mismatch')
239242
})
243+
244+
t.test('npmExtension: provenance without a root hash and no file fails', async t => {
245+
const nodes = [{ name: 'foo', npmExtensionApplied: { extensionPoint: 'transformManifest', dependencies: ['bar'] } }]
246+
const errors = validateNpmExtension(extVirtual(null, nodes), null)
247+
t.match(errors[0], /records \.npm-extension state but no \.npm-extension file is present/,
248+
'per-node provenance counts as extension state even without a root hash')
249+
})

0 commit comments

Comments
 (0)