@openrewrite/recipes-nodejs 0.37.0-20260106-082310 → 0.37.0-20260106-104324

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 +6 -54
  2. package/dist/security/dependency-vulnerability-check.d.ts.map +1 -1
  3. package/dist/security/dependency-vulnerability-check.js +133 -259
  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 +232 -485
  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 +116 -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
+ findDirectUpgradeWithSafeTransitiveInIsolation,
43
+ verifyAllUpgradesFixTransitive,
42
44
  extractVersionFromLockFile,
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
  */
@@ -439,13 +349,6 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
439
349
  return this.transitiveFixStrategy !== 'report';
440
350
  }
441
351
 
442
- /**
443
- * Returns true if transitive vulnerabilities should be fixed with overrides.
444
- */
445
- private shouldFixTransitives(): boolean {
446
- return this.transitiveFixStrategy !== 'report';
447
- }
448
-
449
352
  /**
450
353
  * Returns true if we should verify that transitive fixes are still needed
451
354
  * after applying direct fixes (for override and prefer-direct-upgrade strategies).
@@ -474,7 +377,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
474
377
 
475
378
  for (const fix of fixes) {
476
379
  // Always keep direct fixes and fixes via direct upgrade
477
- if (!fix.isTransitive || fix.fixViaDirectUpgrade) {
380
+ if (!fix.isTransitive || (fix.fixViaDirectUpgrades && fix.fixViaDirectUpgrades.length > 0)) {
478
381
  result.push(fix);
479
382
  continue;
480
383
  }
@@ -521,7 +424,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
521
424
  ): boolean {
522
425
  const vulns = db.getVulnerabilities(packageName);
523
426
  for (const vuln of vulns) {
524
- if (cves.includes(vuln.cve) && this.isVersionAffected(version, vuln)) {
427
+ if (cves.includes(vuln.cve) && isVersionAffected(version, vuln)) {
525
428
  return true;
526
429
  }
527
430
  }
@@ -559,147 +462,73 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
559
462
  }
560
463
 
561
464
  /**
562
- * Checks if a version is affected by a vulnerability.
465
+ * Renders a dependency path as a string.
466
+ * Example: "dependencies > lodash@4.17.20 > vulnerable-pkg@1.0.0"
563
467
  */
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
468
+ private renderPath(scope: DependencyScope | undefined, path: PathSegment[]): string {
469
+ const parts: string[] = [];
470
+ if (scope) {
471
+ parts.push(scope);
598
472
  }
473
+ for (const seg of path) {
474
+ parts.push(`${seg.name}@${seg.version}`);
475
+ }
476
+ return parts.join(' > ');
599
477
  }
600
478
 
601
479
  /**
602
- * Checks if a vulnerability can be fixed within the maximum upgrade delta.
480
+ * Finds all direct dependencies that have a given transitive package in their dependency tree.
481
+ * This is needed because the visited set in findVulnerabilities prevents recording multiple
482
+ * paths to the same vulnerable package.
603
483
  */
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
- }
484
+ private findAllDirectDepsForTransitive(
485
+ marker: NodeResolutionResult,
486
+ transitivePackageName: string,
487
+ scopesToCheck: DependencyScope[]
488
+ ): { name: string; version: string; scope: DependencyScope }[] {
489
+ const results: { name: string; version: string; scope: DependencyScope }[] = [];
631
490
 
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;
491
+ for (const scope of scopesToCheck) {
492
+ const deps = marker[scope] || [];
493
+ for (const dep of deps) {
494
+ if (dep.resolved && this.hasTransitiveInTree(dep.resolved, transitivePackageName, new Set())) {
495
+ results.push({
496
+ name: dep.resolved.name,
497
+ version: dep.resolved.version,
498
+ scope
499
+ });
648
500
  }
649
501
  }
650
-
651
- return false;
652
- } catch {
653
- return false;
654
502
  }
655
- }
656
503
 
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;
504
+ return results;
668
505
  }
669
506
 
