@dependably/npm-check 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/cli.js +1577 -0
- package/package.json +103 -0
- package/src/audit-config.js +331 -0
- package/src/audit.js +805 -0
- package/src/backup.js +235 -0
- package/src/checker.js +771 -0
- package/src/checksum-fixer.js +419 -0
- package/src/deprecation.js +325 -0
- package/src/fixer.js +207 -0
- package/src/format-library.js +132 -0
- package/src/index.js +138 -0
- package/src/integrity.js +519 -0
- package/src/migrator.js +263 -0
- package/src/npmrc-validator.js +205 -0
- package/src/overrides.js +71 -0
- package/src/package-json-validator.js +356 -0
- package/src/parallel-processor.js +374 -0
- package/src/parser.js +133 -0
- package/src/performance.js +283 -0
- package/src/pinner.js +248 -0
- package/src/pnpm-format.js +229 -0
- package/src/pnpm-workspace-validator.js +121 -0
- package/src/progress-reporter.js +245 -0
- package/src/pruner.js +177 -0
- package/src/remediate.js +389 -0
- package/src/report.js +663 -0
- package/src/schema.js +76 -0
- package/src/streaming-parser.js +251 -0
- package/src/updater.js +251 -0
- package/src/usage-scanner.js +215 -0
- package/src/validator.js +282 -0
- package/src/vuln.js +618 -0
- package/src/workers/dedupe-worker.js +20 -0
- package/src/workers/hash-upgrade-worker.js +20 -0
- package/src/workers/migration-worker.js +21 -0
- package/src/workers/validation-worker.js +20 -0
package/src/migrator.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// src/migrator.js
|
|
2
|
+
import { detectLockfileVersion, LOCKFILE_VERSIONS } from './format-library.js';
|
|
3
|
+
|
|
4
|
+
export class MigrationError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'MigrationError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function migrateToVersion(lockfile, targetVersion) {
|
|
12
|
+
const currentVersion = detectLockfileVersion(lockfile);
|
|
13
|
+
if (targetVersion === currentVersion) return lockfile;
|
|
14
|
+
|
|
15
|
+
if (![LOCKFILE_VERSIONS.V1, LOCKFILE_VERSIONS.V2, LOCKFILE_VERSIONS.V3].includes(targetVersion)) {
|
|
16
|
+
throw new MigrationError(`Unsupported target version: ${targetVersion}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const migrated = runMigrationPath(lockfile, currentVersion, targetVersion);
|
|
20
|
+
migrated.lockfileVersion = targetVersion;
|
|
21
|
+
return migrated;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Dispatch to the concrete migration for a (current -> target) pair. Every pair
|
|
25
|
+
// documented in CLAUDE.md is handled here (both upgrades and downgrades); the
|
|
26
|
+
// multi-version hops (V1<->V3) are composed from the single-step migrations.
|
|
27
|
+
function runMigrationPath(lockfile, currentVersion, targetVersion) {
|
|
28
|
+
const { V1, V2, V3 } = LOCKFILE_VERSIONS;
|
|
29
|
+
const key = `${currentVersion}->${targetVersion}`;
|
|
30
|
+
switch (key) {
|
|
31
|
+
case `${V1}->${V2}`: return migrateV1toV2(lockfile);
|
|
32
|
+
case `${V2}->${V3}`: return migrateV2toV3(lockfile);
|
|
33
|
+
case `${V3}->${V2}`: return migrateV3toV2(lockfile);
|
|
34
|
+
case `${V1}->${V3}`: return migrateV2toV3(migrateV1toV2(lockfile));
|
|
35
|
+
case `${V2}->${V1}`: return migrateV2toV1(lockfile);
|
|
36
|
+
case `${V3}->${V1}`: return migrateV2toV1(migrateV3toV2(lockfile));
|
|
37
|
+
default:
|
|
38
|
+
throw new MigrationError(`Unsupported migration path from ${currentVersion} to ${targetVersion}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Path helpers -----------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
// Parse a packages-map install path into its node_modules name segments.
|
|
45
|
+
// "node_modules/a" -> ['a']
|
|
46
|
+
// "node_modules/@scope/a" -> ['@scope/a']
|
|
47
|
+
// "node_modules/a/node_modules/b" -> ['a', 'b']
|
|
48
|
+
// "node_modules/a/node_modules/@scope/b" -> ['a', '@scope/b']
|
|
49
|
+
// Returns null for workspace source paths (e.g. "packages/app") which are not
|
|
50
|
+
// node_modules installs and have no place in the legacy dependencies tree.
|
|
51
|
+
function parseInstallPath(key) {
|
|
52
|
+
const prefix = 'node_modules/';
|
|
53
|
+
if (!key.startsWith(prefix)) return null;
|
|
54
|
+
return key.slice(prefix.length).split('/node_modules/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Merge a package entry's runtime/optional/peer dependency ranges into the
|
|
58
|
+
// legacy tree's `requires` map (name -> range string). Returns null when empty.
|
|
59
|
+
function buildRequires(pkg) {
|
|
60
|
+
const requires = {};
|
|
61
|
+
for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
62
|
+
const deps = pkg[section];
|
|
63
|
+
if (deps && typeof deps === 'object') {
|
|
64
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
65
|
+
if (typeof range === 'string') requires[name] = range;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(requires).length > 0 ? requires : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Convert a v2/v3 packages-map entry into a legacy (v1/v2) dependencies-tree
|
|
73
|
+
// node: a resolution object {version, resolved, integrity, requires, ...}.
|
|
74
|
+
// `existing` preserves any nested `dependencies` already built from children
|
|
75
|
+
// that were seen before their parent.
|
|
76
|
+
function packageEntryToLegacyNode(pkg, existing) {
|
|
77
|
+
const node = { version: typeof pkg.version === 'string' ? pkg.version : '' };
|
|
78
|
+
if (pkg.resolved) node.resolved = pkg.resolved;
|
|
79
|
+
if (pkg.integrity) node.integrity = pkg.integrity;
|
|
80
|
+
if (pkg.dev) node.dev = true;
|
|
81
|
+
if (pkg.optional) node.optional = true;
|
|
82
|
+
const requires = buildRequires(pkg);
|
|
83
|
+
if (requires) node.requires = requires;
|
|
84
|
+
if (existing && existing.dependencies) node.dependencies = existing.dependencies;
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Reconstruct the nested legacy dependencies tree from a v2/v3 packages map.
|
|
89
|
+
// Order-independent: parents seen after their children keep the children that
|
|
90
|
+
// were already placed under them.
|
|
91
|
+
function buildDependenciesTreeFromPackages(packages) {
|
|
92
|
+
const root = {};
|
|
93
|
+
for (const [key, pkg] of Object.entries(packages)) {
|
|
94
|
+
if (key === '' || !pkg || typeof pkg !== 'object') continue;
|
|
95
|
+
const segments = parseInstallPath(key);
|
|
96
|
+
if (!segments || segments.length === 0) continue; // workspace source dir, not an install
|
|
97
|
+
|
|
98
|
+
let tree = root;
|
|
99
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
100
|
+
const seg = segments[i];
|
|
101
|
+
if (!tree[seg]) tree[seg] = { version: '' };
|
|
102
|
+
if (!tree[seg].dependencies) tree[seg].dependencies = {};
|
|
103
|
+
tree = tree[seg].dependencies;
|
|
104
|
+
}
|
|
105
|
+
const name = segments[segments.length - 1];
|
|
106
|
+
tree[name] = packageEntryToLegacyNode(pkg, tree[name]);
|
|
107
|
+
}
|
|
108
|
+
return root;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// npm hosted-git shorthands (github:/gitlab:/bitbucket:/gist:) as they appear in
|
|
112
|
+
// a v1 lockfile's `version` field. Map them to a `git+`-form URL so v2/v3
|
|
113
|
+
// classification (resolved startsWith git+/git://) recognizes them as git deps —
|
|
114
|
+
// otherwise the entry looks like a plain registry package and gets a bogus
|
|
115
|
+
// placeholder integrity stamped on it (EINTEGRITY on npm ci).
|
|
116
|
+
const HOSTED_GIT_HOSTS = {
|
|
117
|
+
github: 'github.com',
|
|
118
|
+
gitlab: 'gitlab.com',
|
|
119
|
+
bitbucket: 'bitbucket.org',
|
|
120
|
+
gist: 'gist.github.com'
|
|
121
|
+
};
|
|
122
|
+
function normalizeHostedGitShorthand(version) {
|
|
123
|
+
if (typeof version !== 'string') return null;
|
|
124
|
+
const m = /^(github|gitlab|bitbucket|gist):(.+)$/.exec(version);
|
|
125
|
+
if (!m) return null;
|
|
126
|
+
return `git+https://${HOSTED_GIT_HOSTS[m[1]]}/${m[2]}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Convert a v1 dependencies-tree node into a v2 packages-map entry (drops the
|
|
130
|
+
// nested `dependencies` — those become their own path-keyed entries — and turns
|
|
131
|
+
// `requires` back into a `dependencies` range map).
|
|
132
|
+
// A v1 git dep carries its git URL in `version`, often with no `resolved`. v2/v3
|
|
133
|
+
// classify git deps by a `git+`/`git://` `resolved`, so derive one — otherwise the
|
|
134
|
+
// entry looks like a plain registry package and gets a bogus placeholder integrity
|
|
135
|
+
// stamped on it. Covers explicit git URLs and npm's hosted-git shorthands
|
|
136
|
+
// (github:/gitlab:/bitbucket:/gist:). Returns null when the version isn't a git ref.
|
|
137
|
+
function v1GitResolved(node) {
|
|
138
|
+
if (typeof node.version !== 'string') return null;
|
|
139
|
+
if (node.version.startsWith('git+') || node.version.startsWith('git://')) return node.version;
|
|
140
|
+
return normalizeHostedGitShorthand(node.version);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function v1NodeToPackageEntry(node) {
|
|
144
|
+
const entry = {};
|
|
145
|
+
if (node.version !== undefined) entry.version = node.version;
|
|
146
|
+
if (node.resolved) entry.resolved = node.resolved;
|
|
147
|
+
if (node.integrity) entry.integrity = node.integrity;
|
|
148
|
+
if (node.dev) entry.dev = true;
|
|
149
|
+
if (node.optional) entry.optional = true;
|
|
150
|
+
// A v1 bundled dep is flagged `bundled: true`; v2/v3 spell it `inBundle`.
|
|
151
|
+
// Preserve it so forEachPackageEntry keeps classifying the entry as bundled
|
|
152
|
+
// (no registry tarball → the fixer must not stamp placeholder integrity).
|
|
153
|
+
if (node.bundled) entry.inBundle = true;
|
|
154
|
+
if (!entry.resolved) {
|
|
155
|
+
const gitUrl = v1GitResolved(node);
|
|
156
|
+
if (gitUrl) entry.resolved = gitUrl;
|
|
157
|
+
}
|
|
158
|
+
if (node.requires && typeof node.requires === 'object') {
|
|
159
|
+
entry.dependencies = { ...node.requires };
|
|
160
|
+
}
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Walk a v1 nested dependencies tree, emitting one packages-map entry per node
|
|
165
|
+
// keyed by its full node_modules install path.
|
|
166
|
+
function walkV1Tree(tree, pathPrefix, packages) {
|
|
167
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
168
|
+
if (!node || typeof node !== 'object') continue;
|
|
169
|
+
const key = `${pathPrefix}/${name}`;
|
|
170
|
+
packages[key] = v1NodeToPackageEntry(node);
|
|
171
|
+
if (node.dependencies && typeof node.dependencies === 'object') {
|
|
172
|
+
walkV1Tree(node.dependencies, `${key}/node_modules`, packages);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Build the v2 packages map from a v1 lockfile's dependencies tree: a root
|
|
178
|
+
// entry ('') with direct-dependency range strings, plus a path-keyed entry for
|
|
179
|
+
// every node in the tree carrying its resolution data.
|
|
180
|
+
function buildPackagesFromV1Tree(lockfile) {
|
|
181
|
+
const packages = {};
|
|
182
|
+
const tree = lockfile.dependencies || {};
|
|
183
|
+
|
|
184
|
+
const root = { name: lockfile.name, version: lockfile.version };
|
|
185
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
186
|
+
if (!node || typeof node !== 'object' || typeof node.version !== 'string') continue;
|
|
187
|
+
const section = node.dev ? 'devDependencies' : 'dependencies';
|
|
188
|
+
root[section] = root[section] || {};
|
|
189
|
+
root[section][name] = node.version;
|
|
190
|
+
}
|
|
191
|
+
packages[''] = root;
|
|
192
|
+
|
|
193
|
+
walkV1Tree(tree, 'node_modules', packages);
|
|
194
|
+
return packages;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Single-step migrations -------------------------------------------------
|
|
198
|
+
|
|
199
|
+
// V1 -> V2: keep the v1 dependencies tree verbatim (it IS a valid v2 legacy
|
|
200
|
+
// tree) and add the packages map derived from it. Nothing is lost.
|
|
201
|
+
function migrateV1toV2(lockfile) {
|
|
202
|
+
const packages = buildPackagesFromV1Tree(lockfile);
|
|
203
|
+
return { ...lockfile, packages, requires: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// V2 -> V3: keep the packages map (including the root '' entry) verbatim; drop
|
|
207
|
+
// the legacy dependencies tree and top-level `requires` that v3 must not carry.
|
|
208
|
+
function migrateV2toV3(lockfile) {
|
|
209
|
+
// v3 carries neither the legacy dependencies tree nor top-level `requires`.
|
|
210
|
+
const rest = { ...lockfile };
|
|
211
|
+
delete rest.dependencies;
|
|
212
|
+
delete rest.requires;
|
|
213
|
+
let packages = lockfile.packages;
|
|
214
|
+
if (!packages || typeof packages !== 'object' || Object.keys(packages).length === 0) {
|
|
215
|
+
// A merge-damaged v2 may carry only the legacy dependencies tree. Rebuild
|
|
216
|
+
// the packages map from it rather than silently emitting an empty v3 that
|
|
217
|
+
// destroys every locked resolution (mirrors migrateV2toV1's fallback).
|
|
218
|
+
packages = buildPackagesFromV1Tree(lockfile);
|
|
219
|
+
} else {
|
|
220
|
+
packages = { ...packages };
|
|
221
|
+
}
|
|
222
|
+
return { ...rest, packages };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// V3 -> V2: keep the packages map verbatim; reconstruct the legacy dependencies
|
|
226
|
+
// tree as resolution objects so npm 6 gets its locked versions back.
|
|
227
|
+
function migrateV3toV2(lockfile) {
|
|
228
|
+
const packages = { ...(lockfile.packages || {}) };
|
|
229
|
+
const dependencies = buildDependenciesTreeFromPackages(packages);
|
|
230
|
+
return { ...lockfile, packages, dependencies, requires: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// V2 -> V1: drop the packages map (and `requires`), keep the legacy dependencies
|
|
234
|
+
// tree. When a v2 file lacks that tree, reconstruct it from the packages map.
|
|
235
|
+
function migrateV2toV1(lockfile) {
|
|
236
|
+
let dependencies = lockfile.dependencies;
|
|
237
|
+
if (!dependencies || typeof dependencies !== 'object' || Object.keys(dependencies).length === 0) {
|
|
238
|
+
dependencies = buildDependenciesTreeFromPackages(lockfile.packages || {});
|
|
239
|
+
}
|
|
240
|
+
// v1 carries neither the packages map nor top-level `requires`.
|
|
241
|
+
const rest = { ...lockfile };
|
|
242
|
+
delete rest.packages;
|
|
243
|
+
delete rest.requires;
|
|
244
|
+
return { ...rest, dependencies };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class PackageLockMigrator {
|
|
248
|
+
constructor(options = {}) {
|
|
249
|
+
this.preserveMetadata = options.preserveMetadata || false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
migrate(lockfile, targetVersion) {
|
|
253
|
+
const migrated = migrateToVersion(lockfile, targetVersion);
|
|
254
|
+
if (this.preserveMetadata) {
|
|
255
|
+
const metadata = {
|
|
256
|
+
name: lockfile.name,
|
|
257
|
+
version: lockfile.version
|
|
258
|
+
};
|
|
259
|
+
return { ...migrated, ...metadata };
|
|
260
|
+
}
|
|
261
|
+
return migrated;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/npmrc-validator.js
|
|
2
|
+
// Parse and validate an ini-style project `.npmrc`, mirroring validator.js's
|
|
3
|
+
// contract: validateNpmrc(contentOrParsed, options) => { valid, errors, warnings, info }.
|
|
4
|
+
// We only ever inspect the project-level `.npmrc` (the committed, reproducible
|
|
5
|
+
// artifact) — not the machine's ~/.npmrc — so results match between local and CI.
|
|
6
|
+
|
|
7
|
+
export class NpmrcValidationError extends Error {
|
|
8
|
+
constructor(message, code) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'NpmrcValidationError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse ini-style .npmrc content into entries, recording 1-based line numbers.
|
|
17
|
+
* Tolerant and comment-aware (`;` and `#` start comments, inline too); strips
|
|
18
|
+
* surrounding quotes from values. A non-comment, non-blank line without `=` is a
|
|
19
|
+
* bare boolean key: npm parses `.npmrc` with the `ini` package, which reads
|
|
20
|
+
* `engine-strict` as `engine-strict = true`, so we do the same (value `'true'`)
|
|
21
|
+
* and let the entry flow through the normal checks (a bare typo still trips the
|
|
22
|
+
* unknown-key warning) rather than hard-erroring on legal config.
|
|
23
|
+
*
|
|
24
|
+
* @returns {{ key: string|null, value: string|null, line: number, raw?: string, malformed?: boolean }[]}
|
|
25
|
+
*/
|
|
26
|
+
export function parseNpmrc(content) {
|
|
27
|
+
const entries = [];
|
|
28
|
+
const lines = (content || '').split(/\r?\n/);
|
|
29
|
+
lines.forEach((raw, i) => {
|
|
30
|
+
// Strip comments: a `;`/`#` starts a comment only at line-start or when
|
|
31
|
+
// preceded by whitespace (npm/ini semantics) — so `_password=pa#ss` keeps
|
|
32
|
+
// its literal value and a secret can't be truncated out of detection.
|
|
33
|
+
const line = raw.replace(/(^|\s)[;#].*$/, '$1').trim();
|
|
34
|
+
if (!line) return;
|
|
35
|
+
const eq = line.indexOf('=');
|
|
36
|
+
if (eq === -1) {
|
|
37
|
+
// Bare boolean key (`ini` semantics): `engine-strict` === `engine-strict=true`.
|
|
38
|
+
entries.push({ key: line.toLowerCase(), value: 'true', line: i + 1 });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const value = line.slice(eq + 1).trim().replace(/(?:^["'])|(?:["']$)/g, '');
|
|
42
|
+
entries.push({
|
|
43
|
+
key: line.slice(0, eq).trim().toLowerCase(),
|
|
44
|
+
value,
|
|
45
|
+
line: i + 1
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Common, recognized npm config keys — used only to *warn* on unknowns, so this
|
|
52
|
+
// is intentionally a generous subset rather than an exhaustive list.
|
|
53
|
+
const KNOWN_KEYS = new Set([
|
|
54
|
+
'registry', 'fund', 'audit', 'audit-level', 'save', 'save-exact', 'save-prefix',
|
|
55
|
+
'package-lock', 'package-lock-only', 'engine-strict', 'strict-ssl', 'ca', 'cafile',
|
|
56
|
+
'cert', 'key', 'proxy', 'https-proxy', 'noproxy', 'always-auth', 'init-author-name',
|
|
57
|
+
'init-author-email', 'init-author-url', 'init-license', 'init-version', 'loglevel',
|
|
58
|
+
'prefix', 'cache', 'legacy-peer-deps', 'fetch-retries', 'fetch-retry-mintimeout',
|
|
59
|
+
'fetch-retry-maxtimeout', 'fetch-timeout', 'access', 'tag', 'lockfile-version',
|
|
60
|
+
'omit', 'include', 'ignore-scripts', 'foreground-scripts', 'node-options',
|
|
61
|
+
'progress', 'prefer-offline', 'prefer-online', 'offline', 'global', 'unsafe-perm',
|
|
62
|
+
'user-agent', 'maxsockets', 'before', 'workspaces', 'workspace'
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// Plaintext-credential keys: bare `_auth`/`_authtoken`/`_password`, or the
|
|
66
|
+
// scoped `//host/:_authToken` form npm uses for per-registry auth.
|
|
67
|
+
const SECRET_KEY_RE = /(^|\/|:)_(auth|authtoken|authbase64|password)$/i;
|
|
68
|
+
const ENV_REF_RE = /\$\{[^}]+\}/;
|
|
69
|
+
// A value that is ENTIRELY an env reference is safe; a partial one
|
|
70
|
+
// (`realsecret${X}`) must NOT exempt the line, or a plaintext secret slips by.
|
|
71
|
+
const ENV_REF_ONLY_RE = /^\$\{[^}]+\}$/;
|
|
72
|
+
|
|
73
|
+
const ALWAYS_ERROR_CODES = new Set([
|
|
74
|
+
'NPMRC_PLAINTEXT_SECRET',
|
|
75
|
+
'NPMRC_STRICT_SSL_OFF',
|
|
76
|
+
'NPMRC_REJECT_UNAUTHORIZED_OFF'
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Security-critical codes that should fail a run regardless of how the audit
|
|
81
|
+
* rule's severity is configured. Exported so the audit rule can force them.
|
|
82
|
+
*/
|
|
83
|
+
export const NPMRC_SECURITY_CODES = ALWAYS_ERROR_CODES;
|
|
84
|
+
|
|
85
|
+
// Each check below inspects one parsed entry and pushes into the shared
|
|
86
|
+
// errors/warnings arrays via `sink`. It returns `true` when it has fully
|
|
87
|
+
// handled the entry (the caller then moves on), so the security/registry rules
|
|
88
|
+
// short-circuit the later unknown-key warning exactly as before.
|
|
89
|
+
|
|
90
|
+
// Plaintext credential keys: error unless the value is entirely an env ref.
|
|
91
|
+
// Auth lines are always "handled" so they never trip the unknown-key warning.
|
|
92
|
+
function checkSecret({ key, value, line }, sink) {
|
|
93
|
+
if (!SECRET_KEY_RE.test(key)) return false;
|
|
94
|
+
if (!ENV_REF_ONLY_RE.test(value)) {
|
|
95
|
+
sink.errors.push(new NpmrcValidationError(
|
|
96
|
+
`plaintext credential at line ${line} ("${key}") — use an env var reference like \${NPM_TOKEN}`,
|
|
97
|
+
'NPMRC_PLAINTEXT_SECRET'));
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// TLS / verification weakening: strict-ssl=false/0 and any *rejectUnauthorized
|
|
103
|
+
// disabled are always hard errors.
|
|
104
|
+
function checkTls({ key, value, line }, sink) {
|
|
105
|
+
if (key === 'strict-ssl' && /^(false|0)$/i.test(value)) {
|
|
106
|
+
sink.errors.push(new NpmrcValidationError(`strict-ssl=${value} at line ${line} disables TLS verification`, 'NPMRC_STRICT_SSL_OFF'));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (key.endsWith('rejectunauthorized') && /^(false|0)$/i.test(value)) {
|
|
110
|
+
sink.errors.push(new NpmrcValidationError(`rejectUnauthorized disabled at line ${line}`, 'NPMRC_REJECT_UNAUTHORIZED_OFF'));
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Privilege escalation: unsafe-perm enabled runs lifecycle scripts elevated.
|
|
117
|
+
function checkUnsafePerm({ key, value, line }, sink) {
|
|
118
|
+
if (key !== 'unsafe-perm' || !/^(true|1)$/i.test(value)) return false;
|
|
119
|
+
sink.warnings.push({ code: 'NPMRC_UNSAFE_PERM', message: `unsafe-perm enabled at line ${line} — lifecycle scripts run with elevated privileges` });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Registry URL validity / scheme. Env-interpolated URLs can't be parsed, so
|
|
124
|
+
// they're accepted as-is; anything else must be a valid URL, and http:// warns.
|
|
125
|
+
function checkRegistry({ key, value, line }, sink) {
|
|
126
|
+
if (key !== 'registry' && !key.endsWith(':registry')) return false;
|
|
127
|
+
if (ENV_REF_RE.test(value)) return true; // env-interpolated URL — can't statically parse
|
|
128
|
+
let url = null;
|
|
129
|
+
try {
|
|
130
|
+
url = new URL(value);
|
|
131
|
+
} catch {
|
|
132
|
+
sink.errors.push(new NpmrcValidationError(`invalid registry URL at line ${line}: "${value}"`, 'NPMRC_INVALID_REGISTRY'));
|
|
133
|
+
}
|
|
134
|
+
if (url && url.protocol === 'http:') {
|
|
135
|
+
sink.warnings.push({ code: 'NPMRC_INSECURE_REGISTRY', message: `registry over http:// at line ${line} (${value}) — prefer https://` });
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Unknown key (warn only). `//host/...` lines are per-registry auth/config and
|
|
141
|
+
// are skipped; a scoped key is matched on its bare suffix too.
|
|
142
|
+
function checkUnknownKey({ key, line }, sink) {
|
|
143
|
+
if (key.startsWith('//')) return; // per-registry auth/config line
|
|
144
|
+
const bareKey = key.includes(':') ? key.slice(key.lastIndexOf(':') + 1) : key;
|
|
145
|
+
if (!KNOWN_KEYS.has(key) && !KNOWN_KEYS.has(bareKey)) {
|
|
146
|
+
sink.warnings.push({ code: 'NPMRC_UNKNOWN_KEY', message: `unrecognized npm config key "${key}" at line ${line}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// npm-specific keys that pnpm genuinely does NOT read from .npmrc. pnpm reads
|
|
151
|
+
// virtually every other setting from `.npmrc` — registry/auth, TLS, proxies, and
|
|
152
|
+
// its install behaviour (`shamefully-hoist`, `node-linker`, `hoist-pattern`,
|
|
153
|
+
// `strict-peer-dependencies`, …) — across pnpm 7-10, so warning on "everything
|
|
154
|
+
// non-auth" is factually wrong for most pnpm projects. We flag ONLY these
|
|
155
|
+
// npm-only keys, each of which has a differently-named pnpm equivalent (or none)
|
|
156
|
+
// and is therefore silently dropped by pnpm. The value is a remediation hint.
|
|
157
|
+
const PNPM_IGNORED_KEYS = new Map([
|
|
158
|
+
['package-lock', 'pnpm uses `lockfile`'],
|
|
159
|
+
['package-lock-only', 'pnpm uses `lockfile-only`'],
|
|
160
|
+
['legacy-peer-deps', 'pnpm uses `auto-install-peers` / `strict-peer-dependencies`']
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// pnpm flavor: flag the handful of npm-only settings pnpm does not honor. Reached
|
|
164
|
+
// only for entries not already claimed by the secret/TLS/registry checks.
|
|
165
|
+
function checkPnpmIgnored({ key, line }, sink) {
|
|
166
|
+
const hint = PNPM_IGNORED_KEYS.get(key);
|
|
167
|
+
if (!hint) return;
|
|
168
|
+
sink.warnings.push({
|
|
169
|
+
code: 'NPMRC_PNPM_IGNORED',
|
|
170
|
+
message: `npm-only setting "${key}" at line ${line} in .npmrc is not honored by pnpm — ${hint}`
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Run the security/registry checks in order; the first to claim the entry wins.
|
|
175
|
+
// When none does, the entry falls through to the unknown-key (npm) or
|
|
176
|
+
// pnpm-ignored (pnpm) warning.
|
|
177
|
+
function validateEntry(entry, sink, flavor) {
|
|
178
|
+
if (entry.malformed) {
|
|
179
|
+
sink.errors.push(new NpmrcValidationError(`malformed line ${entry.line}: "${entry.raw}" (expected key=value)`, 'NPMRC_SYNTAX'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (checkSecret(entry, sink)) return;
|
|
183
|
+
if (checkTls(entry, sink)) return;
|
|
184
|
+
if (checkUnsafePerm(entry, sink)) return;
|
|
185
|
+
if (checkRegistry(entry, sink)) return;
|
|
186
|
+
if (flavor === 'pnpm') {
|
|
187
|
+
checkPnpmIgnored(entry, sink);
|
|
188
|
+
} else {
|
|
189
|
+
checkUnknownKey(entry, sink);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function validateNpmrc(input, options = {}) {
|
|
194
|
+
const entries = typeof input === 'string' ? parseNpmrc(input) : (input || []);
|
|
195
|
+
const sink = { errors: [], warnings: [] };
|
|
196
|
+
const info = { keys: entries.filter((e) => e.key).map((e) => e.key) };
|
|
197
|
+
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
validateEntry(e, sink, options.flavor);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { errors, warnings } = sink;
|
|
203
|
+
const valid = errors.length === 0 && !(options.strictMode && warnings.length > 0);
|
|
204
|
+
return { valid, errors, warnings, info };
|
|
205
|
+
}
|
package/src/overrides.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/overrides.js
|
|
2
|
+
// Shared walker for npm `overrides` and the pnpm `pnpm.overrides` manifest field.
|
|
3
|
+
//
|
|
4
|
+
// npm `overrides` is NESTED: a value is either a range string or a nested object
|
|
5
|
+
// whose "." key overrides the parent package itself and whose other keys override
|
|
6
|
+
// that package's own children. A string value beginning with "$" is a REFERENCE
|
|
7
|
+
// to a direct dependency's version, not a range, and must be skipped.
|
|
8
|
+
//
|
|
9
|
+
// pnpm `pnpm.overrides` is a FLAT map whose keys are selectors (`foo`, `foo@1`,
|
|
10
|
+
// `parent>child`) and whose values are range strings (also possibly "$ref"); the
|
|
11
|
+
// flat form is just the non-nested base case of the same walk.
|
|
12
|
+
//
|
|
13
|
+
// Zero dependencies — a pure structural generator. Consumers (pinner, audit
|
|
14
|
+
// pinned-versions rule, package.json validator) classify/validate the ranges.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Derive the overridden package name from an override key, stripping a trailing
|
|
18
|
+
* `@version` selector (npm allows `"foo@2"`, `"@scope/foo@^1.0.0"` to scope an
|
|
19
|
+
* override to matching versions). The leading `@` of a scope is preserved.
|
|
20
|
+
* @param {string} key
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function overrideName(key) {
|
|
24
|
+
const at = key.lastIndexOf('@');
|
|
25
|
+
return at > 0 ? key.slice(0, at) : key;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Walk an overrides object, yielding every value leaf (each non-object entry).
|
|
30
|
+
* `$name` references are skipped (they point at a direct dep's version, not a
|
|
31
|
+
* range). A leaf's `range` is usually a string but MAY be a non-string for a
|
|
32
|
+
* malformed manifest — consumers classify/validate it (classifyRange and
|
|
33
|
+
* isValidRange both handle non-strings), so the validator can flag bad values.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} overrides - The overrides object (npm `overrides` or `pnpm.overrides`)
|
|
36
|
+
* @param {object} [opts] - Internal recursion state: { parentName, path }
|
|
37
|
+
* @yields {{ path: string, name: string, range: *, container: object, key: string }}
|
|
38
|
+
* path - human-readable location ("foo", "bar > baz", "bar > .")
|
|
39
|
+
* name - the effective package name overridden (the parent for a "." key,
|
|
40
|
+
* with any `@version` selector stripped)
|
|
41
|
+
* range - the leaf value (a range string, or a non-string if malformed)
|
|
42
|
+
* container - the object holding the leaf (so a caller can rewrite in place)
|
|
43
|
+
* key - the leaf's key within `container`
|
|
44
|
+
*/
|
|
45
|
+
// Build the leaf descriptor for a non-object override value, or null when the
|
|
46
|
+
// leaf should be skipped: a "$name" reference (points at a direct dep's version,
|
|
47
|
+
// not a range) or a stray "." with no parent (malformed).
|
|
48
|
+
function overrideLeaf(container, key, value, name, path) {
|
|
49
|
+
if (typeof value === 'string' && value.startsWith('$')) return null;
|
|
50
|
+
if (name == null) return null;
|
|
51
|
+
return { path, name, range: value, container, key };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function* walkOverrides(overrides, opts = {}) {
|
|
55
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return;
|
|
56
|
+
const { parentName = null, path = '' } = opts;
|
|
57
|
+
|
|
58
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
59
|
+
const here = path ? `${path} > ${key}` : key;
|
|
60
|
+
// "." refers to the parent package itself (npm nested form).
|
|
61
|
+
const name = key === '.' ? parentName : overrideName(key);
|
|
62
|
+
|
|
63
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
64
|
+
// Nested override object: the "." inside it refers to THIS key's package.
|
|
65
|
+
yield* walkOverrides(value, { parentName: name, path: here });
|
|
66
|
+
} else {
|
|
67
|
+
const leaf = overrideLeaf(overrides, key, value, name, here);
|
|
68
|
+
if (leaf) yield leaf;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|