@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/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
+ }
@@ -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
+ }