@openrewrite/recipes-nodejs 0.37.0-20260106-083133 → 0.37.0-20260106-170728

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.
Files changed (36) hide show
  1. package/dist/security/dependency-vulnerability-check.d.ts +8 -54
  2. package/dist/security/dependency-vulnerability-check.d.ts.map +1 -1
  3. package/dist/security/dependency-vulnerability-check.js +176 -287
  4. package/dist/security/dependency-vulnerability-check.js.map +1 -1
  5. package/dist/security/index.d.ts +3 -0
  6. package/dist/security/index.d.ts.map +1 -1
  7. package/dist/security/index.js +3 -0
  8. package/dist/security/index.js.map +1 -1
  9. package/dist/security/npm-utils.d.ts +8 -2
  10. package/dist/security/npm-utils.d.ts.map +1 -1
  11. package/dist/security/npm-utils.js +114 -14
  12. package/dist/security/npm-utils.js.map +1 -1
  13. package/dist/security/override-utils.d.ts +23 -0
  14. package/dist/security/override-utils.d.ts.map +1 -0
  15. package/dist/security/override-utils.js +169 -0
  16. package/dist/security/override-utils.js.map +1 -0
  17. package/dist/security/remove-redundant-overrides.d.ts +1 -10
  18. package/dist/security/remove-redundant-overrides.d.ts.map +1 -1
  19. package/dist/security/remove-redundant-overrides.js +4 -152
  20. package/dist/security/remove-redundant-overrides.js.map +1 -1
  21. package/dist/security/types.d.ts +42 -0
  22. package/dist/security/types.d.ts.map +1 -0
  23. package/dist/security/types.js +7 -0
  24. package/dist/security/types.js.map +1 -0
  25. package/dist/security/version-utils.d.ts +13 -0
  26. package/dist/security/version-utils.d.ts.map +1 -0
  27. package/dist/security/version-utils.js +173 -0
  28. package/dist/security/version-utils.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/security/dependency-vulnerability-check.ts +300 -525
  31. package/src/security/index.ts +3 -0
  32. package/src/security/npm-utils.ts +172 -37
  33. package/src/security/override-utils.ts +253 -0
  34. package/src/security/remove-redundant-overrides.ts +9 -211
  35. package/src/security/types.ts +115 -0
  36. package/src/security/version-utils.ts +198 -0
