@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/pruner.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/pruner.js
|
|
2
|
+
import { detectLockfileVersion, LOCKFILE_VERSIONS } from './format-library.js';
|
|
3
|
+
|
|
4
|
+
export class PrunerError extends Error {
|
|
5
|
+
constructor(message, code, context = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'PrunerError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.context = context;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ROOT_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
14
|
+
// npm 7+ auto-installs peers, so they count toward reachability everywhere
|
|
15
|
+
const PACKAGE_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve where npm would look up dependency `name` required from the package
|
|
19
|
+
* at `key`, following node_modules resolution: the package's own node_modules,
|
|
20
|
+
* then each ancestor's, ending at the top level.
|
|
21
|
+
* @returns {string[]} Candidate keys, nearest first
|
|
22
|
+
*/
|
|
23
|
+
function resolutionCandidates(key, name) {
|
|
24
|
+
const candidates = [];
|
|
25
|
+
let host = key;
|
|
26
|
+
for (;;) {
|
|
27
|
+
candidates.push(host === '' ? `node_modules/${name}` : `${host}/node_modules/${name}`);
|
|
28
|
+
if (host === '') break;
|
|
29
|
+
const idx = host.lastIndexOf('/node_modules/');
|
|
30
|
+
if (idx === -1) {
|
|
31
|
+
// workspace dir (e.g. 'packages/foo') or top-level 'node_modules/x' → root next
|
|
32
|
+
host = '';
|
|
33
|
+
} else {
|
|
34
|
+
host = host.slice(0, idx);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return candidates;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A root-like entry is the project itself ('') or a workspace source dir —
|
|
42
|
+
* anything not installed under a node_modules/ path.
|
|
43
|
+
*/
|
|
44
|
+
function isRootLike(key) {
|
|
45
|
+
return key === '' || !key.includes('node_modules/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mark `target` reachable and enqueue it, but only the first time it is seen
|
|
50
|
+
* and only when it exists in the packages map.
|
|
51
|
+
*/
|
|
52
|
+
function visit(target, packages, reachable, queue) {
|
|
53
|
+
if (packages[target] === undefined || reachable.has(target)) return;
|
|
54
|
+
reachable.add(target);
|
|
55
|
+
queue.push(target);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Follow the node_modules resolution chain for dependency `name` required from
|
|
60
|
+
* `key`, marking the nearest existing match reachable (like node resolution).
|
|
61
|
+
*/
|
|
62
|
+
function visitDependency(key, name, packages, reachable, queue) {
|
|
63
|
+
for (const candidate of resolutionCandidates(key, name)) {
|
|
64
|
+
if (packages[candidate] !== undefined) {
|
|
65
|
+
visit(candidate, packages, reachable, queue);
|
|
66
|
+
break; // nearest hit wins, like node resolution
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Process one dequeued entry: follow its link target (if any) and every
|
|
73
|
+
* dependency edge in the sections that apply to its position in the tree.
|
|
74
|
+
*/
|
|
75
|
+
function walkEntry(key, entry, packages, reachable, queue) {
|
|
76
|
+
// Link entries point at their target (usually a workspace dir)
|
|
77
|
+
if (entry.link && typeof entry.resolved === 'string') {
|
|
78
|
+
visit(entry.resolved, packages, reachable, queue);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sections = isRootLike(key) ? ROOT_SECTIONS : PACKAGE_SECTIONS;
|
|
82
|
+
for (const section of sections) {
|
|
83
|
+
const deps = entry[section];
|
|
84
|
+
if (!deps || typeof deps !== 'object') continue;
|
|
85
|
+
for (const name of Object.keys(deps)) {
|
|
86
|
+
visitDependency(key, name, packages, reachable, queue);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Derive the package name for an orphan entry: prefer its explicit `.name`,
|
|
93
|
+
* otherwise the last node_modules/ path segment.
|
|
94
|
+
*/
|
|
95
|
+
function orphanName(key, entry) {
|
|
96
|
+
if (entry && entry.name) return entry.name;
|
|
97
|
+
return key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute which packages-map entries are reachable from the root package and
|
|
102
|
+
* workspace entries by following dependency edges with npm's resolution rules.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} lockfile - Parsed v2/v3 lockfile
|
|
105
|
+
* @returns {{reachable: Set<string>, orphans: Array<{key, name, version}>}}
|
|
106
|
+
*/
|
|
107
|
+
export function findOrphanedPackages(lockfile) {
|
|
108
|
+
if (detectLockfileVersion(lockfile) === LOCKFILE_VERSIONS.V1) {
|
|
109
|
+
throw new PrunerError(
|
|
110
|
+
'v1 lockfiles are not supported; run `npm-check migrate 3` first',
|
|
111
|
+
'UNSUPPORTED_VERSION'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const packages = lockfile.packages || {};
|
|
116
|
+
const reachable = new Set();
|
|
117
|
+
const queue = [];
|
|
118
|
+
|
|
119
|
+
// Roots: the project itself plus workspace source entries
|
|
120
|
+
for (const key of Object.keys(packages)) {
|
|
121
|
+
if (isRootLike(key)) {
|
|
122
|
+
reachable.add(key);
|
|
123
|
+
queue.push(key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
while (queue.length > 0) {
|
|
128
|
+
const key = queue.shift();
|
|
129
|
+
const entry = packages[key];
|
|
130
|
+
if (entry) walkEntry(key, entry, packages, reachable, queue);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const orphans = [];
|
|
134
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
135
|
+
if (!reachable.has(key)) {
|
|
136
|
+
orphans.push({ key, name: orphanName(key, entry), version: entry ? entry.version : undefined });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { reachable, orphans };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Remove orphaned (unreachable) entries from the packages map.
|
|
145
|
+
* v2 lockfiles keep their legacy dependencies tree untouched (npm regenerates
|
|
146
|
+
* it on install); a warning is emitted recommending migration to v3.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} lockfile - Parsed v2/v3 lockfile
|
|
149
|
+
* @returns {{lockfile, removed: Array<{key, name, version}>, warnings: string[]}}
|
|
150
|
+
*/
|
|
151
|
+
export function prunePackages(lockfile) {
|
|
152
|
+
const { orphans } = findOrphanedPackages(lockfile);
|
|
153
|
+
const warnings = [];
|
|
154
|
+
|
|
155
|
+
if (orphans.length === 0) {
|
|
156
|
+
return { lockfile, removed: [], warnings };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const removedKeys = new Set(orphans.map((o) => o.key));
|
|
160
|
+
const packages = {};
|
|
161
|
+
for (const [key, entry] of Object.entries(lockfile.packages)) {
|
|
162
|
+
if (!removedKeys.has(key)) {
|
|
163
|
+
packages[key] = entry;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const pruned = { ...lockfile, packages };
|
|
168
|
+
|
|
169
|
+
if (detectLockfileVersion(lockfile) === LOCKFILE_VERSIONS.V2 && lockfile.dependencies) {
|
|
170
|
+
warnings.push(
|
|
171
|
+
'v2 legacy dependencies tree was left untouched (npm regenerates it on install); ' +
|
|
172
|
+
'consider `npm-check migrate 3` to drop it entirely'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { lockfile: pruned, removed: orphans, warnings };
|
|
177
|
+
}
|
package/src/remediate.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// src/remediate.js
|
|
2
|
+
// Dependency remediation: turns the deprecated/vulnerable findings into action.
|
|
3
|
+
//
|
|
4
|
+
// Scope (deliberate): npm-check is lockfile-first and does not re-resolve the
|
|
5
|
+
// dependency graph — that is `npm install`'s job. So remediation here bumps the
|
|
6
|
+
// *direct* dependencies in package.json that are themselves deprecated or carry
|
|
7
|
+
// an advisory at/above the severity threshold, rewriting their range to the
|
|
8
|
+
// registry's latest version and syncing the lockfile root. Transitive findings
|
|
9
|
+
// (a flagged package that is not a direct dependency) are reported as guidance
|
|
10
|
+
// only — they clear when their parent is upgraded or via npm `overrides`. After
|
|
11
|
+
// writing, the caller must run `npm install` to materialize the new tree.
|
|
12
|
+
import { checkDeprecations } from './deprecation.js';
|
|
13
|
+
import { checkVulnerabilities } from './vuln.js';
|
|
14
|
+
import { classifyRange } from './pinner.js';
|
|
15
|
+
import { forEachPackageEntry } from './format-library.js';
|
|
16
|
+
import { deriveRegistryBase, DEFAULT_REGISTRY, fetchLatestVersion } from './integrity.js';
|
|
17
|
+
import { buildEnvelope } from './schema.js';
|
|
18
|
+
|
|
19
|
+
export class RemediationError extends Error {
|
|
20
|
+
constructor(message, code, context = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'RemediationError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.context = context;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rewrite a range to target `version`, preserving the existing operator
|
|
32
|
+
* (exact stays exact, ^ stays ^, ~ stays ~).
|
|
33
|
+
*/
|
|
34
|
+
function rewriteRange(currentRange, version, rangeType) {
|
|
35
|
+
if (rangeType === 'caret') return `^${version}`;
|
|
36
|
+
if (rangeType === 'tilde') return `~${version}`;
|
|
37
|
+
return version; // exact
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Index the lockfile's top-level installed versions by package name
|
|
42
|
+
* (nearest install, i.e. `node_modules/<name>`), so we know each direct
|
|
43
|
+
* dependency's currently-resolved version.
|
|
44
|
+
*/
|
|
45
|
+
function topLevelVersions(lockfile) {
|
|
46
|
+
const versions = new Map();
|
|
47
|
+
forEachPackageEntry(lockfile, ({ key, entry, name }) => {
|
|
48
|
+
if (key.startsWith('node_modules/') && key.indexOf('/node_modules/', 'node_modules/'.length) === -1) {
|
|
49
|
+
if (entry.version) versions.set(name, { version: entry.version, resolved: entry.resolved });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return versions;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate inputs, throwing the appropriate RemediationError on failure.
|
|
57
|
+
*/
|
|
58
|
+
function assertValidInputs(lockfile, packageJson) {
|
|
59
|
+
if (!lockfile || typeof lockfile !== 'object') {
|
|
60
|
+
throw new RemediationError('lockfile data is required', 'MISSING_LOCKFILE');
|
|
61
|
+
}
|
|
62
|
+
if (!packageJson || typeof packageJson !== 'object') {
|
|
63
|
+
throw new RemediationError('package.json data is required', 'MISSING_PACKAGE_JSON');
|
|
64
|
+
}
|
|
65
|
+
if (lockfile.lockfileVersion === 1) {
|
|
66
|
+
throw new RemediationError('v1 lockfiles are not supported; run `npm-check migrate 3` first', 'UNSUPPORTED_VERSION');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the deprecation + vulnerability scanners and collapse their findings into
|
|
72
|
+
* a map of flagged package name → Set of reasons. Deprecated (any) + vulnerable
|
|
73
|
+
* at/above threshold (vuln errors carry an advisoryId; below-threshold ones are
|
|
74
|
+
* warnings and skipped).
|
|
75
|
+
*/
|
|
76
|
+
async function gatherFlagged(lockfile, options) {
|
|
77
|
+
const { includeDeprecated, timeoutMs, concurrency, defaultRegistry, minSeverity, fetchManifest, fetchAdvisories, onProgress } = options;
|
|
78
|
+
|
|
79
|
+
// remediate gathers *actual* findings to bump — it is not a CI gate, so it opts
|
|
80
|
+
// out of the scanners' fail-closed default (failOnUnresolved). Otherwise an
|
|
81
|
+
// unreachable-registry entry would land in `errors` and be mistaken for a finding.
|
|
82
|
+
const [deprecationResult, vulnResult] = await Promise.all([
|
|
83
|
+
includeDeprecated
|
|
84
|
+
? checkDeprecations(lockfile, { timeoutMs, concurrency, defaultRegistry, fetchManifest, onProgress, failOnUnresolved: false })
|
|
85
|
+
: Promise.resolve({ warnings: [], errors: [] }),
|
|
86
|
+
checkVulnerabilities(lockfile, { timeoutMs, concurrency, defaultRegistry, minSeverity, fetchAdvisories, onProgress, failOnUnresolved: false })
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const flagged = new Map(); // name -> Set of reasons
|
|
90
|
+
const flag = (name, reason) => {
|
|
91
|
+
if (!flagged.has(name)) flagged.set(name, new Set());
|
|
92
|
+
flagged.get(name).add(reason);
|
|
93
|
+
};
|
|
94
|
+
if (includeDeprecated) {
|
|
95
|
+
for (const w of [...deprecationResult.warnings, ...deprecationResult.errors]) {
|
|
96
|
+
if (w.package) flag(w.package, 'deprecated');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const e of vulnResult.errors) {
|
|
100
|
+
if (e.advisoryId && e.package) flag(e.package, 'vulnerable');
|
|
101
|
+
}
|
|
102
|
+
return flagged;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Map package.json's declared dependencies (across all sections) to their
|
|
107
|
+
* first-seen { section, range }, keyed by name.
|
|
108
|
+
*/
|
|
109
|
+
function indexDirectDeps(packageJson) {
|
|
110
|
+
const directDeps = new Map(); // name -> { section, range }
|
|
111
|
+
for (const section of DEP_SECTIONS) {
|
|
112
|
+
const deps = packageJson[section];
|
|
113
|
+
if (!deps || typeof deps !== 'object') continue;
|
|
114
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
115
|
+
if (!directDeps.has(name)) directDeps.set(name, { section, range });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return directDeps;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the registry's latest version for a flagged dep, using the injected
|
|
123
|
+
* fetcher when provided. Returns { latest } on success, or { warning } when the
|
|
124
|
+
* registry is unreachable or has no latest version.
|
|
125
|
+
*/
|
|
126
|
+
async function resolveLatest(name, current, options) {
|
|
127
|
+
const { fetchLatest, defaultRegistry, timeoutMs } = options;
|
|
128
|
+
const registryBase = (current && deriveRegistryBase(current.resolved, name)) || defaultRegistry;
|
|
129
|
+
let latest;
|
|
130
|
+
try {
|
|
131
|
+
latest = fetchLatest
|
|
132
|
+
? await fetchLatest(name, registryBase)
|
|
133
|
+
: await fetchLatestVersion(name, { registryBase, timeoutMs });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { warning: { package: name, reason: `registry unreachable (${e.message})` } };
|
|
136
|
+
}
|
|
137
|
+
if (!latest) {
|
|
138
|
+
return { warning: { package: name, reason: 'registry has no latest version' } };
|
|
139
|
+
}
|
|
140
|
+
return { latest };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decide what to do with a single flagged name and push the outcome onto the
|
|
145
|
+
* appropriate result bucket. Bumps a simple-ranged direct dep; otherwise records
|
|
146
|
+
* guidance (transitive / latest-still-affected), a skip, or a warning.
|
|
147
|
+
*/
|
|
148
|
+
async function processFlagged(name, reasons, ctx) {
|
|
149
|
+
const { directDeps, installed, buckets, options } = ctx;
|
|
150
|
+
const reasonList = [...reasons];
|
|
151
|
+
const direct = directDeps.get(name);
|
|
152
|
+
|
|
153
|
+
if (!direct) {
|
|
154
|
+
// Transitive — can't bump a range we don't own; report as guidance.
|
|
155
|
+
buckets.guidance.push({ package: name, reasons: reasonList, kind: 'transitive' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const rangeType = classifyRange(direct.range);
|
|
160
|
+
if (!['exact', 'caret', 'tilde'].includes(rangeType)) {
|
|
161
|
+
buckets.skipped.push({ package: name, section: direct.section, range: direct.range, reasons: reasonList, reason: `${rangeType} range — bump manually` });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const current = installed.get(name);
|
|
166
|
+
const { latest, warning } = await resolveLatest(name, current, options);
|
|
167
|
+
if (warning) {
|
|
168
|
+
buckets.warnings.push(warning);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// If the currently-installed version is already at registry latest, bumping
|
|
173
|
+
// the range string won't change what `npm install` resolves — it is a no-op
|
|
174
|
+
// fix. This check must happen before the range-string comparison because
|
|
175
|
+
// a range like `^1.0.0` resolved to `1.4.2` (== latest) would produce a
|
|
176
|
+
// rewritten range of `^1.4.2` which differs from `^1.0.0`, misleading the
|
|
177
|
+
// old range-string check into classifying a no-op as a genuine bump.
|
|
178
|
+
if (current && current.version === latest) {
|
|
179
|
+
buckets.guidance.push({ package: name, reasons: reasonList, kind: 'latest-still-affected', range: direct.range });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const newRange = rewriteRange(direct.range, latest, rangeType);
|
|
184
|
+
if (newRange === direct.range) {
|
|
185
|
+
// Range string already points at latest yet still flagged; guide.
|
|
186
|
+
buckets.guidance.push({ package: name, reasons: reasonList, kind: 'latest-still-affected', range: direct.range });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
buckets.bumped.push({
|
|
191
|
+
package: name,
|
|
192
|
+
section: direct.section,
|
|
193
|
+
from: direct.range,
|
|
194
|
+
to: newRange,
|
|
195
|
+
latest,
|
|
196
|
+
fromVersion: current ? current.version : null,
|
|
197
|
+
reasons: reasonList
|
|
198
|
+
});
|
|
199
|
+
// `latest` is a mutable, maintainer-controlled pointer: the bump pulls a
|
|
200
|
+
// version that hasn't itself been scanned. Flag it so the caller re-runs the
|
|
201
|
+
// scanners after `npm install` rather than assuming the bump is clean.
|
|
202
|
+
buckets.warnings.push({ package: name, reason: `bumped to unvetted latest (${latest}) — re-run \`npm-check\` after \`npm install\` to confirm it isn't itself vulnerable/deprecated` });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Apply the planned bumps to a fresh package.json + a copied lockfile, syncing
|
|
207
|
+
* the lockfile root entry's ranges. Returns the new pair.
|
|
208
|
+
*/
|
|
209
|
+
function applyBumps(lockfile, packageJson, bumped) {
|
|
210
|
+
const newPackageJson = JSON.parse(JSON.stringify(packageJson));
|
|
211
|
+
const newLockfile = { ...lockfile };
|
|
212
|
+
if (bumped.length > 0 && newLockfile.packages && newLockfile.packages['']) {
|
|
213
|
+
newLockfile.packages = { ...newLockfile.packages, '': { ...newLockfile.packages[''] } };
|
|
214
|
+
}
|
|
215
|
+
for (const b of bumped) {
|
|
216
|
+
newPackageJson[b.section] = { ...newPackageJson[b.section], [b.package]: b.to };
|
|
217
|
+
const root = newLockfile.packages && newLockfile.packages[''];
|
|
218
|
+
if (root && root[b.section] && Object.prototype.hasOwnProperty.call(root[b.section], b.package)) {
|
|
219
|
+
root[b.section] = { ...root[b.section], [b.package]: b.to };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { newPackageJson, newLockfile };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Plan (and optionally apply) remediation bumps for flagged direct dependencies.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} lockfile - Parsed lockfile (v2/v3)
|
|
229
|
+
* @param {object} packageJson - Parsed package.json
|
|
230
|
+
* @param {object} options
|
|
231
|
+
* @param {string} options.minSeverity - Advisory threshold that counts a dep as vulnerable (default: 'high')
|
|
232
|
+
* @param {boolean} options.includeDeprecated - Treat deprecated direct deps as remediation targets (default: true)
|
|
233
|
+
* @param {number} options.timeoutMs / options.concurrency / options.defaultRegistry
|
|
234
|
+
* @param {Function} options.fetchManifest / options.fetchAdvisories - Injected scan transports (tests)
|
|
235
|
+
* @param {Function} options.fetchLatest - Injected (name, registryBase) => Promise<version|null> (tests)
|
|
236
|
+
* @param {Function} options.onProgress
|
|
237
|
+
* @returns {Promise<object>} { packageJson, lockfile, bumped, guidance, skipped, warnings, changed }
|
|
238
|
+
*/
|
|
239
|
+
export async function remediateDependencies(lockfile, packageJson, options = {}) {
|
|
240
|
+
const {
|
|
241
|
+
minSeverity = 'high',
|
|
242
|
+
includeDeprecated = true,
|
|
243
|
+
timeoutMs = 10000,
|
|
244
|
+
concurrency = 8,
|
|
245
|
+
defaultRegistry = DEFAULT_REGISTRY,
|
|
246
|
+
fetchManifest = null,
|
|
247
|
+
fetchAdvisories = null,
|
|
248
|
+
fetchLatest = null,
|
|
249
|
+
onProgress = null
|
|
250
|
+
} = options;
|
|
251
|
+
const resolved = { minSeverity, includeDeprecated, timeoutMs, concurrency, defaultRegistry, fetchManifest, fetchAdvisories, fetchLatest, onProgress };
|
|
252
|
+
|
|
253
|
+
assertValidInputs(lockfile, packageJson);
|
|
254
|
+
|
|
255
|
+
// 1. Gather findings (reuse the existing scanners; transports are injectable for tests).
|
|
256
|
+
const flagged = await gatherFlagged(lockfile, resolved);
|
|
257
|
+
|
|
258
|
+
// 2. Map flagged names to direct dependencies in package.json.
|
|
259
|
+
const directDeps = indexDirectDeps(packageJson);
|
|
260
|
+
const installed = topLevelVersions(lockfile);
|
|
261
|
+
|
|
262
|
+
// 3. For each flagged name: bump if it's a simple-ranged direct dep, else guide.
|
|
263
|
+
const buckets = { bumped: [], guidance: [], skipped: [], warnings: [] };
|
|
264
|
+
const ctx = { directDeps, installed, buckets, options: resolved };
|
|
265
|
+
for (const [name, reasons] of flagged) {
|
|
266
|
+
await processFlagged(name, reasons, ctx);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 4. Apply bumps to a fresh package.json + sync the lockfile root entry.
|
|
270
|
+
const { newPackageJson, newLockfile } = applyBumps(lockfile, packageJson, buckets.bumped);
|
|
271
|
+
|
|
272
|
+
// Stable ordering for readable output.
|
|
273
|
+
buckets.bumped.sort((a, b) => a.package.localeCompare(b.package));
|
|
274
|
+
buckets.guidance.sort((a, b) => a.package.localeCompare(b.package));
|
|
275
|
+
buckets.skipped.sort((a, b) => a.package.localeCompare(b.package));
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
packageJson: newPackageJson,
|
|
279
|
+
lockfile: newLockfile,
|
|
280
|
+
bumped: buckets.bumped,
|
|
281
|
+
guidance: buckets.guidance,
|
|
282
|
+
skipped: buckets.skipped,
|
|
283
|
+
warnings: buckets.warnings,
|
|
284
|
+
changed: buckets.bumped.length > 0
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A remediation finding's reason set decides its category/severity/ruleId. A vuln
|
|
289
|
+
// reason outranks a deprecation (it cleared the severity threshold, default high);
|
|
290
|
+
// a deprecation-only finding is a soft `low` signal. remediate's buckets don't
|
|
291
|
+
// retain the advisory's own id/severity, so the ruleId is a stable category id.
|
|
292
|
+
function severityForReasons(reasons) {
|
|
293
|
+
return reasons.includes('vulnerable') ? 'high' : 'low';
|
|
294
|
+
}
|
|
295
|
+
function categoryForReasons(reasons) {
|
|
296
|
+
return reasons.includes('vulnerable') ? 'vulnerability' : 'deprecated';
|
|
297
|
+
}
|
|
298
|
+
function ruleIdForReasons(reasons) {
|
|
299
|
+
return reasons.includes('vulnerable') ? 'vulnerable' : 'deprecated';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Wrap a remediateDependencies() result in the shared finding-schema envelope.
|
|
304
|
+
* Each flagged direct/transitive dependency becomes one Finding: a planned bump,
|
|
305
|
+
* transitive/latest-still-affected guidance, or a manual-range skip. Operational
|
|
306
|
+
* warnings (unreachable registry, unvetted-latest) and the `changed` flag ride
|
|
307
|
+
* under `extra`. Mirrors vuln.js's vulnEnvelope.
|
|
308
|
+
*
|
|
309
|
+
* @param {object} result - a remediateDependencies() result
|
|
310
|
+
* @param {object} meta
|
|
311
|
+
* @param {string} meta.target - the directory/manifest scanned, as given
|
|
312
|
+
* @param {number} [meta.scanned] - packages examined (0 when unknown)
|
|
313
|
+
* @param {number} [meta.exitCode]- the real process exit code (default 0)
|
|
314
|
+
* @returns {object} the shared envelope
|
|
315
|
+
*/
|
|
316
|
+
export function remediateEnvelope(result, { target, scanned = 0, exitCode = 0 } = {}) {
|
|
317
|
+
const findings = [];
|
|
318
|
+
|
|
319
|
+
for (const b of result.bumped) {
|
|
320
|
+
findings.push({
|
|
321
|
+
severity: severityForReasons(b.reasons),
|
|
322
|
+
ruleId: ruleIdForReasons(b.reasons),
|
|
323
|
+
category: categoryForReasons(b.reasons),
|
|
324
|
+
message: `${b.package} ${b.from} → ${b.to} (${b.reasons.join(', ')})`,
|
|
325
|
+
location: null,
|
|
326
|
+
remediation: `upgrade to ${b.latest}`,
|
|
327
|
+
extra: {
|
|
328
|
+
package: b.package,
|
|
329
|
+
installedVersion: b.fromVersion ?? null,
|
|
330
|
+
fixedVersion: b.latest,
|
|
331
|
+
section: b.section,
|
|
332
|
+
action: 'bump',
|
|
333
|
+
reasons: b.reasons
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const g of result.guidance) {
|
|
339
|
+
const isLatest = g.kind === 'latest-still-affected';
|
|
340
|
+
findings.push({
|
|
341
|
+
severity: severityForReasons(g.reasons),
|
|
342
|
+
ruleId: ruleIdForReasons(g.reasons),
|
|
343
|
+
category: categoryForReasons(g.reasons),
|
|
344
|
+
message: `${g.package}: ${g.reasons.join(', ')}${isLatest ? '; latest still affected' : ' (transitive)'}`,
|
|
345
|
+
location: null,
|
|
346
|
+
remediation: isLatest
|
|
347
|
+
? `latest (${g.range}) is still affected — no fix available yet`
|
|
348
|
+
: 'upgrade the parent dependency or add an npm override',
|
|
349
|
+
extra: {
|
|
350
|
+
package: g.package,
|
|
351
|
+
action: 'guidance',
|
|
352
|
+
kind: g.kind,
|
|
353
|
+
reasons: g.reasons,
|
|
354
|
+
...(g.range ? { range: g.range } : {})
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const s of result.skipped) {
|
|
360
|
+
const reasons = Array.isArray(s.reasons) ? s.reasons : [];
|
|
361
|
+
findings.push({
|
|
362
|
+
severity: severityForReasons(reasons),
|
|
363
|
+
ruleId: ruleIdForReasons(reasons),
|
|
364
|
+
category: categoryForReasons(reasons),
|
|
365
|
+
message: `${s.section}/${s.package} (${s.range}): ${s.reason}`,
|
|
366
|
+
location: null,
|
|
367
|
+
remediation: 'bump the range manually',
|
|
368
|
+
extra: {
|
|
369
|
+
package: s.package,
|
|
370
|
+
section: s.section,
|
|
371
|
+
action: 'skip',
|
|
372
|
+
range: s.range,
|
|
373
|
+
reason: s.reason,
|
|
374
|
+
reasons
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return buildEnvelope({
|
|
380
|
+
target,
|
|
381
|
+
scanned,
|
|
382
|
+
findings,
|
|
383
|
+
exitCode,
|
|
384
|
+
extra: {
|
|
385
|
+
changed: result.changed,
|
|
386
|
+
warnings: result.warnings
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|