@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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// src/usage-scanner.js
|
|
2
|
+
// Heuristic detection of dependencies declared in package.json but never
|
|
3
|
+
// imported by the application's source code ("flag for removal").
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
export class UsageScannerError extends Error {
|
|
8
|
+
constructor(message, code, context = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'UsageScannerError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.context = context;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte'];
|
|
17
|
+
export const DEFAULT_IGNORE_DIRS = [
|
|
18
|
+
'node_modules', '.git', '.backups', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', 'vendor'
|
|
19
|
+
];
|
|
20
|
+
// Build/tooling output dirs that DEFAULT_IGNORE_DIRS skips during the app scan.
|
|
21
|
+
// `findUnusedDependencies` scans these separately so a dependency imported only
|
|
22
|
+
// by a hand-written build toolkit (e.g. a `build/` shipped as source) is not
|
|
23
|
+
// mistaken for unused. Conservative by design: it can only rescue deps from a
|
|
24
|
+
// removal suggestion, never add one.
|
|
25
|
+
export const DEFAULT_BUILD_DIRS = ['build', 'dist', 'out'];
|
|
26
|
+
|
|
27
|
+
// require('x') / require("x") / import('x') / import "x" / from 'x' / export ... from 'x'
|
|
28
|
+
const IMPORT_PATTERNS = [
|
|
29
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
30
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
31
|
+
/\bimport\s+['"]([^'"]+)['"]/g,
|
|
32
|
+
/\bfrom\s+['"]([^'"]+)['"]/g
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reduce an import specifier to its package name.
|
|
37
|
+
* 'lodash/fp' → 'lodash'; '@scope/pkg/sub' → '@scope/pkg';
|
|
38
|
+
* relative paths and node: builtins → null.
|
|
39
|
+
*/
|
|
40
|
+
export function specifierToPackageName(specifier) {
|
|
41
|
+
if (!specifier || typeof specifier !== 'string') return null;
|
|
42
|
+
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')) return null;
|
|
43
|
+
if (specifier.startsWith('node:')) return null;
|
|
44
|
+
if (specifier.includes('://')) return null; // URLs (e.g. https: imports)
|
|
45
|
+
|
|
46
|
+
const parts = specifier.split('/');
|
|
47
|
+
if (specifier.startsWith('@')) {
|
|
48
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
|
|
49
|
+
}
|
|
50
|
+
return parts[0];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walkFiles(dir, extensions, ignoreDirs, files = []) {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return files;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
if (!ignoreDirs.includes(entry.name) && !entry.name.startsWith('.')) {
|
|
63
|
+
walkFiles(path.join(dir, entry.name), extensions, ignoreDirs, files);
|
|
64
|
+
}
|
|
65
|
+
} else if (entry.isFile() && extensions.includes(path.extname(entry.name))) {
|
|
66
|
+
files.push(path.join(dir, entry.name));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return files;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scan a project's source files and collect the set of imported package names.
|
|
74
|
+
* @param {string} dir - Project root
|
|
75
|
+
* @param {object} options - { extensions, ignoreDirs }
|
|
76
|
+
* @returns {{used: Set<string>, scannedFiles: number}}
|
|
77
|
+
*/
|
|
78
|
+
export function scanUsedPackages(dir, options = {}) {
|
|
79
|
+
const { extensions = DEFAULT_EXTENSIONS, ignoreDirs = DEFAULT_IGNORE_DIRS } = options;
|
|
80
|
+
|
|
81
|
+
if (!fs.existsSync(dir)) {
|
|
82
|
+
throw new UsageScannerError(`Directory not found: ${dir}`, 'DIR_NOT_FOUND');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const files = walkFiles(dir, extensions, ignoreDirs);
|
|
86
|
+
const used = new Set();
|
|
87
|
+
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
let content;
|
|
90
|
+
try {
|
|
91
|
+
content = fs.readFileSync(file, 'utf8');
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
96
|
+
pattern.lastIndex = 0;
|
|
97
|
+
let match;
|
|
98
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
99
|
+
const name = specifierToPackageName(match[1]);
|
|
100
|
+
if (name) used.add(name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { used, scannedFiles: files.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decide whether a declared dependency counts as "used" — by an explicit
|
|
110
|
+
* ignore-list entry, a real import, an npm-script mention, or the
|
|
111
|
+
* @types/foo-follows-foo heuristic.
|
|
112
|
+
*/
|
|
113
|
+
function isDependencyUsed(name, { used, scriptsText, ignore }) {
|
|
114
|
+
return (
|
|
115
|
+
ignore.includes(name) ||
|
|
116
|
+
used.has(name) ||
|
|
117
|
+
// CLI tools invoked from npm scripts (eslint, jest, …) are used even
|
|
118
|
+
// though nothing imports them
|
|
119
|
+
scriptsText.includes(name) ||
|
|
120
|
+
// @types/foo is "used" when foo itself is
|
|
121
|
+
(name.startsWith('@types/') && used.has(name.slice('@types/'.length)))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Collect the unused entries from a single package.json dependency section.
|
|
127
|
+
*/
|
|
128
|
+
function collectUnusedInSection(deps, section, context) {
|
|
129
|
+
const unused = [];
|
|
130
|
+
if (!deps || typeof deps !== 'object') return unused;
|
|
131
|
+
|
|
132
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
133
|
+
if (isDependencyUsed(name, context)) continue;
|
|
134
|
+
unused.push({ name, section, version });
|
|
135
|
+
}
|
|
136
|
+
return unused;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Scan each build/tooling dir that exists under `dir`, as its own pass. Returns
|
|
141
|
+
* the union of packages imported there plus a per-dir file/usage breakdown.
|
|
142
|
+
*/
|
|
143
|
+
function scanBuildDirs(dir, buildDirs, options) {
|
|
144
|
+
const used = new Set();
|
|
145
|
+
let scannedFiles = 0;
|
|
146
|
+
const dirsScanned = [];
|
|
147
|
+
for (const name of buildDirs) {
|
|
148
|
+
const full = path.join(dir, name);
|
|
149
|
+
if (!fs.existsSync(full)) continue;
|
|
150
|
+
const res = scanUsedPackages(full, options);
|
|
151
|
+
for (const pkg of res.used) used.add(pkg);
|
|
152
|
+
scannedFiles += res.scannedFiles;
|
|
153
|
+
dirsScanned.push(name);
|
|
154
|
+
}
|
|
155
|
+
return { used, scannedFiles, dirsScanned };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Find dependencies declared in package.json that the application never
|
|
160
|
+
* imports. Heuristic — results are flagged for removal, never auto-removed:
|
|
161
|
+
* packages used only via CLI, config files, or runtime magic can appear
|
|
162
|
+
* unused. Mentions in npm scripts count as used to reduce CLI-tool noise.
|
|
163
|
+
*
|
|
164
|
+
* Runs two passes: the application (with build/output dirs ignored), and the
|
|
165
|
+
* build/tooling dirs (`buildDirs`) separately. A dependency counts as used if
|
|
166
|
+
* either pass imports it, so a `build/` shipped as source no longer produces
|
|
167
|
+
* false "unused" flags; deps imported ONLY by the build pass are surfaced as
|
|
168
|
+
* `buildOnly` for visibility. Pass `buildDirs: []` to disable the second pass.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} packageJson - Parsed package.json
|
|
171
|
+
* @param {string} dir - Project root to scan
|
|
172
|
+
* @param {object} options - { includeDev = false, ignore = [], buildDirs, extensions, ignoreDirs }
|
|
173
|
+
* @returns {{unused: Array<{name, section, version}>, used: Set<string>, usedByApp: Set<string>, usedByBuild: Set<string>, buildOnly: string[], scannedFiles: number, appFiles: number, buildFiles: number, buildDirsScanned: string[], sectionsChecked: string[]}}
|
|
174
|
+
*/
|
|
175
|
+
export function findUnusedDependencies(packageJson, dir, options = {}) {
|
|
176
|
+
const { includeDev = false, ignore = [], buildDirs = DEFAULT_BUILD_DIRS } = options;
|
|
177
|
+
|
|
178
|
+
if (!packageJson || typeof packageJson !== 'object') {
|
|
179
|
+
throw new UsageScannerError('package.json data is required', 'MISSING_PACKAGE_JSON');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Pass 1: the application, with build/output dirs ignored (default behavior).
|
|
183
|
+
const app = scanUsedPackages(dir, options);
|
|
184
|
+
const usedByApp = app.used;
|
|
185
|
+
|
|
186
|
+
// Pass 2: the build/tooling dirs, scanned separately.
|
|
187
|
+
const build = scanBuildDirs(dir, buildDirs, options);
|
|
188
|
+
const usedByBuild = build.used;
|
|
189
|
+
|
|
190
|
+
const used = new Set([...usedByApp, ...usedByBuild]);
|
|
191
|
+
const scriptsText = Object.values(packageJson.scripts || {}).join('\n');
|
|
192
|
+
const context = { used, scriptsText, ignore };
|
|
193
|
+
|
|
194
|
+
const sectionsChecked = includeDev ? ['dependencies', 'devDependencies'] : ['dependencies'];
|
|
195
|
+
const unused = [];
|
|
196
|
+
|
|
197
|
+
for (const section of sectionsChecked) {
|
|
198
|
+
unused.push(...collectUnusedInSection(packageJson[section], section, context));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const buildOnly = [...usedByBuild].filter((name) => !usedByApp.has(name)).sort();
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
unused,
|
|
205
|
+
used,
|
|
206
|
+
usedByApp,
|
|
207
|
+
usedByBuild,
|
|
208
|
+
buildOnly,
|
|
209
|
+
scannedFiles: app.scannedFiles + build.scannedFiles,
|
|
210
|
+
appFiles: app.scannedFiles,
|
|
211
|
+
buildFiles: build.scannedFiles,
|
|
212
|
+
buildDirsScanned: build.dirsScanned,
|
|
213
|
+
sectionsChecked
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/validator.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// src/validator.js
|
|
2
|
+
import { detectLockfileVersion, hasPackagesMap, hasDependenciesTree } from './format-library.js';
|
|
3
|
+
|
|
4
|
+
export class ValidationError extends Error {
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Allow calling validatePackageLock(lockfile, options) when packageJson is omitted.
|
|
12
|
+
function normalizeValidateArgs(argLength, packageJson, options) {
|
|
13
|
+
const looksLikeOptions = argLength === 2 && packageJson && typeof packageJson === 'object' &&
|
|
14
|
+
(packageJson.allowMissingIntegrity !== undefined || packageJson.validateAgainstPackageJson !== undefined);
|
|
15
|
+
if (looksLikeOptions) {
|
|
16
|
+
return { packageJson: null, options: packageJson };
|
|
17
|
+
}
|
|
18
|
+
return { packageJson, options };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Basic top-level field checks (name, version, lockfileVersion).
|
|
22
|
+
function validateRootFields(lockfile, errors) {
|
|
23
|
+
if (!lockfile.name || typeof lockfile.name !== 'string') {
|
|
24
|
+
errors.push(new ValidationError('Missing or invalid package name', 'INVALID_NAME'));
|
|
25
|
+
}
|
|
26
|
+
if (!lockfile.version || typeof lockfile.version !== 'string') {
|
|
27
|
+
errors.push(new ValidationError('Missing or invalid package version', 'INVALID_VERSION'));
|
|
28
|
+
}
|
|
29
|
+
// Basic semver-ish check (major.minor.patch). If format is wrong, mark invalid.
|
|
30
|
+
if (typeof lockfile.version === 'string') {
|
|
31
|
+
const semverLike = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
|
|
32
|
+
if (!semverLike.test(lockfile.version)) {
|
|
33
|
+
errors.push(new ValidationError('Missing or invalid package version', 'INVALID_VERSION'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (typeof lockfile.lockfileVersion !== 'number') {
|
|
37
|
+
errors.push(new ValidationError('Missing or invalid lockfile version', 'INVALID_LOCKFILE_VERSION'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Validate the version-appropriate dependencies tree and/or packages map.
|
|
42
|
+
function validateVersionStructure(lockfile, version, errors, warnings, options) {
|
|
43
|
+
if (hasDependenciesTree(version)) {
|
|
44
|
+
if (!lockfile.dependencies || typeof lockfile.dependencies !== 'object') {
|
|
45
|
+
errors.push(new ValidationError('Missing dependencies object', 'MISSING_DEPENDENCIES'));
|
|
46
|
+
} else {
|
|
47
|
+
validateDependenciesTree(lockfile.dependencies, errors, warnings);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (hasPackagesMap(version)) {
|
|
52
|
+
if (!lockfile.packages || typeof lockfile.packages !== 'object') {
|
|
53
|
+
errors.push(new ValidationError('Missing packages map', 'MISSING_PACKAGES_MAP'));
|
|
54
|
+
} else {
|
|
55
|
+
validatePackagesMap(lockfile.packages, errors, warnings, options);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validatePackageLock(lockfile, packageJson = null, options = {}) {
|
|
61
|
+
({ packageJson, options } = normalizeValidateArgs(arguments.length, packageJson, options));
|
|
62
|
+
const errors = [];
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const info = {};
|
|
65
|
+
|
|
66
|
+
// Basic structure checks
|
|
67
|
+
validateRootFields(lockfile, errors);
|
|
68
|
+
|
|
69
|
+
let version;
|
|
70
|
+
try {
|
|
71
|
+
version = detectLockfileVersion(lockfile);
|
|
72
|
+
info.version = version;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Normalize error code expected by tests
|
|
75
|
+
errors.push(new ValidationError(e.message, 'VERSION_MISMATCH'));
|
|
76
|
+
return { valid: false, errors, warnings, info };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
validateVersionStructure(lockfile, version, errors, warnings, options);
|
|
80
|
+
|
|
81
|
+
// Validate against package.json if provided
|
|
82
|
+
if (packageJson && options.validateAgainstPackageJson) {
|
|
83
|
+
validateAgainstPackageJson(lockfile, packageJson, errors);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const valid = errors.length === 0 && !(options.strictMode && warnings.length > 0);
|
|
87
|
+
return { valid, errors, warnings, info };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recognise one SRI token: sha1/sha256/sha384/sha512.
|
|
91
|
+
const SRI_TOKEN_REGEX = /^sha(?:1|256|384|512)-[A-Za-z0-9+/=]+$/;
|
|
92
|
+
|
|
93
|
+
// Accept sha1/sha256/sha384/sha512 single hashes and space-separated multi-hash
|
|
94
|
+
// SRI strings (e.g. 'sha512-... sha1-...'). Every whitespace-separated token
|
|
95
|
+
// must match a recognised algorithm.
|
|
96
|
+
function isValidIntegrityHash(value) {
|
|
97
|
+
if (typeof value !== 'string' || value.trim() === '') return false;
|
|
98
|
+
return value.trim().split(/\s+/).every(token => SRI_TOKEN_REGEX.test(token));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Returns true when any token in a (valid) integrity value uses the legacy sha1
|
|
102
|
+
// algorithm. Callers should emit a warning (not an error) suggesting an upgrade.
|
|
103
|
+
function hasLegacySha1(value) {
|
|
104
|
+
if (typeof value !== 'string') return false;
|
|
105
|
+
return value.trim().split(/\s+/).some(token => token.startsWith('sha1-'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Classify a dependency entry by shape. Returns 'leaf' for string/boolean leaves,
|
|
109
|
+
// 'object' for valid objects, or 'invalid' for anything that isn't an object.
|
|
110
|
+
function classifyDependencyEntry(dep) {
|
|
111
|
+
if (typeof dep === 'string' || typeof dep === 'boolean') {
|
|
112
|
+
return 'leaf';
|
|
113
|
+
}
|
|
114
|
+
if (typeof dep !== 'object' || Array.isArray(dep)) {
|
|
115
|
+
return 'invalid';
|
|
116
|
+
}
|
|
117
|
+
return 'object';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate a single dependency entry; recurses into nested dependencies.
|
|
121
|
+
// warnings is threaded through so sha1 hashes can be flagged without an error.
|
|
122
|
+
function validateDependencyEntry(name, dep, errors, warnings, depth) {
|
|
123
|
+
if (dep == null) {
|
|
124
|
+
errors.push(new ValidationError(`Dependency ${name} is not an object`, 'INVALID_DEPENDENCY'));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const kind = classifyDependencyEntry(dep);
|
|
129
|
+
if (kind === 'invalid') {
|
|
130
|
+
errors.push(new ValidationError(`Dependency ${name} is not an object`, 'INVALID_DEPENDENCY'));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (kind === 'leaf') {
|
|
134
|
+
// Top-level string/boolean entries are missing required fields; nested
|
|
135
|
+
// string/boolean entries are valid range/flag leaves.
|
|
136
|
+
if (depth === 0) {
|
|
137
|
+
errors.push(new ValidationError(`Missing or invalid version for ${name}`, 'MISSING_DEP_VERSION'));
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate version when dependency is an object
|
|
143
|
+
if (!dep.version || typeof dep.version !== 'string') {
|
|
144
|
+
errors.push(new ValidationError(`Missing or invalid version for ${name}`, 'MISSING_DEP_VERSION'));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Validate integrity on dependency objects if present.
|
|
148
|
+
// sha1 is accepted as structurally valid but emits a LEGACY_INTEGRITY warning.
|
|
149
|
+
if (dep.integrity) {
|
|
150
|
+
if (!isValidIntegrityHash(dep.integrity)) {
|
|
151
|
+
errors.push(new ValidationError(`Invalid integrity hash for dependency ${name}`, 'INVALID_INTEGRITY'));
|
|
152
|
+
} else if (hasLegacySha1(dep.integrity)) {
|
|
153
|
+
warnings.push({ code: 'LEGACY_INTEGRITY', message: `Dependency ${name} uses sha1 integrity; run upgrade-hashes to convert to sha512` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (dep.dependencies) {
|
|
158
|
+
validateDependenciesTree(dep.dependencies, errors, warnings, depth + 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateDependenciesTree(dependencies, errors, warnings, depth = 0) {
|
|
163
|
+
for (const [name, dep] of Object.entries(dependencies)) {
|
|
164
|
+
validateDependencyEntry(name, dep, errors, warnings, depth);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Validate the integrity field (and the allowMissingIntegrity policy) for a package entry.
|
|
169
|
+
// sha1 integrity is accepted as structurally valid but emits a LEGACY_INTEGRITY warning.
|
|
170
|
+
function validatePackageIntegrity(path, pkg, errors, warnings, options) {
|
|
171
|
+
if (pkg.integrity) {
|
|
172
|
+
if (!isValidIntegrityHash(pkg.integrity)) {
|
|
173
|
+
errors.push(new ValidationError(`Invalid integrity hash for package at ${path}`, 'INVALID_INTEGRITY'));
|
|
174
|
+
} else if (hasLegacySha1(pkg.integrity)) {
|
|
175
|
+
warnings.push({ code: 'LEGACY_INTEGRITY', message: `Package at ${path} uses sha1 integrity; run upgrade-hashes to convert to sha512` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (options.allowMissingIntegrity === false && !pkg.integrity) {
|
|
179
|
+
// Treat missing integrity as an error when not allowed, and also record a warning
|
|
180
|
+
errors.push(new ValidationError(`Missing integrity hash for package at ${path}`, 'MISSING_INTEGRITY'));
|
|
181
|
+
warnings.push({ code: 'MISSING_INTEGRITY', message: `Missing integrity hash for package at ${path}` });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Validate the resolved-URL scheme for a package entry.
|
|
186
|
+
// Guards typeof before calling startsWith to avoid a crash on corrupted
|
|
187
|
+
// non-string resolved values (e.g. "resolved": 42 from a bad merge).
|
|
188
|
+
function validatePackageResolved(path, pkg, warnings) {
|
|
189
|
+
if (!pkg.resolved) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (typeof pkg.resolved !== 'string') {
|
|
193
|
+
warnings.push({ code: 'INVALID_RESOLVED', message: `Invalid resolved URL for package at ${path}: ${pkg.resolved}` });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const validSchemes = ['https://', 'http://', 'git+', 'git://', 'file:'];
|
|
197
|
+
const hasValidScheme = validSchemes.some(scheme => pkg.resolved.startsWith(scheme));
|
|
198
|
+
if (!hasValidScheme) {
|
|
199
|
+
warnings.push({ code: 'INVALID_RESOLVED', message: `Invalid resolved URL for package at ${path}: ${pkg.resolved}` });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Recurse into each dependency section of a package entry. packages-map
|
|
204
|
+
// dependency values are version-range strings (depth 1 allows string/boolean
|
|
205
|
+
// leaves), unlike the v1 top-level tree.
|
|
206
|
+
function validatePackageDependencySections(pkg, errors, warnings) {
|
|
207
|
+
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
208
|
+
if (pkg[section]) {
|
|
209
|
+
validateDependenciesTree(pkg[section], errors, warnings, 1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Validate a single packages-map entry.
|
|
215
|
+
function validatePackageEntry(path, pkg, errors, warnings, options) {
|
|
216
|
+
if (!pkg || typeof pkg !== 'object') {
|
|
217
|
+
errors.push(new ValidationError(`Package at ${path} is not an object`, 'INVALID_PACKAGE'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// npm only writes `name` on the root entry (and aliased installs);
|
|
221
|
+
// requiring it elsewhere would reject every real lockfile
|
|
222
|
+
if (path === '' && (!pkg.name || typeof pkg.name !== 'string')) {
|
|
223
|
+
errors.push(new ValidationError(`Missing or invalid name for package at ${path}`, 'INVALID_PACKAGE_NAME'));
|
|
224
|
+
}
|
|
225
|
+
// link entries (`link: true`) carry no version by design
|
|
226
|
+
if (!pkg.link && (!pkg.version || typeof pkg.version !== 'string')) {
|
|
227
|
+
errors.push(new ValidationError(`Missing or invalid version for package at ${path}`, 'INVALID_PACKAGE_VERSION'));
|
|
228
|
+
}
|
|
229
|
+
validatePackageIntegrity(path, pkg, errors, warnings, options);
|
|
230
|
+
validatePackageResolved(path, pkg, warnings);
|
|
231
|
+
validatePackageDependencySections(pkg, errors, warnings);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validatePackagesMap(packages, errors, warnings, options) {
|
|
235
|
+
for (const [path, pkg] of Object.entries(packages)) {
|
|
236
|
+
validatePackageEntry(path, pkg, errors, warnings, options);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check that every package.json dependency in a section is present in the
|
|
241
|
+
// lockfile root entry's matching section (v2/v3 packages map path).
|
|
242
|
+
function checkDependencySectionInLockfile(rootEntry, packageJson, section, label, code, errors) {
|
|
243
|
+
const lockDeps = rootEntry && rootEntry[section];
|
|
244
|
+
const pkgDeps = packageJson[section] || {};
|
|
245
|
+
for (const [name] of Object.entries(pkgDeps)) {
|
|
246
|
+
if (!lockDeps || !lockDeps[name]) {
|
|
247
|
+
errors.push(new ValidationError(`Missing ${label} ${name} in lockfile`, code));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// v1 lockfile cross-check: each package.json dep section is checked against
|
|
253
|
+
// the top-level dependencies tree (v1 hoists all deps to the top level).
|
|
254
|
+
function checkDependencySectionInV1Tree(v1Deps, packageJson, section, label, code, errors) {
|
|
255
|
+
const pkgDeps = packageJson[section] || {};
|
|
256
|
+
for (const [name] of Object.entries(pkgDeps)) {
|
|
257
|
+
if (!v1Deps[name]) {
|
|
258
|
+
errors.push(new ValidationError(`Missing ${label} ${name} in lockfile`, code));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function validateAgainstPackageJson(lockfile, packageJson, errors) {
|
|
264
|
+
const rootEntry = lockfile.packages && lockfile.packages[''];
|
|
265
|
+
|
|
266
|
+
if (!rootEntry) {
|
|
267
|
+
// v1 lockfile (or a degenerate v2/v3 missing the root packages[''] entry):
|
|
268
|
+
// fall back to the top-level dependencies tree for the cross-check.
|
|
269
|
+
if (lockfile.dependencies) {
|
|
270
|
+
checkDependencySectionInV1Tree(lockfile.dependencies, packageJson, 'dependencies', 'dependency', 'MISSING_IN_LOCKFILE', errors);
|
|
271
|
+
checkDependencySectionInV1Tree(lockfile.dependencies, packageJson, 'devDependencies', 'devDependency', 'MISSING_DEV_IN_LOCKFILE', errors);
|
|
272
|
+
checkDependencySectionInV1Tree(lockfile.dependencies, packageJson, 'optionalDependencies', 'optionalDependency', 'MISSING_OPT_IN_LOCKFILE', errors);
|
|
273
|
+
checkDependencySectionInV1Tree(lockfile.dependencies, packageJson, 'peerDependencies', 'peerDependency', 'MISSING_PEER_IN_LOCKFILE', errors);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
checkDependencySectionInLockfile(rootEntry, packageJson, 'dependencies', 'dependency', 'MISSING_IN_LOCKFILE', errors);
|
|
279
|
+
checkDependencySectionInLockfile(rootEntry, packageJson, 'devDependencies', 'devDependency', 'MISSING_DEV_IN_LOCKFILE', errors);
|
|
280
|
+
checkDependencySectionInLockfile(rootEntry, packageJson, 'optionalDependencies', 'optionalDependency', 'MISSING_OPT_IN_LOCKFILE', errors);
|
|
281
|
+
checkDependencySectionInLockfile(rootEntry, packageJson, 'peerDependencies', 'peerDependency', 'MISSING_PEER_IN_LOCKFILE', errors);
|
|
282
|
+
}
|