@@ -25,6 +25,7 @@ import {
25
25
  DependencyScope,
26
26
  findNodeResolutionResult,
27
27
  getUpdatedLockFileContent,
28
+ NodeResolutionResult,
28
29
  NpmrcScope,
29
30
  PackageManager,
30
31
  ResolvedDependency,
@@ -38,40 +39,32 @@ import * as semver from "semver";
38
39
  import * as path from "path";
39
40
  import {parseSeverity, Severity, severityOrdinal, Vulnerability, VulnerabilityDatabase} from "./vulnerability";
40
41
  import {
41
- findDirectUpgradeThatFixesTransitive,
42
- extractVersionFromLockFile,
42
+ findDirectUpgradeWithSafeTransitiveInIsolation,
43
+ verifyAllUpgradesFixTransitive,
44
+ extractAllVersionsFromLockFile,
43
45
  DependencyScope as NpmUtilsDependencyScope
44
46
  } from "./npm-utils";
45
-
46
- /**
47
- * All dependency scopes that can contain dependencies in package.json.
48
- */
49
- const ALL_DEPENDENCY_SCOPES: DependencyScope[] = [
50
- 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'
51
- ];
52
-
53
- /**
54
- * Maximum upgrade delta for version upgrades.
55
- * Use 'none' to only report vulnerabilities without making any changes.
56
- */
57
- export type UpgradeDelta = 'none' | 'patch' | 'minor' | 'major';
58
-
59
- /**
60
- * Strategy for handling transitive dependency vulnerabilities.
61
- *
62
- * - 'report': Report transitive vulnerabilities but don't fix them. (Default)
63
- * - 'override': Add overrides/resolutions for transitives that aren't fixed
64
- * by direct dependency upgrades. Runs an extra npm install
65
- * to check which transitives are still vulnerable after
66
- * applying direct fixes, then adds overrides only for those.
67
- * - 'prefer-direct-upgrade': First applies minimum direct dependency upgrades,
68
- * then for any remaining transitive vulnerabilities,
69
- * tries to find a higher direct dependency version
70
- * that fixes them. Falls back to overrides if no
71
- * suitable direct upgrade exists. Queries npm registry.
72
- * Most thorough, slowest.
73
- */
74
- export type TransitiveFixStrategy = 'report' | 'override' | 'prefer-direct-upgrade';
47
+ import {
48
+ UpgradeDelta,
49
+ extractMinimumVersion,
50
+ isVersionWithinDelta,
51
+ isVersionAffected,
52
+ isUpgradeableWithinDelta,
53
+ getUpgradeVersion,
54
+ applyVersionPrefix
55
+ } from "./version-utils";
56
+ import {
57
+ ALL_DEPENDENCY_SCOPES,
58
+ TransitiveFixStrategy,
59
+ PathSegment,
60
+ VulnerableDependency,
61
+ VulnerabilityFix,
62
+ ProjectUpdateInfo
63
+ } from "./types";
64
+ import {
65
+ findDirectDependencyScope,
66
+ getOverridesFromPackageJson
67
+ } from "./override-utils";
75
68
 
76
69
  /**
77
70
  * Row for the vulnerability report data table.
@@ -186,89 +179,6 @@ class VulnerabilityReportRow {
186
179
  }
187
180
  }
188
181
 
189
- /**
190
- * Represents a segment in the dependency path.
191
- */
192
- interface PathSegment {
193
- name: string;
194
- version: string;
195
- }
196
-
197
- /**
198
- * Represents a vulnerable dependency found during scanning.
199
- */
200
- interface VulnerableDependency {
201
- /** The resolved dependency that is vulnerable */
202
- resolved: ResolvedDependency;
203
- /** The vulnerability affecting it */
204
- vulnerability: Vulnerability;
205
- /** Depth in dependency tree (0 = direct) */
206
- depth: number;
207
- /** Whether this is a direct dependency */
208
- isDirect: boolean;
209
- /** The scope where this dependency was found (for direct deps) */
210
- scope?: DependencyScope;
211
- /** Path from root to this dependency */
212
- path: PathSegment[];
213
- }
214
-
215
- /**
216
- * Represents a fix to apply for a vulnerability.
217
- */
218
- interface VulnerabilityFix {
219
- /** Package name to upgrade (the vulnerable package) */
220
- packageName: string;
221
- /** Version to upgrade to */
222
- newVersion: string;
223
- /** Whether the vulnerable package is a transitive dependency */
224
- isTransitive: boolean;
225
- /** CVEs this fix resolves */
226
- cves: string[];
227
- /** CVE summaries for generating comments (CVE ID -> summary) */
228
- cveSummaries: Map<string, string>;
229
- /** The scope where this dependency was found (for direct deps) */
230
- scope?: DependencyScope;
231
- /** The original major version (for version-specific overrides) */
232
- originalMajorVersion?: number;
233
- /**
234
- * For transitive vulnerabilities, info about the direct dependency that brings
235
- * this transitive in. Used by prefer-direct-upgrade to find higher versions
236
- * of the direct dependency that might fix the transitive.
237
- */
238
- directDepInfo?: {
239
- name: string;
240
- version: string;
241
- scope: DependencyScope;
242
- };
243
- /**
244
- * If set, fix this transitive vulnerability by upgrading a direct dependency
245
- * instead of using overrides. This is preferred when a newer version of a
246
- * direct dependency includes a fixed version of the vulnerable transitive.
247
- */
248
- fixViaDirectUpgrade?: {
249
- /** Name of the direct dependency to upgrade */
250
- directDepName: string;
251
- /** Version to upgrade the direct dependency to */
252
- directDepVersion: string;
253
- /** Scope of the direct dependency */
254
- directDepScope: DependencyScope;
255
- };
256
- }
257
-
258
- /**
259
- * Project info for lock file updates.
260
- */
261
- interface ProjectUpdateInfo {
262
- /** Relative path to package.json (from source root) */
263
- packageJsonPath: string;
264
- /** Original package.json content */
265
- originalPackageJson: string;
266
- /** The package manager used by this project */
267
- packageManager: PackageManager;
268
- /** Config file contents extracted from the project (e.g., .npmrc) */
269
- configFiles?: Record<string, string>;
270
- }
271
-
272
182
  /**
273
183
  * Accumulator for the scanning recipe.
274
184
  */
@@ -324,7 +234,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
324
234
  @Option({
325
235
  displayName: "Scope",
326
236
  description: "Match dependencies with the specified scope. Default includes all scopes. " +
327
- "Use 'dependencies' for production dependencies, 'devDependencies' for development only, etc.",
237
+ "Use `dependencies` for production dependencies, `devDependencies` for development only, etc.",
328
238
  required: false,
329
239
  example: "dependencies",
330
240
  valid: ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
@@ -334,20 +244,31 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
334
244
  @Option({
335
245
  displayName: "Transitive fix strategy",
336
246
  description: "Strategy for handling transitive dependency vulnerabilities. " +
337
- "'report' only reports them without fixing. " +
338
- "'override' adds overrides only for transitives not fixed by direct upgrades (runs extra npm install to verify). " +
339
- "'prefer-direct-upgrade' tries to find higher direct dependency versions that fix transitives, falls back to overrides (queries npm registry). " +
340
- "Default is 'report'.",
247
+ "`report` only reports them without fixing. " +
248
+ "`override` adds overrides/resolutions for transitive vulnerabilities. " +
249
+ "`lock-file` updates the lock file to resolve safe versions without modifying `package.json` (similar to Dependabot). " +
250
+ "Default is `report`.",
341
251
  required: false,
342
252
  example: "override",
343
- valid: ["report", "override", "prefer-direct-upgrade"]
253
+ valid: ["report", "override", "lock-file"]
344
254
  })
345
255
  transitiveFixStrategy?: TransitiveFixStrategy;
346
256
 
257
+ @Option({
258
+ displayName: "Prefer direct upgrade",
259
+ description: "When fixing transitive vulnerabilities, first try to find higher versions of " +
260
+ "direct dependencies that include safe transitive versions. Falls back to the " +
261
+ "`transitiveFixStrategy` if no suitable direct upgrade exists. Queries npm registry. " +
262
+ "Default is `true`.",
263
+ required: false,
264
+ example: "false"
265
+ })
266
+ preferDirectUpgrade?: boolean;
267
+
347
268
  @Option({
348
269
  displayName: "Maximum upgrade delta",
349
270
  description: "The maximum difference to allow when upgrading a dependency version. " +
350
- "Use 'none' to only report vulnerabilities without making any changes. " +
271
+ "Use `none` to only report vulnerabilities without making any changes. " +
351
272
  "Patch version upgrades are the default and safest option. " +
352
273
  "Minor version upgrades can introduce new features but typically no breaking changes. " +
353
274
  "Major version upgrades may require code changes.",
@@ -360,8 +281,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
360
281
  @Option({
361
282
  displayName: "Minimum severity",
362
283
  description: "Only fix vulnerabilities with a severity level equal to or higher than the specified minimum. " +
363
- "Vulnerabilities are classified as LOW, MODERATE, HIGH, or CRITICAL based on their potential impact. " +
364
- "Default is LOW, which includes all severity levels.",
284
+ "Vulnerabilities are classified as `LOW`, `MODERATE`, `HIGH`, or `CRITICAL` based on their potential impact. " +
285
+ "Default is `LOW`, which includes all severity levels.",
365
286
  required: false,
366
287
  example: "MODERATE",
367
288
  valid: ["LOW", "MODERATE", "HIGH", "CRITICAL"]
@@ -381,11 +302,11 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
381
302
 
382
303
  @Option({
383
304
  displayName: "Fix declared versions",
384
- description: "When enabled, also upgrades version specifiers declared in package.json that specify vulnerable versions, " +
305
+ description: "When enabled, also upgrades version specifiers declared in `package.json` that specify vulnerable versions, " +
385
306
  "even if the lock file already resolves to a safe version. This is a preventive measure to ensure that " +
386
307
  "future installs (e.g., on a different machine or after lock file changes) won't install vulnerable versions. " +
387
308
  "These preventive upgrades are NOT reported in the vulnerability data table since there's no actual vulnerability. " +
388
- "Default is false.",
309
+ "Default is `false`.",
389
310
  required: false,
390
311
  example: "true"
391
312
  })
@@ -393,9 +314,9 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
393
314
 
394
315
  @Option({
395
316
  displayName: "Add override comments",
396
- description: "When enabled, adds a comment field (e.g., '//overrides') alongside overrides to document which CVEs " +
317
+ description: "When enabled, adds a comment field (e.g., `//overrides`) alongside overrides to document which CVEs " +
397
318
  "each override is fixing. This helps with auditing and knowing when overrides can be removed. " +
398
- "Default is true.",
319
+ "Default is `true`.",
399
320
  required: false,
400
321
  example: "true"
401
322
  })
@@ -407,6 +328,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
407
328
  constructor(options?: {
408
329
  scope?: DependencyScope;
409
330
  transitiveFixStrategy?: TransitiveFixStrategy;
331
+ preferDirectUpgrade?: boolean;
410
332
  maximumUpgradeDelta?: UpgradeDelta;
411
333
  minimumSeverity?: string;
412
334
  cvePattern?: string;
@@ -415,6 +337,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
415
337
  }) {
416
338
  super(options);
417
339
  this.transitiveFixStrategy ??= 'report';
340
+ this.preferDirectUpgrade ??= true;
418
341
  this.maximumUpgradeDelta ??= 'patch';
419
342
  this.minimumSeverity ??= Severity.LOW;
420
343
  this.fixDeclaredVersions ??= false;
@@ -434,25 +357,22 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
434
357
 
435
358
  /**
436
359
  * Returns true if transitive vulnerabilities should be scanned.
360
+ * Always true - we want to report transitive vulnerabilities even if not fixing them.
437
361
  */
438
362
  private shouldScanTransitives(): boolean {
439
- return this.transitiveFixStrategy !== 'report';
440
- }
441
-
442
- /**
443
- * Returns true if transitive vulnerabilities should be fixed with overrides.
444
- */
445
- private shouldFixTransitives(): boolean {
446
- return this.transitiveFixStrategy !== 'report';
363
+ return true;
447
364
  }
448
365
 
449
366
  /**
450
367
  * Returns true if we should verify that transitive fixes are still needed
451
- * after applying direct fixes (for override and prefer-direct-upgrade strategies).
368
+ * after applying direct fixes. Only needed when preferDirectUpgrade is true,
369
+ * because it tries to fix transitives via direct dependency upgrades first.
370
+ *
371
+ * For 'override' and 'lock-file' strategies without preferDirectUpgrade,
372
+ * we apply the strategy directly without checking if direct upgrades helped.
452
373
  */
453
374
  private shouldVerifyTransitiveFixes(): boolean {
454
- return this.transitiveFixStrategy === 'override' ||
455
- this.transitiveFixStrategy === 'prefer-direct-upgrade';
375
+ return this.preferDirectUpgrade === true;
456
376
  }
457
377
 
458
378
  /**
@@ -474,37 +394,41 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
474
394
 
475
395
  for (const fix of fixes) {
476
396
  // Always keep direct fixes and fixes via direct upgrade
477
- if (!fix.isTransitive || fix.fixViaDirectUpgrade) {
397
+ if (!fix.isTransitive || (fix.fixViaDirectUpgrades && fix.fixViaDirectUpgrades.length > 0)) {
478
398
  result.push(fix);
479
399
  continue;
480
400
  }
481
401
 
482
- // For transitive fixes, check if the package is still vulnerable in the lock file
483
- const resolvedVersion = extractVersionFromLockFile(
402
+ // For transitive fixes, check if ANY version of the package is still vulnerable.
403
+ // A package can appear at multiple locations in the tree at different versions
404
+ // (e.g., hoisted at 2.1.1 and nested at 1.0.2), so we need to check ALL versions.
405
+ const resolvedVersions = extractAllVersionsFromLockFile(
484
406
  lockFileContent,
485
407
  fix.packageName,
486
408
  packageManager
487
409
  );
488
410
 
489
- if (!resolvedVersion) {
411
+ if (resolvedVersions.length === 0) {
490
412
  // Package not found in lock file - might have been removed by direct upgrade
491
413
  // Skip this fix (transitive is no longer in the tree)
492
414
  continue;
493
415
  }
494
416
 
495
- // Check if the resolved version is still vulnerable
496
- const isStillVulnerable = this.isVersionStillVulnerable(
497
- fix.packageName,
498
- resolvedVersion,
499
- fix.cves,
500
- db
417
+ // Check if ANY resolved version is still vulnerable
418
+ const anyVersionVulnerable = resolvedVersions.some(version =>
419
+ this.isVersionStillVulnerable(
420
+ fix.packageName,
421
+ version,
422
+ fix.cves,
423
+ db
424
+ )
501
425
  );
502
426
 
503
- if (isStillVulnerable) {
504
- // Transitive is still vulnerable, keep the fix
427
+ if (anyVersionVulnerable) {
428
+ // At least one version of the transitive is still vulnerable, keep the fix
505
429
  result.push(fix);
506
430
  }
507
- // Otherwise, the direct upgrade already fixed it - skip the override
431
+ // Otherwise, all versions are safe - skip the override
508
432
  }
509
433
 
510
434
  return result;
@@ -521,7 +445,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
521
445
  ): boolean {
522
446
  const vulns = db.getVulnerabilities(packageName);
523
447
  for (const vuln of vulns) {
524
- if (cves.includes(vuln.cve) && this.isVersionAffected(version, vuln)) {
448
+ if (cves.includes(vuln.cve) && isVersionAffected(version, vuln)) {
525
449
  return true;
526
450
  }
527
451
  }
@@ -559,147 +483,73 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
559
483
  }
560
484
 
561
485
  /**
562
- * Checks if a version is affected by a vulnerability.
486
+ * Renders a dependency path as a string.
487
+ * Example: "dependencies > lodash@4.17.20 > vulnerable-pkg@1.0.0"
563
488
  */
564
- private isVersionAffected(version: string, vulnerability: Vulnerability): boolean {
565
- try {
566
- const v = semver.parse(version);
567
- if (!v) return false;
568
-
569
- // Check introduced version
570
- if (vulnerability.introducedVersion && vulnerability.introducedVersion !== '0') {
571
- const introduced = semver.parse(vulnerability.introducedVersion);
572
- if (introduced && semver.lt(v, introduced)) {
573
- return false; // Version is before the vulnerability was introduced
574
- }
575
- }
576
-
577
- // Check if fixed
578
- if (vulnerability.fixedVersion) {
579
- const fixed = semver.parse(vulnerability.fixedVersion);
580
- if (fixed && semver.gte(v, fixed)) {
581
- return false; // Version is at or after the fix
582
- }
583
- return true; // Version is in the vulnerable range
584
- }
585
-
586
- // Check last affected version
587
- if (vulnerability.lastAffectedVersion) {
588
- const lastAffected = semver.parse(vulnerability.lastAffectedVersion);
589
- if (lastAffected && semver.gt(v, lastAffected)) {
590
- return false; // Version is after the last affected
591
- }
592
- return true;
593
- }
594
-
595
- return true; // No fix information, assume vulnerable
596
- } catch {
597
- return false; // Invalid version, skip
489
+ private renderPath(scope: DependencyScope | undefined, path: PathSegment[]): string {
490
+ const parts: string[] = [];
491
+ if (scope) {
492
+ parts.push(scope);
598
493
  }
494
+ for (const seg of path) {
495
+ parts.push(`${seg.name}@${seg.version}`);
496
+ }
497
+ return parts.join(' > ');
599
498
  }
600
499
 
601
500
  /**
602
- * Checks if a vulnerability can be fixed within the maximum upgrade delta.
501
+ * Finds all direct dependencies that have a given transitive package in their dependency tree.
502
+ * This is needed because the visited set in findVulnerabilities prevents recording multiple
503
+ * paths to the same vulnerable package.
603
504
  */
604
- private isUpgradeableWithinDelta(currentVersion: string, vulnerability: Vulnerability): boolean {
605
- if (this.isReportOnly()) {
606
- return false;
607
- }
608
-
609
- try {
610
- const current = semver.parse(currentVersion);
611
- if (!current) return false;
612
-
613
- const delta = this.maximumUpgradeDelta!;
614
-
615
- // If we have a fixed version, check if it's reachable within delta
616
- if (vulnerability.fixedVersion) {
617
- const fixed = semver.parse(vulnerability.fixedVersion);
618
- if (!fixed) return false;
619
-
620
- switch (delta) {
621
- case 'patch':
622
- return current.major === fixed.major && current.minor === fixed.minor;
623
- case 'minor':
624
- return current.major === fixed.major;
625
- case 'major':
626
- return true;
627
- case 'none':
628
- return false;
629
- }
630
- }
505
+ private findAllDirectDepsForTransitive(
506
+ marker: NodeResolutionResult,
507
+ transitivePackageName: string,
508
+ scopesToCheck: DependencyScope[]
509
+ ): { name: string; version: string; scope: DependencyScope }[] {
510
+ const results: { name: string; version: string; scope: DependencyScope }[] = [];
631
511
 
632
- // If we only have lastAffectedVersion, check if upgrading within delta
633
- // could get us past it (i.e., to a non-vulnerable version)
634
- if (vulnerability.lastAffectedVersion) {
635
- const lastAffected = semver.parse(vulnerability.lastAffectedVersion);
636
- if (!lastAffected) return false;
637
-
638
- switch (delta) {
639
- case 'patch':
640
- return current.major === lastAffected.major &&
641
- current.minor === lastAffected.minor;
642
- case 'minor':
643
- return current.major === lastAffected.major;
644
- case 'major':
645
- return true;
646
- case 'none':
647
- return false;
512
+ for (const scope of scopesToCheck) {
513
+ const deps = marker[scope] || [];
514
+ for (const dep of deps) {
515
+ if (dep.resolved && this.hasTransitiveInTree(dep.resolved, transitivePackageName, new Set())) {
516
+ results.push({
517
+ name: dep.resolved.name,
518
+ version: dep.resolved.version,
519
+ scope
520
+ });
648
521
  }
649
522
  }
650
-
651
- return false;
652
- } catch {
653
- return false;
654
523
  }
655
- }
656
524
 
657
- /**
658
- * Gets the version to upgrade to for a vulnerability.
659
- */
660
- private getUpgradeVersion(vulnerability: Vulnerability): string | undefined {
661
- if (vulnerability.fixedVersion) {
662
- return vulnerability.fixedVersion;
663
- }
664
- // For lastAffectedVersion, we need the next version after it
665
- // We can't determine this exactly, so return undefined and let the
666
- // upgrade recipe handle version selection
667
- return undefined;
525
+ return results;
668
526
  }
669
527
 
670
528
  /**
671
- * Gets the version prefix to use based on the maximum upgrade delta.
672
- * This allows future patches that might fix additional vulnerabilities.
673
- *
674
- * - 'patch' → '~' (allows patch updates, e.g., ~1.2.3 matches 1.2.x)
675
- * - 'minor' → '^' (allows minor and patch updates, e.g., ^1.2.3 matches 1.x.x)
676
- * - 'major' → '^' (same as minor; ^ is reasonable even for major upgrades)
529
+ * Checks if a resolved dependency has a specific transitive in its dependency tree.
677
530
  */
678
- private getVersionPrefixForDelta(): string {
679
- switch (this.maximumUpgradeDelta) {
680
- case 'patch':
681
- return '~';
682
- case 'minor':
683
- case 'major':
684
- return '^';
685
- default:
686
- return '';
687
- }
688
- }
531
+ private hasTransitiveInTree(
532
+ resolved: ResolvedDependency,
533
+ targetPackageName: string,
534
+ visited: Set<string>
535
+ ): boolean {
536
+ const key = `${resolved.name}@${resolved.version}`;
537
+ if (visited.has(key)) return false;
538
+ visited.add(key);
689
539
 
690
- /**
691
- * Renders a dependency path as a string.
692
- * Example: "dependencies > lodash@4.17.20 > vulnerable-pkg@1.0.0"
693
- */
694
- private renderPath(scope: DependencyScope | undefined, path: PathSegment[]): string {
695
- const parts: string[] = [];
696
- if (scope) {
697
- parts.push(scope);
698
- }
699
- for (const seg of path) {
700
- parts.push(`${seg.name}@${seg.version}`);
540
+ // Check direct children
541
+ if (resolved.dependencies) {
542
+ for (const child of resolved.dependencies) {
543
+ if (child.name === targetPackageName) {
544
+ return true;
545
+ }
546
+ if (child.resolved && this.hasTransitiveInTree(child.resolved, targetPackageName, visited)) {
547
+ return true;
548
+ }
549
+ }
701
550
  }
702
- return parts.join(' > ');
551
+
552
+ return false;
703
553
  }
704
554
 
705
555
  /**
@@ -736,7 +586,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
736
586
  }
737
587
 
738
588
  // Check if this version is affected
739
- if (this.isVersionAffected(resolved.version, vuln)) {
589
+ if (isVersionAffected(resolved.version, vuln)) {
740
590
  results.push({
741
591
  resolved,
742
592
  vulnerability: vuln,
@@ -793,7 +643,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
793
643
  if (!dep.resolved) continue;
794
644
 
795
645
  // Extract the minimum version from the version constraint
796
- const declaredMinVersion = this.extractMinimumVersion(dep.versionConstraint);
646
+ const declaredMinVersion = extractMinimumVersion(dep.versionConstraint);
797
647
  if (!declaredMinVersion) continue;
798
648
 
799
649
  // Skip if declared version equals resolved version (no discrepancy)
@@ -817,8 +667,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
817
667
  }
818
668
 
819
669
  // Check if declared version is affected but resolved is not
820
- if (this.isVersionAffected(declaredMinVersion, vuln) &&
821
- !this.isVersionAffected(dep.resolved.version, vuln)) {
670
+ if (isVersionAffected(declaredMinVersion, vuln) &&
671
+ !isVersionAffected(dep.resolved.version, vuln)) {
822
672
  affectedCves.push(vuln.cve);
823
673
  affectedCveSummaries.set(vuln.cve, vuln.summary);
824
674
 
@@ -863,30 +713,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
863
713
  if (this.isReportOnly()) {
864
714
  return false;
865
715
  }
866
- return this.isVersionWithinDelta(fromVersion, toVersion);
867
- }
868
-
869
- /**
870
- * Extracts the minimum version from a version constraint.
871
- * For example: "^3.0.2" -> "3.0.2", "~1.2.3" -> "1.2.3", ">=2.0.0" -> "2.0.0"
872
- */
873
- private extractMinimumVersion(constraint: string): string | undefined {
874
- if (!constraint) return undefined;
875
-
876
- // Handle exact versions
877
- if (semver.valid(constraint)) {
878
- return constraint;
879
- }
880
-
881
- // Handle ranges with prefix: ^, ~, >=, >, etc.
882
- const match = constraint.match(/^[~^>=<]*\s*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)/);
883
- if (match && semver.valid(match[1])) {
884
- return match[1];
885
- }
886
-
887
- // Try to coerce other formats
888
- const coerced = semver.coerce(constraint);
889
- return coerced?.version;
716
+ return isVersionWithinDelta(fromVersion, toVersion, this.maximumUpgradeDelta!);
890
717
  }
891
718
 
892
719
  /**
@@ -915,7 +742,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
915
742
 
916
743
  // Check if the fix version itself has vulnerabilities
917
744
  const vulnsInFixVersion = db.getVulnerabilities(packageName)
918
- .filter(v => this.isVersionAffected(initialFixVersion, v));
745
+ .filter(v => isVersionAffected(initialFixVersion, v));
919
746
 
920
747
  if (vulnsInFixVersion.length === 0) {
921
748
  // No vulnerabilities in this version - it's safe
@@ -925,10 +752,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
925
752
  // Find fix versions for vulnerabilities in the current fix version
926
753
  let highestFixVersion = initialFixVersion;
927
754
  for (const vuln of vulnsInFixVersion) {
928
- const fixVersion = this.getUpgradeVersion(vuln);
755
+ const fixVersion = getUpgradeVersion(vuln);
929
756
  if (fixVersion && semver.valid(fixVersion)) {
930
757
  // Check if this fix version is within delta from the ORIGINAL version
931
- if (this.isVersionWithinDelta(originalVersion, fixVersion)) {
758
+ if (isVersionWithinDelta(originalVersion, fixVersion, this.maximumUpgradeDelta!)) {
932
759
  if (semver.gt(fixVersion, highestFixVersion)) {
933
760
  highestFixVersion = fixVersion;
934
761
  }
@@ -951,32 +778,6 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
951
778
  return initialFixVersion;
952
779
  }
953
780
 
954
- /**
955
- * Checks if a target version is within the allowed upgrade delta from the original version.
956
- */
957
- private isVersionWithinDelta(originalVersion: string, targetVersion: string): boolean {
958
- try {
959
- const original = semver.parse(originalVersion);
960
- const target = semver.parse(targetVersion);
961
- if (!original || !target) return false;
962
-
963
- switch (this.maximumUpgradeDelta) {
964
- case 'patch':
965
- return original.major === target.major && original.minor === target.minor;
966
- case 'minor':
967
- return original.major === target.major;
968
- case 'major':
969
- return true;
970
- case 'none':
971
- return false;
972
- default:
973
- return false;
974
- }
975
- } catch {
976
- return false;
977
- }
978
- }
979
-
980
781
  /**
981
782
  * Computes the fixes to apply for the vulnerabilities found.
982
783
  * Groups vulnerabilities by package AND major version to handle packages with multiple
@@ -998,6 +799,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
998
799
  packageManager: PackageManager;
999
800
  originalPackageJson: string;
1000
801
  configFiles?: Record<string, string>;
802
+ marker?: NodeResolutionResult;
803
+ scopesToCheck?: DependencyScope[];
1001
804
  }
1002
805
  ): Promise<VulnerabilityFix[]> {
1003
806
  if (this.isReportOnly()) {
@@ -1041,8 +844,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1041
844
  let isTransitive = true;
1042
845
  let scope: DependencyScope | undefined;
1043
846
  let originalVersion: string | undefined;
1044
- // Track the direct dependency that brings in this transitive (from the first vuln's path)
1045
- let directDepInfo: { name: string; version: string; scope: DependencyScope } | undefined;
847
+ // Track ALL direct dependencies that bring in this transitive
848
+ const directDepInfosMap = new Map<string, { name: string; version: string; scope: DependencyScope }>();
1046
849
 
1047
850
  for (const vuln of vulns) {
1048
851
  // Track the original version for delta checking
@@ -1050,22 +853,28 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1050
853
  originalVersion = vuln.resolved.version;
1051
854
  }
1052
855
 
1053
- // Track direct dependency info for transitive vulnerabilities
1054
- if (!vuln.isDirect && vuln.path.length > 0 && !directDepInfo) {
856
+ // Collect all unique direct dependencies that bring in this transitive
857
+ if (!vuln.isDirect && vuln.path.length > 0) {
1055
858
  const directDep = vuln.path[0];
1056
- directDepInfo = {
1057
- name: directDep.name,
1058
- version: directDep.version,
1059
- scope: vuln.scope || 'dependencies'
1060
- };
859
+ const depScope = vuln.scope || 'dependencies';
860
+ // Use name@scope as key to deduplicate (same dep could appear in different scopes)
861
+ const key = `${directDep.name}@${depScope}`;
862
+ if (!directDepInfosMap.has(key)) {
863
+ directDepInfosMap.set(key, {
864
+ name: directDep.name,
865
+ version: directDep.version,
866
+ scope: depScope
867
+ });
868
+ }
1061
869
  }
1062
870
 
1063
871
  // Check if upgradeable within delta
1064
- if (!this.isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability)) {
872
+ const upgradeCheck = isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability, this.maximumUpgradeDelta!);
873
+ if (this.isReportOnly() || !upgradeCheck) {
1065
874
  continue;
1066
875
  }
1067
876
 
1068
- const fixVersion = this.getUpgradeVersion(vuln.vulnerability);
877
+ const fixVersion = getUpgradeVersion(vuln.vulnerability);
1069
878
  if (fixVersion) {
1070
879
  // When there are multiple major versions of the same package in the tree
1071
880
  // (e.g., semver@6.x and semver@7.x), only consider fix versions that match
@@ -1092,6 +901,23 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1092
901
  }
1093
902
  }
1094
903
 
904
+ // If this is a transitive vulnerability and we have a marker, find ALL direct deps
905
+ // that have this transitive in their tree. The visited set in findVulnerabilities
906
+ // prevents recording multiple paths to the same package, so we need this extra step.
907
+ if (isTransitive && projectContext?.marker && projectContext?.scopesToCheck) {
908
+ const allDirectDeps = this.findAllDirectDepsForTransitive(
909
+ projectContext.marker,
910
+ packageName,
911
+ projectContext.scopesToCheck
912
+ );
913
+ for (const dep of allDirectDeps) {
914
+ const key = `${dep.name}@${dep.scope}`;
915
+ if (!directDepInfosMap.has(key)) {
916
+ directDepInfosMap.set(key, dep);
917
+ }
918
+ }
919
+ }
920
+
1095
921
  if (highestFixVersion && cves.length > 0 && originalVersion) {
1096
922
  // Recursively find the highest safe version (checking if fix versions have vulns)
1097
923
  const safeVersion = this.findHighestSafeVersion(
@@ -1101,6 +927,9 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1101
927
  db
1102
928
  );
1103
929
 
930
+ // Convert directDepInfosMap to array for storage
931
+ const directDepInfosArray = Array.from(directDepInfosMap.values());
932
+
1104
933
  const fix: VulnerabilityFix = {
1105
934
  packageName,
1106
935
  newVersion: safeVersion || highestFixVersion,
@@ -1109,12 +938,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1109
938
  cveSummaries,
1110
939
  scope,
1111
940
  originalMajorVersion: originalMajor,
1112
- // Store direct dependency info for potential later use by prefer-direct-upgrade
1113
- directDepInfo: isTransitive && directDepInfo ? {
1114
- name: directDepInfo.name,
1115
- version: directDepInfo.version,
1116
- scope: directDepInfo.scope
1117
- } : undefined
941
+ // Store ALL direct dependencies for potential later use by preferDirectUpgrade
942
+ directDepInfos: isTransitive && directDepInfosArray.length > 0 ? directDepInfosArray : undefined
1118
943
  };
1119
944
 
1120
945
  fixes.push(fix);
@@ -1125,103 +950,108 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1125
950
  }
1126
951
 
1127
952
  /**
1128
- * Tries to find a direct dependency upgrade that fixes a transitive vulnerability.
1129
- * Returns the version to upgrade to, or undefined if no suitable upgrade found.
1130
- */
1131
- private async tryFindDirectDepUpgrade(
1132
- transitivePackageName: string,
1133
- requiredFixVersion: string,
1134
- directDepInfo: { name: string; version: string; scope: DependencyScope },
1135
- projectContext: {
1136
- packageManager: PackageManager;
1137
- originalPackageJson: string;
1138
- configFiles?: Record<string, string>;
1139
- },
1140
- db: VulnerabilityDatabase
1141
- ): Promise<string | undefined> {
1142
- // Create a vulnerability check function for the transitive
1143
- const isVulnerable = (version: string): boolean => {
1144
- try {
1145
- // Version is vulnerable if it's less than the required fix version
1146
- return semver.lt(version, requiredFixVersion);
1147
- } catch {
1148
- return true; // Assume vulnerable if we can't parse
1149
- }
1150
- };
1151
-
1152
- // Create a delta check function based on recipe settings
1153
- const isWithinDelta = (from: string, to: string): boolean => {
1154
- return this.isVersionWithinDelta(from, to);
1155
- };
1156
-
1157
- try {
1158
- const upgradeVersion = await findDirectUpgradeThatFixesTransitive(
1159
- projectContext.packageManager,
1160
- directDepInfo.name,
1161
- directDepInfo.version,
1162
- transitivePackageName,
1163
- isVulnerable,
1164
- isWithinDelta,
1165
- projectContext.originalPackageJson,
1166
- directDepInfo.scope as NpmUtilsDependencyScope,
1167
- projectContext.configFiles
1168
- );
1169
-
1170
- return upgradeVersion;
1171
- } catch {
1172
- return undefined;
1173
- }
1174
- }
1175
-
1176
- /**
1177
- * For each remaining transitive fix, tries to find a higher direct dependency version
1178
- * that fixes the transitive vulnerability. Modifies fixes in place by setting
1179
- * `fixViaDirectUpgrade` when a suitable upgrade is found.
953
+ * For each remaining transitive fix, tries to find higher versions of ALL direct
954
+ * dependencies that bring in the transitive. Uses isolation-based testing to find
955
+ * candidate upgrades for each direct dep independently, then verifies all upgrades
956
+ * together fix the vulnerability.
1180
957
  *
1181
958
  * @param fixes The transitive fixes that are still needed after applying direct fixes
1182
959
  * @param updateInfo Project update info containing package manager and config
1183
- * @param db The vulnerability database
1184
- * @returns The fixes array with `fixViaDirectUpgrade` set where applicable
960
+ * @returns The fixes array with `fixViaDirectUpgrades` set where applicable
1185
961
  */
1186
962
  private async tryDirectUpgradesForTransitives(
1187
963
  fixes: VulnerabilityFix[],
1188
- updateInfo: ProjectUpdateInfo,
1189
- db: VulnerabilityDatabase
964
+ updateInfo: ProjectUpdateInfo
1190
965
  ): Promise<VulnerabilityFix[]> {
1191
966
  const result: VulnerabilityFix[] = [];
1192
967
 
1193
968
  for (const fix of fixes) {
1194
969
  // Skip if no direct dependency info available
1195
- if (!fix.directDepInfo) {
970
+ if (!fix.directDepInfos || fix.directDepInfos.length === 0) {
1196
971
  result.push(fix);
1197
972
  continue;
1198
973
  }
1199
974
 
1200
- // Try to find a direct dependency upgrade that fixes this transitive
1201
- const directUpgradeVersion = await this.tryFindDirectDepUpgrade(
1202
- fix.packageName,
1203
- fix.newVersion,
1204
- fix.directDepInfo,
1205
- {
1206
- packageManager: updateInfo.packageManager,
1207
- originalPackageJson: updateInfo.originalPackageJson,
1208
- configFiles: updateInfo.configFiles
1209
- },
1210
- db
1211
- );
975
+ // Phase 1: Find candidate upgrades for ALL direct dependencies in isolation
976
+ // Each direct dep is tested independently to find a version that brings in a safe transitive
977
+ const candidateUpgrades: {
978
+ directDepName: string;
979
+ directDepVersion: string;
980
+ directDepScope: DependencyScope;
981
+ }[] = [];
982
+
983
+ // Create a vulnerability check function for the transitive
984
+ const isVulnerable = (version: string): boolean => {
985
+ try {
986
+ return semver.lt(version, fix.newVersion);
987
+ } catch {
988
+ return true;
989
+ }
990
+ };
1212
991
 
1213
- if (directUpgradeVersion) {
1214
- // Found a direct upgrade that fixes the transitive
1215
- result.push({
1216
- ...fix,
1217
- fixViaDirectUpgrade: {
1218
- directDepName: fix.directDepInfo.name,
992
+ // Create a delta check function based on recipe settings
993
+ const isWithinDelta = (from: string, to: string): boolean => {
994
+ return isVersionWithinDelta(from, to, this.maximumUpgradeDelta!);
995
+ };
996
+
997
+ for (const directDepInfo of fix.directDepInfos) {
998
+ const directUpgradeVersion = await findDirectUpgradeWithSafeTransitiveInIsolation(
999
+ updateInfo.packageManager,
1000
+ directDepInfo.name,
1001
+ directDepInfo.version,
1002
+ fix.packageName,
1003
+ isVulnerable,
1004
+ isWithinDelta,
1005
+ directDepInfo.scope as NpmUtilsDependencyScope,
1006
+ updateInfo.configFiles
1007
+ );
1008
+
1009
+ if (directUpgradeVersion) {
1010
+ candidateUpgrades.push({
1011
+ directDepName: directDepInfo.name,
1219
1012
  directDepVersion: directUpgradeVersion,
1220
- directDepScope: fix.directDepInfo.scope
1013
+ directDepScope: directDepInfo.scope
1014
+ });
1015
+ }
1016
+ }
1017
+
1018
+ // Phase 2: Verify all candidate upgrades together fix the transitive
1019
+ if (candidateUpgrades.length > 0) {
1020
+ // Check if we found upgrades for ALL direct deps
1021
+ const foundAllUpgrades = candidateUpgrades.length === fix.directDepInfos.length;
1022
+
1023
+ if (foundAllUpgrades) {
1024
+ // Verify all upgrades together fix the vulnerability
1025
+ const allFixed = await verifyAllUpgradesFixTransitive(
1026
+ updateInfo.packageManager,
1027
+ candidateUpgrades.map(u => ({
1028
+ name: u.directDepName,
1029
+ version: u.directDepVersion,
1030
+ scope: u.directDepScope as NpmUtilsDependencyScope
1031
+ })),
1032
+ fix.packageName,
1033
+ isVulnerable,
1034
+ updateInfo.originalPackageJson,
1035
+ updateInfo.configFiles
1036
+ );
1037
+
1038
+ if (allFixed) {
1039
+ result.push({
1040
+ ...fix,
1041
+ fixViaDirectUpgrades: candidateUpgrades
1042
+ });
1043
+ continue;
1221
1044
  }
1045
+ }
1046
+
1047
+ // Partial upgrades found or verification failed - apply what we have
1048
+ // The two-phase mechanism will add an override if still needed
1049
+ result.push({
1050
+ ...fix,
1051
+ fixViaDirectUpgrades: candidateUpgrades
1222
1052
  });
1223
1053
  } else {
1224
- // No direct upgrade found, will use override
1054
+ // No direct upgrades found, will use override
1225
1055
  result.push(fix);
1226
1056
  }
1227
1057
  }
@@ -1340,7 +1170,9 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1340
1170
  fixes = await recipe.computeFixes(vulnerabilities, acc.db, {
1341
1171
  packageManager: pm,
1342
1172
  originalPackageJson,
1343
- configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
1173
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined,
1174
+ marker,
1175
+ scopesToCheck
1344
1176
  });
1345
1177
  }
1346
1178
 
@@ -1514,9 +1346,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1514
1346
  // Insert rows into the data table for each vulnerability (always, even in report-only mode)
1515
1347
  if (vulnerabilities && vulnerabilities.length > 0) {
1516
1348
  for (const vuln of vulnerabilities) {
1517
- const upgradeable = recipe.isUpgradeableWithinDelta(
1349
+ const upgradeable = !recipe.isReportOnly() && isUpgradeableWithinDelta(
1518
1350
  vuln.resolved.version,
1519
- vuln.vulnerability
1351
+ vuln.vulnerability,
1352
+ recipe.maximumUpgradeDelta!
1520
1353
  );
1521
1354
 
1522
1355
  recipe.vulnerabilityReport.insertRow(ctx, new VulnerabilityReportRow(
@@ -1793,10 +1626,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1793
1626
  * Writes a modified package.json with all new versions, then runs install.
1794
1627
  * All file contents are provided from in-memory sources (SourceFiles), not read from disk.
1795
1628
  *
1796
- * For 'override' and 'prefer-direct-upgrade' strategies, this uses a two-phase approach:
1629
+ * When preferDirectUpgrade is enabled, this uses a two-phase approach:
1797
1630
  * 1. First run with only direct fixes (+ fixes via direct upgrade)
1798
1631
  * 2. Check the resulting lock file for remaining transitive vulnerabilities
1799
- * 3. If any remain, add overrides and run again
1632
+ * 3. If any remain, apply the fallback strategy (override or lock-file)
1800
1633
  */
1801
1634
  private async runPackageManagerInstall(
1802
1635
  acc: Accumulator,
@@ -1804,10 +1637,11 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1804
1637
  fixes: VulnerabilityFix[]
1805
1638
  ): Promise<void> {
1806
1639
  // Separate direct fixes from transitive fixes
1807
- const directFixes = fixes.filter(f => !f.isTransitive || f.fixViaDirectUpgrade);
1808
- const transitiveFixes = fixes.filter(f => f.isTransitive && !f.fixViaDirectUpgrade);
1640
+ // Fixes with fixViaDirectUpgrades are treated as direct fixes (they upgrade direct deps)
1641
+ const directFixes = fixes.filter(f => !f.isTransitive || (f.fixViaDirectUpgrades && f.fixViaDirectUpgrades.length > 0));
1642
+ const transitiveFixes = fixes.filter(f => f.isTransitive && (!f.fixViaDirectUpgrades || f.fixViaDirectUpgrades.length === 0));
1809
1643
 
1810
- // For 'override' and 'prefer-direct-upgrade', use two-phase approach
1644
+ // When preferDirectUpgrade is enabled, use two-phase approach
1811
1645
  if (this.shouldVerifyTransitiveFixes() && transitiveFixes.length > 0) {
1812
1646
  // Phase 1: Apply only direct fixes and check what transitives are still vulnerable
1813
1647
  const phase1PackageJson = this.createModifiedPackageJson(
@@ -1858,13 +1692,12 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1858
1692
  return;
1859
1693
  }
1860
1694
 
1861
- // For 'prefer-direct-upgrade', try to find higher direct dependency versions
1695
+ // When preferDirectUpgrade is enabled, try to find higher direct dependency versions
1862
1696
  // that fix the remaining transitive vulnerabilities
1863
- if (this.transitiveFixStrategy === 'prefer-direct-upgrade') {
1697
+ if (this.preferDirectUpgrade) {
1864
1698
  remainingTransitiveFixes = await this.tryDirectUpgradesForTransitives(
1865
1699
  remainingTransitiveFixes,
1866
- updateInfo,
1867
- acc.db
1700
+ updateInfo
1868
1701
  );
1869
1702
  }
1870
1703
 
@@ -1917,10 +1750,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1917
1750
  * Runs the package manager in a temporary directory for a workspace.
1918
1751
  * Handles direct dependency fixes in workspace members and transitive fixes in root.
1919
1752
  *
1920
- * For 'override' and 'prefer-direct-upgrade' strategies, this uses a two-phase approach:
1753
+ * When preferDirectUpgrade is enabled, this uses a two-phase approach:
1921
1754
  * 1. First run with only direct fixes (+ fixes via direct upgrade)
1922
1755
  * 2. Check the resulting lock file for remaining transitive vulnerabilities
1923
- * 3. If any remain, add overrides and run again
1756
+ * 3. If any remain, apply the fallback strategy (override or lock-file)
1924
1757
  */
1925
1758
  private async runWorkspacePackageManagerInstall(
1926
1759
  acc: Accumulator,
@@ -1931,26 +1764,23 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1931
1764
  const pm = rootUpdateInfo.packageManager;
1932
1765
 
1933
1766
  // Collect all fixes from root and members, separating direct from transitive
1934
- const allDirectFixes: VulnerabilityFix[] = [];
1935
1767
  const allTransitiveFixes: VulnerabilityFix[] = [];
1936
1768
 
1937
1769
  // Process root fixes
1938
1770
  const rootFixes = acc.fixesByProject.get(rootPath) || [];
1939
- const rootDirectFixes = rootFixes.filter(f => !f.isTransitive || f.fixViaDirectUpgrade);
1940
- const rootTransitiveFixes = rootFixes.filter(f => f.isTransitive && !f.fixViaDirectUpgrade);
1941
- allDirectFixes.push(...rootDirectFixes);
1771
+ const rootDirectFixes = rootFixes.filter(f => !f.isTransitive || (f.fixViaDirectUpgrades && f.fixViaDirectUpgrades.length > 0));
1772
+ const rootTransitiveFixes = rootFixes.filter(f => f.isTransitive && (!f.fixViaDirectUpgrades || f.fixViaDirectUpgrades.length === 0));
1942
1773
  allTransitiveFixes.push(...rootTransitiveFixes);
1943
1774
 
1944
1775
  // Process member fixes and collect transitive fixes
1945
1776
  const memberDirectFixes = new Map<string, VulnerabilityFix[]>();
1946
1777
  for (const memberPath of memberPaths) {
1947
1778
  const memberFixes = acc.fixesByProject.get(memberPath) || [];
1948
- const directFixes = memberFixes.filter(f => !f.isTransitive || f.fixViaDirectUpgrade);
1949
- const transitiveFixes = memberFixes.filter(f => f.isTransitive && !f.fixViaDirectUpgrade);
1779
+ const directFixes = memberFixes.filter(f => !f.isTransitive || (f.fixViaDirectUpgrades && f.fixViaDirectUpgrades.length > 0));
1780
+ const transitiveFixes = memberFixes.filter(f => f.isTransitive && (!f.fixViaDirectUpgrades || f.fixViaDirectUpgrades.length === 0));
1950
1781
 
1951
1782
  if (directFixes.length > 0) {
1952
1783
  memberDirectFixes.set(memberPath, directFixes);
1953
- allDirectFixes.push(...directFixes);
1954
1784
  }
1955
1785
  allTransitiveFixes.push(...transitiveFixes);
1956
1786
  }
@@ -1980,7 +1810,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1980
1810
 
1981
1811
  const rootOriginalContent = acc.allPackageJsonContents.get(rootPath) || rootUpdateInfo.originalPackageJson;
1982
1812
 
1983
- // For 'override' and 'prefer-direct-upgrade', use two-phase approach
1813
+ // When preferDirectUpgrade is enabled, use two-phase approach
1984
1814
  if (this.shouldVerifyTransitiveFixes() && allTransitiveFixes.length > 0) {
1985
1815
  // Phase 1: Apply only direct fixes
1986
1816
  const phase1RootPackageJson = this.createModifiedPackageJson(
@@ -2033,13 +1863,12 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
2033
1863
  return;
2034
1864
  }
2035
1865
 
2036
- // For 'prefer-direct-upgrade', try to find higher direct dependency versions
1866
+ // When preferDirectUpgrade is enabled, try to find higher direct dependency versions
2037
1867
  // that fix the remaining transitive vulnerabilities
2038
- if (this.transitiveFixStrategy === 'prefer-direct-upgrade') {
1868
+ if (this.preferDirectUpgrade) {
2039
1869
  remainingTransitiveFixes = await this.tryDirectUpgradesForTransitives(
2040
1870
  remainingTransitiveFixes,
2041
- rootUpdateInfo,
2042
- acc.db
1871
+ rootUpdateInfo
2043
1872
  );
2044
1873
  }
2045
1874
 
@@ -2142,18 +1971,27 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
2142
1971
  packageJson[fix.scope][fix.packageName] = applyVersionPrefix(originalVersion, fix.newVersion);
2143
1972
  }
2144
1973
  } else if (fix.isTransitive) {
2145
- // If we found a direct dependency upgrade that fixes this transitive vulnerability,
2146
- // upgrade that direct dependency instead of using overrides. This is preferred because:
1974
+ // For 'lock-file' strategy, we don't add overrides for transitive deps.
1975
+ // The lock file update alone is expected to resolve safe versions.
1976
+ if (this.transitiveFixStrategy === 'lock-file') {
1977
+ continue;
1978
+ }
1979
+
1980
+ // If we found direct dependency upgrades that fix this transitive vulnerability,
1981
+ // upgrade those direct dependencies instead of using overrides. This is preferred because:
2147
1982
  // 1. It's cleaner - no override/resolution clutter in package.json
2148
1983
  // 2. It's more compatible - the direct dep author tested with their transitive deps
2149
1984
  // 3. Future installs work naturally without forced versions
2150
- if (fix.fixViaDirectUpgrade) {
2151
- const {directDepName, directDepVersion, directDepScope} = fix.fixViaDirectUpgrade;
2152
- if (packageJson[directDepScope]?.[directDepName]) {
2153
- const originalVersion = packageJson[directDepScope][directDepName];
2154
- packageJson[directDepScope][directDepName] = applyVersionPrefix(originalVersion, directDepVersion);
2155
- continue; // Skip the override logic below
1985
+ if (fix.fixViaDirectUpgrades && fix.fixViaDirectUpgrades.length > 0) {
1986
+ // Apply ALL direct dependency upgrades that were found
1987
+ for (const upgrade of fix.fixViaDirectUpgrades) {
1988
+ const {directDepName, directDepVersion, directDepScope} = upgrade;
1989
+ if (packageJson[directDepScope]?.[directDepName]) {
1990
+ const originalVersion = packageJson[directDepScope][directDepName];
1991
+ packageJson[directDepScope][directDepName] = applyVersionPrefix(originalVersion, directDepVersion);
1992
+ }
2156
1993
  }
1994
+ continue; // Skip the override logic below
2157
1995
  }
2158
1996
 
2159
1997
  // Check if this package is also a direct dependency in any scope.
@@ -2341,66 +2179,3 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
2341
2179
  return packageJson;
2342
2180
  }
2343
2181
  }
2344
-
2345
- /**
2346
- * Extracts the version prefix (e.g., ^, ~, >=) from a version string.
2347
- * Returns the prefix and the bare version separately.
2348
- */
2349
- export function extractVersionPrefix(versionString: string): { prefix: string; version: string } {
2350
- // Match common semver range prefixes: ^, ~, >=, <=, >, <, =
2351
- const match = versionString.match(/^([~^]|>=?|<=?|=)?(.*)$/);
2352
- if (match) {
2353
- return {
2354
- prefix: match[1] || '',
2355
- version: match[2]
2356
- };
2357
- }
2358
- return {prefix: '', version: versionString};
2359
- }
2360
-
2361
- /**
2362
- * Applies the original version prefix to a new version.
2363
- */
2364
- export function applyVersionPrefix(originalVersion: string, newVersion: string): string {
2365
- const {prefix} = extractVersionPrefix(originalVersion);
2366
- return prefix + newVersion;
2367
- }
2368
-
2369
- /**
2370
- * Finds the dependency scope where a package is declared as a direct dependency.
2371
- * Returns the scope name (dependencies, devDependencies, etc.) or undefined if not found.
2372
- */
2373
- function findDirectDependencyScope(
2374
- packageJson: Record<string, any>,
2375
- packageName: string
2376
- ): DependencyScope | undefined {
2377
- for (const scope of ALL_DEPENDENCY_SCOPES) {
2378
- if (packageJson[scope]?.[packageName]) {
2379
- return scope;
2380
- }
2381
- }
2382
- return undefined;
2383
- }
2384
-
2385
- /**
2386
- * Extracts the overrides section from a package.json based on the package manager.
2387
- * Returns the overrides object or undefined if not present.
2388
- */
2389
- function getOverridesFromPackageJson(
2390
- packageJson: Record<string, any>,
2391
- packageManager: PackageManager
2392
- ): Record<string, string> | undefined {
2393
- switch (packageManager) {
2394
- case PackageManager.Npm:
2395
- case PackageManager.Bun:
2396
- return packageJson.overrides;
2397
- case PackageManager.Pnpm:
2398
- return packageJson.pnpm?.overrides;
2399
- case PackageManager.YarnClassic:
2400
- case PackageManager.YarnBerry:
2401
- return packageJson.resolutions;
2402
- default:
2403
- return undefined;
2404
- }
2405
- }
2406
-