670
507
  /**
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)
508
+ * Checks if a resolved dependency has a specific transitive in its dependency tree.
677
509
  */
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
- }
510
+ private hasTransitiveInTree(
511
+ resolved: ResolvedDependency,
512
+ targetPackageName: string,
513
+ visited: Set<string>
514
+ ): boolean {
515
+ const key = `${resolved.name}@${resolved.version}`;
516
+ if (visited.has(key)) return false;
517
+ visited.add(key);
689
518
 
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}`);
519
+ // Check direct children
520
+ if (resolved.dependencies) {
521
+ for (const child of resolved.dependencies) {
522
+ if (child.name === targetPackageName) {
523
+ return true;
524
+ }
525
+ if (child.resolved && this.hasTransitiveInTree(child.resolved, targetPackageName, visited)) {
526
+ return true;
527
+ }
528
+ }
701
529
  }
702
- return parts.join(' > ');
530
+
531
+ return false;
703
532
  }
704
533
 
705
534
  /**
@@ -736,7 +565,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
736
565
  }
737
566
 
738
567
  // Check if this version is affected
739
- if (this.isVersionAffected(resolved.version, vuln)) {
568
+ if (isVersionAffected(resolved.version, vuln)) {
740
569
  results.push({
741
570
  resolved,
742
571
  vulnerability: vuln,
@@ -793,7 +622,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
793
622
  if (!dep.resolved) continue;
794
623
 
795
624
  // Extract the minimum version from the version constraint
796
- const declaredMinVersion = this.extractMinimumVersion(dep.versionConstraint);
625
+ const declaredMinVersion = extractMinimumVersion(dep.versionConstraint);
797
626
  if (!declaredMinVersion) continue;
798
627
 
799
628
  // Skip if declared version equals resolved version (no discrepancy)
@@ -817,8 +646,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
817
646
  }
818
647
 
819
648
  // Check if declared version is affected but resolved is not
820
- if (this.isVersionAffected(declaredMinVersion, vuln) &&
821
- !this.isVersionAffected(dep.resolved.version, vuln)) {
649
+ if (isVersionAffected(declaredMinVersion, vuln) &&
650
+ !isVersionAffected(dep.resolved.version, vuln)) {
822
651
  affectedCves.push(vuln.cve);
823
652
  affectedCveSummaries.set(vuln.cve, vuln.summary);
824
653
 
@@ -863,30 +692,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
863
692
  if (this.isReportOnly()) {
864
693
  return false;
865
694
  }
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;
695
+ return isVersionWithinDelta(fromVersion, toVersion, this.maximumUpgradeDelta!);
890
696
  }
891
697
 
892
698
  /**
@@ -915,7 +721,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
915
721
 
916
722
  // Check if the fix version itself has vulnerabilities
917
723
  const vulnsInFixVersion = db.getVulnerabilities(packageName)
918
- .filter(v => this.isVersionAffected(initialFixVersion, v));
724
+ .filter(v => isVersionAffected(initialFixVersion, v));
919
725
 
920
726
  if (vulnsInFixVersion.length === 0) {
921
727
  // No vulnerabilities in this version - it's safe
@@ -925,10 +731,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
925
731
  // Find fix versions for vulnerabilities in the current fix version
926
732
  let highestFixVersion = initialFixVersion;
927
733
  for (const vuln of vulnsInFixVersion) {
928
- const fixVersion = this.getUpgradeVersion(vuln);
734
+ const fixVersion = getUpgradeVersion(vuln);
929
735
  if (fixVersion && semver.valid(fixVersion)) {
930
736
  // Check if this fix version is within delta from the ORIGINAL version
931
- if (this.isVersionWithinDelta(originalVersion, fixVersion)) {
737
+ if (isVersionWithinDelta(originalVersion, fixVersion, this.maximumUpgradeDelta!)) {
932
738
  if (semver.gt(fixVersion, highestFixVersion)) {
933
739
  highestFixVersion = fixVersion;
934
740
  }
@@ -951,32 +757,6 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
951
757
  return initialFixVersion;
952
758
  }
953
759
 
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
760
  /**
981
761
  * Computes the fixes to apply for the vulnerabilities found.
982
762
  * Groups vulnerabilities by package AND major version to handle packages with multiple
@@ -998,6 +778,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
998
778
  packageManager: PackageManager;
999
779
  originalPackageJson: string;
1000
780
  configFiles?: Record<string, string>;
781
+ marker?: NodeResolutionResult;
782
+ scopesToCheck?: DependencyScope[];
1001
783
  }
1002
784
  ): Promise<VulnerabilityFix[]> {
1003
785
  if (this.isReportOnly()) {
@@ -1041,8 +823,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1041
823
  let isTransitive = true;
1042
824
  let scope: DependencyScope | undefined;
1043
825
  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;
826
+ // Track ALL direct dependencies that bring in this transitive
827
+ const directDepInfosMap = new Map<string, { name: string; version: string; scope: DependencyScope }>();
1046
828
 
1047
829
  for (const vuln of vulns) {
1048
830
  // Track the original version for delta checking
@@ -1050,22 +832,27 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1050
832
  originalVersion = vuln.resolved.version;
1051
833
  }
1052
834
 
1053
- // Track direct dependency info for transitive vulnerabilities
1054
- if (!vuln.isDirect && vuln.path.length > 0 && !directDepInfo) {
835
+ // Collect all unique direct dependencies that bring in this transitive
836
+ if (!vuln.isDirect && vuln.path.length > 0) {
1055
837
  const directDep = vuln.path[0];
1056
- directDepInfo = {
1057
- name: directDep.name,
1058
- version: directDep.version,
1059
- scope: vuln.scope || 'dependencies'
1060
- };
838
+ const depScope = vuln.scope || 'dependencies';
839
+ // Use name@scope as key to deduplicate (same dep could appear in different scopes)
840
+ const key = `${directDep.name}@${depScope}`;
841
+ if (!directDepInfosMap.has(key)) {
842
+ directDepInfosMap.set(key, {
843
+ name: directDep.name,
844
+ version: directDep.version,
845
+ scope: depScope
846
+ });
847
+ }
1061
848
  }
1062
849
 
1063
850
  // Check if upgradeable within delta
1064
- if (!this.isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability)) {
851
+ if (this.isReportOnly() || !isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability, this.maximumUpgradeDelta!)) {
1065
852
  continue;
1066
853
  }
1067
854
 
1068
- const fixVersion = this.getUpgradeVersion(vuln.vulnerability);
855
+ const fixVersion = getUpgradeVersion(vuln.vulnerability);
1069
856
  if (fixVersion) {
1070
857
  // When there are multiple major versions of the same package in the tree
1071
858
  // (e.g., semver@6.x and semver@7.x), only consider fix versions that match
@@ -1092,6 +879,23 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1092
879
  }
1093
880
  }
1094
881
 
882
+ // If this is a transitive vulnerability and we have a marker, find ALL direct deps
883
+ // that have this transitive in their tree. The visited set in findVulnerabilities
884
+ // prevents recording multiple paths to the same package, so we need this extra step.
885
+ if (isTransitive && projectContext?.marker && projectContext?.scopesToCheck) {
886
+ const allDirectDeps = this.findAllDirectDepsForTransitive(
887
+ projectContext.marker,
888
+ packageName,
889
+ projectContext.scopesToCheck
890
+ );
891
+ for (const dep of allDirectDeps) {
892
+ const key = `${dep.name}@${dep.scope}`;
893
+ if (!directDepInfosMap.has(key)) {
894
+ directDepInfosMap.set(key, dep);
895
+ }
896
+ }
897
+ }
898
+
1095
899
  if (highestFixVersion && cves.length > 0 && originalVersion) {
1096
900
  // Recursively find the highest safe version (checking if fix versions have vulns)
1097
901
  const safeVersion = this.findHighestSafeVersion(
@@ -1101,6 +905,9 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1101
905
  db
1102
906
  );
1103
907
 
908
+ // Convert directDepInfosMap to array for storage
909
+ const directDepInfosArray = Array.from(directDepInfosMap.values());
910
+
1104
911
  const fix: VulnerabilityFix = {
1105
912
  packageName,
1106
913
  newVersion: safeVersion || highestFixVersion,
@@ -1109,12 +916,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1109
916
  cveSummaries,
1110
917
  scope,
1111
918
  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
919
+ // Store ALL direct dependencies for potential later use by prefer-direct-upgrade
920
+ directDepInfos: isTransitive && directDepInfosArray.length > 0 ? directDepInfosArray : undefined
1118
921
  };
1119
922
 
1120
923
  fixes.push(fix);
@@ -1125,103 +928,108 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1125
928
  }
1126
929
 
1127
930
  /**
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.
931
+ * For each remaining transitive fix, tries to find higher versions of ALL direct
932
+ * dependencies that bring in the transitive. Uses isolation-based testing to find
933
+ * candidate upgrades for each direct dep independently, then verifies all upgrades
934
+ * together fix the vulnerability.
1180
935
  *
1181
936
  * @param fixes The transitive fixes that are still needed after applying direct fixes
1182
937
  * @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
938
+ * @returns The fixes array with `fixViaDirectUpgrades` set where applicable
1185
939
  */
1186
940
  private async tryDirectUpgradesForTransitives(
1187
941
  fixes: VulnerabilityFix[],
1188
- updateInfo: ProjectUpdateInfo,
1189
- db: VulnerabilityDatabase
942
+ updateInfo: ProjectUpdateInfo
1190
943
  ): Promise<VulnerabilityFix[]> {
1191
944
  const result: VulnerabilityFix[] = [];
1192
945
 
1193
946
  for (const fix of fixes) {
1194
947
  // Skip if no direct dependency info available
1195
- if (!fix.directDepInfo) {
948
+ if (!fix.directDepInfos || fix.directDepInfos.length === 0) {
1196
949
  result.push(fix);
1197
950
  continue;
1198
951
  }
1199
952
 
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
- );
953
+ // Phase 1: Find candidate upgrades for ALL direct dependencies in isolation
954
+ // Each direct dep is tested independently to find a version that brings in a safe transitive
955
+ const candidateUpgrades: {
956
+ directDepName: string;
957
+ directDepVersion: string;
958
+ directDepScope: DependencyScope;
959
+ }[] = [];
960
+
961
+ // Create a vulnerability check function for the transitive
962
+ const isVulnerable = (version: string): boolean => {
963
+ try {
964
+ return semver.lt(version, fix.newVersion);
965
+ } catch {
966
+ return true;
967
+ }
968
+ };
1212
969
 
1213
- if (directUpgradeVersion) {
1214
- // Found a direct upgrade that fixes the transitive
1215
- result.push({
1216
- ...fix,
1217
- fixViaDirectUpgrade: {
1218
- directDepName: fix.directDepInfo.name,
970
+ // Create a delta check function based on recipe settings
971
+ const isWithinDelta = (from: string, to: string): boolean => {
972
+ return isVersionWithinDelta(from, to, this.maximumUpgradeDelta!);
973
+ };
974
+
975
+ for (const directDepInfo of fix.directDepInfos) {
976
+ const directUpgradeVersion = await findDirectUpgradeWithSafeTransitiveInIsolation(
977
+ updateInfo.packageManager,
978
+ directDepInfo.name,
979
+ directDepInfo.version,
980
+ fix.packageName,
981
+ isVulnerable,
982
+ isWithinDelta,
983
+ directDepInfo.scope as NpmUtilsDependencyScope,
984
+ updateInfo.configFiles
985
+ );
986
+
987
+ if (directUpgradeVersion) {
988
+ candidateUpgrades.push({
989
+ directDepName: directDepInfo.name,
1219
990
  directDepVersion: directUpgradeVersion,
1220
- directDepScope: fix.directDepInfo.scope
991
+ directDepScope: directDepInfo.scope
992
+ });
993
+ }
994
+ }
995
+
996
+ // Phase 2: Verify all candidate upgrades together fix the transitive
997
+ if (candidateUpgrades.length > 0) {
998
+ // Check if we found upgrades for ALL direct deps
999
+ const foundAllUpgrades = candidateUpgrades.length === fix.directDepInfos.length;
1000
+
1001
+ if (foundAllUpgrades) {
1002
+ // Verify all upgrades together fix the vulnerability
1003
+ const allFixed = await verifyAllUpgradesFixTransitive(
1004
+ updateInfo.packageManager,
1005
+ candidateUpgrades.map(u => ({
1006
+ name: u.directDepName,
1007
+ version: u.directDepVersion,
1008
+ scope: u.directDepScope as NpmUtilsDependencyScope
1009
+ })),
1010
+ fix.packageName,
1011
+ isVulnerable,
1012
+ updateInfo.originalPackageJson,
1013
+ updateInfo.configFiles
1014
+ );
1015
+
1016
+ if (allFixed) {
1017
+ result.push({
1018
+ ...fix,
1019
+ fixViaDirectUpgrades: candidateUpgrades
1020
+ });
1021
+ continue;
1221
1022
  }
1023
+ }
1024
+
1025
+ // Partial upgrades found or verification failed - apply what we have
1026
+ // The two-phase mechanism will add an override if still needed
1027
+ result.push({
1028
+ ...fix,
1029
+ fixViaDirectUpgrades: candidateUpgrades
1222
1030
  });
1223
1031
  } else {
1224
- // No direct upgrade found, will use override
1032
+ // No direct upgrades found, will use override
1225
1033
  result.push(fix);
1226
1034
  }
1227
1035
  }
@@ -1340,7 +1148,9 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1340
1148
  fixes = await recipe.computeFixes(vulnerabilities, acc.db, {
1341
1149
  packageManager: pm,
1342
1150
  originalPackageJson,
1343
- configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
1151
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined,
1152
+ marker,
1153
+ scopesToCheck
1344
1154
  });
1345
1155
  }
1346
1156
 
@@ -1514,9 +1324,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1514
1324
  // Insert rows into the data table for each vulnerability (always, even in report-only mode)
1515
1325
  if (vulnerabilities && vulnerabilities.length > 0) {
1516
1326
  for (const vuln of vulnerabilities) {
1517
- const upgradeable = recipe.isUpgradeableWithinDelta(
1327
+ const upgradeable = !recipe.isReportOnly() && isUpgradeableWithinDelta(
1518
1328
  vuln.resolved.version,
1519
- vuln.vulnerability
1329
+ vuln.vulnerability,
1330
+ recipe.maximumUpgradeDelta!
1520
1331
  );
1521
1332
 
1522
1333
  recipe.vulnerabilityReport.insertRow(ctx, new VulnerabilityReportRow(
@@ -1804,8 +1615,9 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1804
1615
  fixes: VulnerabilityFix[]
1805
1616
  ): Promise<void> {
1806
1617
  // 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);
1618
+ // Fixes with fixViaDirectUpgrades are treated as direct fixes (they upgrade direct deps)
1619
+ const directFixes = fixes.filter(f => !f.isTransitive || (f.fixViaDirectUpgrades && f.fixViaDirectUpgrades.length > 0));
1620
+ const transitiveFixes = fixes.filter(f => f.isTransitive && (!f.fixViaDirectUpgrades || f.fixViaDirectUpgrades.length === 0));
1809
1621
 
1810
1622
  // For 'override' and 'prefer-direct-upgrade', use two-phase approach
1811
1623
  if (this.shouldVerifyTransitiveFixes() && transitiveFixes.length > 0) {
@@ -1863,8 +1675,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1863
1675
  if (this.transitiveFixStrategy === 'prefer-direct-upgrade') {
1864
1676
  remainingTransitiveFixes = await this.tryDirectUpgradesForTransitives(
1865
1677
  remainingTransitiveFixes,
1866
- updateInfo,
1867
- acc.db
1678
+ updateInfo
1868
1679
  );
1869
1680
  }
1870
1681
 
@@ -1931,26 +1742,23 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1931
1742
  const pm = rootUpdateInfo.packageManager;
1932
1743
 
1933
1744
  // Collect all fixes from root and members, separating direct from transitive
1934
- const allDirectFixes: VulnerabilityFix[] = [];
1935
1745
  const allTransitiveFixes: VulnerabilityFix[] = [];
1936
1746
 
1937
1747
  // Process root fixes
1938
1748
  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);
1749
+ const rootDirectFixes = rootFixes.filter(f => !f.isTransitive || (f.fixViaDirectUpgrades && f.fixViaDirectUpgrades.length > 0));
1750
+ const rootTransitiveFixes = rootFixes.filter(f => f.isTransitive && (!f.fixViaDirectUpgrades || f.fixViaDirectUpgrades.length === 0));
1942
1751
  allTransitiveFixes.push(...rootTransitiveFixes);
1943
1752
 
1944
1753
  // Process member fixes and collect transitive fixes
1945
1754
  const memberDirectFixes = new Map<string, VulnerabilityFix[]>();
1946
1755
  for (const memberPath of memberPaths) {
1947
1756
  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);
1757
+ const directFixes = memberFixes.filter(f => !f.isTransitive || (f.fixViaDirectUpgrades && f.fixViaDirectUpgrades.length > 0));
1758
+ const transitiveFixes = memberFixes.filter(f => f.isTransitive && (!f.fixViaDirectUpgrades || f.fixViaDirectUpgrades.length === 0));
1950
1759
 
1951
1760
  if (directFixes.length > 0) {
1952
1761
  memberDirectFixes.set(memberPath, directFixes);
1953
- allDirectFixes.push(...directFixes);
1954
1762
  }
1955
1763
  allTransitiveFixes.push(...transitiveFixes);
1956
1764
  }
@@ -2038,8 +1846,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
2038
1846
  if (this.transitiveFixStrategy === 'prefer-direct-upgrade') {
2039
1847
  remainingTransitiveFixes = await this.tryDirectUpgradesForTransitives(
2040
1848
  remainingTransitiveFixes,
2041
- rootUpdateInfo,
2042
- acc.db
1849
+ rootUpdateInfo
2043
1850
  );
2044
1851
  }
2045
1852
 
@@ -2142,18 +1949,21 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
2142
1949
  packageJson[fix.scope][fix.packageName] = applyVersionPrefix(originalVersion, fix.newVersion);
2143
1950
  }
2144
1951
  } 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:
1952
+ // If we found direct dependency upgrades that fix this transitive vulnerability,
1953
+ // upgrade those direct dependencies instead of using overrides. This is preferred because:
2147
1954
  // 1. It's cleaner - no override/resolution clutter in package.json
2148
1955
  // 2. It's more compatible - the direct dep author tested with their transitive deps
2149
1956
  // 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
1957
+ if (fix.fixViaDirectUpgrades && fix.fixViaDirectUpgrades.length > 0) {
1958
+ // Apply ALL direct dependency upgrades that were found
1959
+ for (const upgrade of fix.fixViaDirectUpgrades) {
1960
+ const {directDepName, directDepVersion, directDepScope} = upgrade;
1961
+ if (packageJson[directDepScope]?.[directDepName]) {
1962
+ const originalVersion = packageJson[directDepScope][directDepName];
1963
+ packageJson[directDepScope][directDepName] = applyVersionPrefix(originalVersion, directDepVersion);
1964
+ }
2156
1965
  }
1966
+ continue; // Skip the override logic below
2157
1967
  }
2158
1968
 
2159
1969
  // Check if this package is also a direct dependency in any scope.
@@ -2341,66 +2151,3 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
2341
2151
  return packageJson;
2342
2152
  }
2343
2153
  }
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
-