@openrewrite/recipes-nodejs 0.37.0-20260104-170507 → 0.37.0-20260106-082310

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.
@@ -37,6 +37,11 @@ import {
37
37
  import * as semver from "semver";
38
38
  import * as path from "path";
39
39
  import {parseSeverity, Severity, severityOrdinal, Vulnerability, VulnerabilityDatabase} from "./vulnerability";
40
+ import {
41
+ findDirectUpgradeThatFixesTransitive,
42
+ extractVersionFromLockFile,
43
+ DependencyScope as NpmUtilsDependencyScope
44
+ } from "./npm-utils";
40
45
 
41
46
  /**
42
47
  * All dependency scopes that can contain dependencies in package.json.
@@ -51,6 +56,23 @@ const ALL_DEPENDENCY_SCOPES: DependencyScope[] = [
51
56
  */
52
57
  export type UpgradeDelta = 'none' | 'patch' | 'minor' | 'major';
53
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';
75
+
54
76
  /**
55
77
  * Row for the vulnerability report data table.
56
78
  */
@@ -194,18 +216,43 @@ interface VulnerableDependency {
194
216
  * Represents a fix to apply for a vulnerability.
195
217
  */
196
218
  interface VulnerabilityFix {
197
- /** Package name to upgrade */
219
+ /** Package name to upgrade (the vulnerable package) */
198
220
  packageName: string;
199
221
  /** Version to upgrade to */
200
222
  newVersion: string;
201
- /** Whether this is a transitive dependency fix */
223
+ /** Whether the vulnerable package is a transitive dependency */
202
224
  isTransitive: boolean;
203
225
  /** CVEs this fix resolves */
204
226
  cves: string[];
227
+ /** CVE summaries for generating comments (CVE ID -> summary) */
228
+ cveSummaries: Map<string, string>;
205
229
  /** The scope where this dependency was found (for direct deps) */
206
230
  scope?: DependencyScope;
207
231
  /** The original major version (for version-specific overrides) */
208
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
+ };
209
256
  }
210
257
 
211
258
  /**
@@ -285,14 +332,17 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
285
332
  scope?: DependencyScope;
286
333
 
287
334
  @Option({
288
- displayName: "Override transitives",
289
- description: "When enabled, transitive dependencies with vulnerabilities will have their versions overridden " +
290
- "using npm overrides, yarn resolutions, or pnpm overrides. " +
291
- "By default only direct dependencies have their version numbers upgraded.",
335
+ displayName: "Transitive fix strategy",
336
+ 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'.",
292
341
  required: false,
293
- example: "true"
342
+ example: "override",
343
+ valid: ["report", "override", "prefer-direct-upgrade"]
294
344
  })
295
- overrideTransitive?: boolean;
345
+ transitiveFixStrategy?: TransitiveFixStrategy;
296
346
 
297
347
  @Option({
298
348
  displayName: "Maximum upgrade delta",
@@ -341,21 +391,34 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
341
391
  })
342
392
  fixDeclaredVersions?: boolean;
343
393
 
394
+ @Option({
395
+ displayName: "Add override comments",
396
+ description: "When enabled, adds a comment field (e.g., '//overrides') alongside overrides to document which CVEs " +
397
+ "each override is fixing. This helps with auditing and knowing when overrides can be removed. " +
398
+ "Default is true.",
399
+ required: false,
400
+ example: "true"
401
+ })
402
+ addOverrideComments?: boolean;
403
+
344
404
  /** Cached compiled regex for CVE pattern matching */
345
405
  private cvePatternRegex?: RegExp;
346
406
 
347
407
  constructor(options?: {
348
408
  scope?: DependencyScope;
349
- overrideTransitive?: boolean;
409
+ transitiveFixStrategy?: TransitiveFixStrategy;
350
410
  maximumUpgradeDelta?: UpgradeDelta;
351
411
  minimumSeverity?: string;
352
412
  cvePattern?: string;
353
413
  fixDeclaredVersions?: boolean;
414
+ addOverrideComments?: boolean;
354
415
  }) {
355
416
  super(options);
417
+ this.transitiveFixStrategy ??= 'report';
356
418
  this.maximumUpgradeDelta ??= 'patch';
357
419
  this.minimumSeverity ??= Severity.LOW;
358
420
  this.fixDeclaredVersions ??= false;
421
+ this.addOverrideComments ??= true;
359
422
  if (options?.minimumSeverity) {
360
423
  this.minimumSeverity = parseSeverity(options.minimumSeverity);
361
424
  }
@@ -369,6 +432,102 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
369
432
  }
370
433
  }
371
434
 
435
+ /**
436
+ * Returns true if transitive vulnerabilities should be scanned.
437
+ */
438
+ 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';
447
+ }
448
+
449
+ /**
450
+ * Returns true if we should verify that transitive fixes are still needed
451
+ * after applying direct fixes (for override and prefer-direct-upgrade strategies).
452
+ */
453
+ private shouldVerifyTransitiveFixes(): boolean {
454
+ return this.transitiveFixStrategy === 'override' ||
455
+ this.transitiveFixStrategy === 'prefer-direct-upgrade';
456
+ }
457
+
458
+ /**
459
+ * Filters out transitive fixes that are no longer needed based on the lock file
460
+ * after applying direct fixes. Returns only the fixes that are still required.
461
+ *
462
+ * @param fixes All computed fixes
463
+ * @param lockFileContent The lock file content after applying direct fixes
464
+ * @param packageManager The package manager used
465
+ * @param db The vulnerability database
466
+ */
467
+ private filterRemainingTransitiveFixes(
468
+ fixes: VulnerabilityFix[],
469
+ lockFileContent: string,
470
+ packageManager: PackageManager,
471
+ db: VulnerabilityDatabase
472
+ ): VulnerabilityFix[] {
473
+ const result: VulnerabilityFix[] = [];
474
+
475
+ for (const fix of fixes) {
476
+ // Always keep direct fixes and fixes via direct upgrade
477
+ if (!fix.isTransitive || fix.fixViaDirectUpgrade) {
478
+ result.push(fix);
479
+ continue;
480
+ }
481
+
482
+ // For transitive fixes, check if the package is still vulnerable in the lock file
483
+ const resolvedVersion = extractVersionFromLockFile(
484
+ lockFileContent,
485
+ fix.packageName,
486
+ packageManager
487
+ );
488
+
489
+ if (!resolvedVersion) {
490
+ // Package not found in lock file - might have been removed by direct upgrade
491
+ // Skip this fix (transitive is no longer in the tree)
492
+ continue;
493
+ }
494
+
495
+ // Check if the resolved version is still vulnerable
496
+ const isStillVulnerable = this.isVersionStillVulnerable(
497
+ fix.packageName,
498
+ resolvedVersion,
499
+ fix.cves,
500
+ db
501
+ );
502
+
503
+ if (isStillVulnerable) {
504
+ // Transitive is still vulnerable, keep the fix
505
+ result.push(fix);
506
+ }
507
+ // Otherwise, the direct upgrade already fixed it - skip the override
508
+ }
509
+
510
+ return result;
511
+ }
512
+
513
+ /**
514
+ * Checks if a package version is still vulnerable to any of the specified CVEs.
515
+ */
516
+ private isVersionStillVulnerable(
517
+ packageName: string,
518
+ version: string,
519
+ cves: string[],
520
+ db: VulnerabilityDatabase
521
+ ): boolean {
522
+ const vulns = db.getVulnerabilities(packageName);
523
+ for (const vuln of vulns) {
524
+ if (cves.includes(vuln.cve) && this.isVersionAffected(version, vuln)) {
525
+ return true;
526
+ }
527
+ }
528
+ return false;
529
+ }
530
+
372
531
  override initialValue(_ctx: ExecutionContext): Accumulator {
373
532
  return {
374
533
  // DependencyRecipeAccumulator fields
@@ -589,8 +748,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
589
748
  }
590
749
  }
591
750
 
592
- // Recurse into transitive dependencies if overrideTransitive is enabled
593
- if (this.overrideTransitive) {
751
+ // Recurse into transitive dependencies if we need to scan them
752
+ if (this.shouldScanTransitives()) {
594
753
  const transitives = [
595
754
  ...(resolved.dependencies || []),
596
755
  ...(resolved.devDependencies || []),
@@ -643,6 +802,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
643
802
  // Check if the declared minimum version is vulnerable
644
803
  const vulns = db.getVulnerabilities(dep.name);
645
804
  const affectedCves: string[] = [];
805
+ const affectedCveSummaries = new Map<string, string>();
646
806
  let highestFixVersion: string | undefined;
647
807
 
648
808
  for (const vuln of vulns) {
@@ -660,6 +820,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
660
820
  if (this.isVersionAffected(declaredMinVersion, vuln) &&
661
821
  !this.isVersionAffected(dep.resolved.version, vuln)) {
662
822
  affectedCves.push(vuln.cve);
823
+ affectedCveSummaries.set(vuln.cve, vuln.summary);
663
824
 
664
825
  // Find fix version for this vulnerability.
665
826
  // Only use fixedVersion - don't guess based on lastAffectedVersion
@@ -683,6 +844,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
683
844
  scope,
684
845
  isTransitive: false,
685
846
  cves: affectedCves,
847
+ cveSummaries: affectedCveSummaries,
686
848
  originalMajorVersion: majorVersion
687
849
  });
688
850
  }
@@ -821,13 +983,23 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
821
983
  * major versions in the dependency tree (e.g., semver@6.x and semver@7.x).
822
984
  * Recursively checks if fix versions have their own vulnerabilities.
823
985
  *
986
+ * For transitive vulnerabilities, first tries to find a direct dependency upgrade
987
+ * that includes a fixed version of the transitive. Falls back to overrides if
988
+ * no suitable direct dependency upgrade is found.
989
+ *
824
990
  * @param vulnerabilities The vulnerabilities found during scanning
825
991
  * @param db The vulnerability database
992
+ * @param projectContext Context needed to check direct dependency upgrades
826
993
  */
827
- private computeFixes(
994
+ private async computeFixes(
828
995
  vulnerabilities: VulnerableDependency[],
829
- db: VulnerabilityDatabase
830
- ): VulnerabilityFix[] {
996
+ db: VulnerabilityDatabase,
997
+ projectContext?: {
998
+ packageManager: PackageManager;
999
+ originalPackageJson: string;
1000
+ configFiles?: Record<string, string>;
1001
+ }
1002
+ ): Promise<VulnerabilityFix[]> {
831
1003
  if (this.isReportOnly()) {
832
1004
  return [];
833
1005
  }
@@ -865,9 +1037,12 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
865
1037
  // Find the highest fix version needed across all vulnerabilities for this package@major
866
1038
  let highestFixVersion: string | undefined;
867
1039
  const cves: string[] = [];
1040
+ const cveSummaries = new Map<string, string>();
868
1041
  let isTransitive = true;
869
1042
  let scope: DependencyScope | undefined;
870
1043
  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;
871
1046
 
872
1047
  for (const vuln of vulns) {
873
1048
  // Track the original version for delta checking
@@ -875,6 +1050,16 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
875
1050
  originalVersion = vuln.resolved.version;
876
1051
  }
877
1052
 
1053
+ // Track direct dependency info for transitive vulnerabilities
1054
+ if (!vuln.isDirect && vuln.path.length > 0 && !directDepInfo) {
1055
+ const directDep = vuln.path[0];
1056
+ directDepInfo = {
1057
+ name: directDep.name,
1058
+ version: directDep.version,
1059
+ scope: vuln.scope || 'dependencies'
1060
+ };
1061
+ }
1062
+
878
1063
  // Check if upgradeable within delta
879
1064
  if (!this.isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability)) {
880
1065
  continue;
@@ -899,6 +1084,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
899
1084
  }
900
1085
 
901
1086
  cves.push(vuln.vulnerability.cve);
1087
+ cveSummaries.set(vuln.vulnerability.cve, vuln.vulnerability.summary);
902
1088
 
903
1089
  if (vuln.isDirect) {
904
1090
  isTransitive = false;
@@ -915,20 +1101,134 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
915
1101
  db
916
1102
  );
917
1103
 
918
- fixes.push({
1104
+ const fix: VulnerabilityFix = {
919
1105
  packageName,
920
1106
  newVersion: safeVersion || highestFixVersion,
921
1107
  isTransitive,
922
1108
  cves,
1109
+ cveSummaries,
923
1110
  scope,
924
- originalMajorVersion: originalMajor
925
- });
1111
+ 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
1118
+ };
1119
+
1120
+ fixes.push(fix);
926
1121
  }
927
1122
  }
928
1123
 
929
1124
  return fixes;
930
1125
  }
931
1126
 
1127
+ /**
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.
1180
+ *
1181
+ * @param fixes The transitive fixes that are still needed after applying direct fixes
1182
+ * @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
1185
+ */
1186
+ private async tryDirectUpgradesForTransitives(
1187
+ fixes: VulnerabilityFix[],
1188
+ updateInfo: ProjectUpdateInfo,
1189
+ db: VulnerabilityDatabase
1190
+ ): Promise<VulnerabilityFix[]> {
1191
+ const result: VulnerabilityFix[] = [];
1192
+
1193
+ for (const fix of fixes) {
1194
+ // Skip if no direct dependency info available
1195
+ if (!fix.directDepInfo) {
1196
+ result.push(fix);
1197
+ continue;
1198
+ }
1199
+
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
+ );
1212
+
1213
+ if (directUpgradeVersion) {
1214
+ // Found a direct upgrade that fixes the transitive
1215
+ result.push({
1216
+ ...fix,
1217
+ fixViaDirectUpgrade: {
1218
+ directDepName: fix.directDepInfo.name,
1219
+ directDepVersion: directUpgradeVersion,
1220
+ directDepScope: fix.directDepInfo.scope
1221
+ }
1222
+ });
1223
+ } else {
1224
+ // No direct upgrade found, will use override
1225
+ result.push(fix);
1226
+ }
1227
+ }
1228
+
1229
+ return result;
1230
+ }
1231
+
932
1232
  override async scanner(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
933
1233
  const recipe = this;
934
1234
 
@@ -1036,7 +1336,12 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1036
1336
  let fixes: VulnerabilityFix[] = [];
1037
1337
  if (vulnerabilities.length > 0) {
1038
1338
  acc.vulnerableByProject.set(doc.sourcePath, vulnerabilities);
1039
- fixes = recipe.computeFixes(vulnerabilities, acc.db);
1339
+ const originalPackageJson = storedContent || await TreePrinters.print(doc);
1340
+ fixes = await recipe.computeFixes(vulnerabilities, acc.db, {
1341
+ packageManager: pm,
1342
+ originalPackageJson,
1343
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
1344
+ });
1040
1345
  }
1041
1346
 
1042
1347
  // Fix declared versions for preventive upgrades (when enabled)
@@ -1487,13 +1792,103 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1487
1792
  * Runs the package manager in a temporary directory to update the lock file.
1488
1793
  * Writes a modified package.json with all new versions, then runs install.
1489
1794
  * All file contents are provided from in-memory sources (SourceFiles), not read from disk.
1795
+ *
1796
+ * For 'override' and 'prefer-direct-upgrade' strategies, this uses a two-phase approach:
1797
+ * 1. First run with only direct fixes (+ fixes via direct upgrade)
1798
+ * 2. Check the resulting lock file for remaining transitive vulnerabilities
1799
+ * 3. If any remain, add overrides and run again
1490
1800
  */
1491
1801
  private async runPackageManagerInstall(
1492
1802
  acc: Accumulator,
1493
1803
  updateInfo: ProjectUpdateInfo,
1494
1804
  fixes: VulnerabilityFix[]
1495
1805
  ): Promise<void> {
1496
- // Create modified package.json with all the new version constraints
1806
+ // 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);
1809
+
1810
+ // For 'override' and 'prefer-direct-upgrade', use two-phase approach
1811
+ if (this.shouldVerifyTransitiveFixes() && transitiveFixes.length > 0) {
1812
+ // Phase 1: Apply only direct fixes and check what transitives are still vulnerable
1813
+ const phase1PackageJson = this.createModifiedPackageJson(
1814
+ updateInfo.originalPackageJson,
1815
+ directFixes,
1816
+ updateInfo.packageManager
1817
+ );
1818
+
1819
+ const phase1Result = await runInstallInTempDir(
1820
+ updateInfo.packageManager,
1821
+ phase1PackageJson,
1822
+ {
1823
+ configFiles: updateInfo.configFiles
1824
+ }
1825
+ );
1826
+
1827
+ if (!phase1Result.success || !phase1Result.lockFileContent) {
1828
+ // Phase 1 failed, fall back to applying all fixes
1829
+ const modifiedPackageJson = this.createModifiedPackageJson(
1830
+ updateInfo.originalPackageJson,
1831
+ fixes,
1832
+ updateInfo.packageManager
1833
+ );
1834
+
1835
+ const result = await runInstallInTempDir(
1836
+ updateInfo.packageManager,
1837
+ modifiedPackageJson,
1838
+ {
1839
+ configFiles: updateInfo.configFiles
1840
+ }
1841
+ );
1842
+
1843
+ storeInstallResult(result, acc, updateInfo, modifiedPackageJson);
1844
+ return;
1845
+ }
1846
+
1847
+ // Filter out transitive fixes that are no longer needed
1848
+ let remainingTransitiveFixes = this.filterRemainingTransitiveFixes(
1849
+ transitiveFixes,
1850
+ phase1Result.lockFileContent,
1851
+ updateInfo.packageManager,
1852
+ acc.db
1853
+ );
1854
+
1855
+ if (remainingTransitiveFixes.length === 0) {
1856
+ // All transitives were fixed by direct upgrades - use phase 1 result
1857
+ storeInstallResult(phase1Result, acc, updateInfo, phase1PackageJson);
1858
+ return;
1859
+ }
1860
+
1861
+ // For 'prefer-direct-upgrade', try to find higher direct dependency versions
1862
+ // that fix the remaining transitive vulnerabilities
1863
+ if (this.transitiveFixStrategy === 'prefer-direct-upgrade') {
1864
+ remainingTransitiveFixes = await this.tryDirectUpgradesForTransitives(
1865
+ remainingTransitiveFixes,
1866
+ updateInfo,
1867
+ acc.db
1868
+ );
1869
+ }
1870
+
1871
+ // Phase 2: Apply remaining transitive fixes (either as direct upgrades or overrides)
1872
+ const finalFixes = [...directFixes, ...remainingTransitiveFixes];
1873
+ const finalPackageJson = this.createModifiedPackageJson(
1874
+ updateInfo.originalPackageJson,
1875
+ finalFixes,
1876
+ updateInfo.packageManager
1877
+ );
1878
+
1879
+ const finalResult = await runInstallInTempDir(
1880
+ updateInfo.packageManager,
1881
+ finalPackageJson,
1882
+ {
1883
+ configFiles: updateInfo.configFiles
1884
+ }
1885
+ );
1886
+
1887
+ storeInstallResult(finalResult, acc, updateInfo, finalPackageJson);
1888
+ return;
1889
+ }
1890
+
1891
+ // When there are no transitive fixes, apply all fixes directly
1497
1892
  const modifiedPackageJson = this.createModifiedPackageJson(
1498
1893
  updateInfo.originalPackageJson,
1499
1894
  fixes,
@@ -1521,6 +1916,11 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1521
1916
  /**
1522
1917
  * Runs the package manager in a temporary directory for a workspace.
1523
1918
  * Handles direct dependency fixes in workspace members and transitive fixes in root.
1919
+ *
1920
+ * For 'override' and 'prefer-direct-upgrade' strategies, this uses a two-phase approach:
1921
+ * 1. First run with only direct fixes (+ fixes via direct upgrade)
1922
+ * 2. Check the resulting lock file for remaining transitive vulnerabilities
1923
+ * 3. If any remain, add overrides and run again
1524
1924
  */
1525
1925
  private async runWorkspacePackageManagerInstall(
1526
1926
  acc: Accumulator,
@@ -1530,39 +1930,32 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1530
1930
  const memberPaths = acc.workspaceRoots.get(rootPath) || [];
1531
1931
  const pm = rootUpdateInfo.packageManager;
1532
1932
 
1533
- // Collect all transitive fixes from root and members (for root overrides)
1933
+ // Collect all fixes from root and members, separating direct from transitive
1934
+ const allDirectFixes: VulnerabilityFix[] = [];
1534
1935
  const allTransitiveFixes: VulnerabilityFix[] = [];
1535
1936
 
1536
1937
  // Process root fixes
1537
1938
  const rootFixes = acc.fixesByProject.get(rootPath) || [];
1538
- const rootDirectFixes = rootFixes.filter(f => !f.isTransitive);
1539
- const rootTransitiveFixes = rootFixes.filter(f => f.isTransitive);
1939
+ const rootDirectFixes = rootFixes.filter(f => !f.isTransitive || f.fixViaDirectUpgrade);
1940
+ const rootTransitiveFixes = rootFixes.filter(f => f.isTransitive && !f.fixViaDirectUpgrade);
1941
+ allDirectFixes.push(...rootDirectFixes);
1540
1942
  allTransitiveFixes.push(...rootTransitiveFixes);
1541
1943
 
1542
1944
  // Process member fixes and collect transitive fixes
1543
1945
  const memberDirectFixes = new Map<string, VulnerabilityFix[]>();
1544
1946
  for (const memberPath of memberPaths) {
1545
1947
  const memberFixes = acc.fixesByProject.get(memberPath) || [];
1546
- const directFixes = memberFixes.filter(f => !f.isTransitive);
1547
- const transitiveFixes = memberFixes.filter(f => f.isTransitive);
1948
+ const directFixes = memberFixes.filter(f => !f.isTransitive || f.fixViaDirectUpgrade);
1949
+ const transitiveFixes = memberFixes.filter(f => f.isTransitive && !f.fixViaDirectUpgrade);
1548
1950
 
1549
1951
  if (directFixes.length > 0) {
1550
1952
  memberDirectFixes.set(memberPath, directFixes);
1953
+ allDirectFixes.push(...directFixes);
1551
1954
  }
1552
1955
  allTransitiveFixes.push(...transitiveFixes);
1553
1956
  }
1554
1957
 
1555
- // Create modified root package.json (with direct fixes + all transitive overrides)
1556
- const rootOriginalContent = acc.allPackageJsonContents.get(rootPath) || rootUpdateInfo.originalPackageJson;
1557
- const modifiedRootPackageJson = this.createModifiedPackageJson(
1558
- rootOriginalContent,
1559
- [...rootDirectFixes, ...allTransitiveFixes],
1560
- pm
1561
- );
1562
-
1563
1958
  // Create modified workspace member package.json files (with direct fixes only)
1564
- // Keep the full package.json content including workspace: dependencies so Yarn
1565
- // can properly resolve them in the temp directory
1566
1959
  const workspacePackages: Record<string, string> = {};
1567
1960
  for (const memberPath of memberPaths) {
1568
1961
  const originalContent = acc.allPackageJsonContents.get(memberPath);
@@ -1573,28 +1966,110 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1573
1966
  const directFixes = memberDirectFixes.get(memberPath);
1574
1967
  let modifiedContent: string;
1575
1968
  if (directFixes && directFixes.length > 0) {
1576
- // Apply direct dependency fixes to this member
1577
1969
  modifiedContent = this.createModifiedPackageJsonDirectOnly(
1578
1970
  originalContent,
1579
1971
  directFixes
1580
1972
  );
1581
- // Store the modified content for the actual output
1582
1973
  acc.modifiedWorkspaceMemberContents.set(memberPath, modifiedContent);
1583
1974
  } else {
1584
1975
  modifiedContent = originalContent;
1585
1976
  }
1586
1977
 
1587
- // Keep the full package.json including workspace: dependencies
1588
1978
  workspacePackages[memberPath] = modifiedContent;
1589
1979
  }
1590
1980
 
1591
- // Note: We intentionally do NOT pass the original lock file content.
1592
- // npm (and other package managers) don't apply overrides to already-resolved
1593
- // dependencies in an existing lock file. By not passing the original lock file,
1594
- // we force a fresh resolution that respects the overrides.
1981
+ const rootOriginalContent = acc.allPackageJsonContents.get(rootPath) || rootUpdateInfo.originalPackageJson;
1982
+
1983
+ // For 'override' and 'prefer-direct-upgrade', use two-phase approach
1984
+ if (this.shouldVerifyTransitiveFixes() && allTransitiveFixes.length > 0) {
1985
+ // Phase 1: Apply only direct fixes
1986
+ const phase1RootPackageJson = this.createModifiedPackageJson(
1987
+ rootOriginalContent,
1988
+ rootDirectFixes,
1989
+ pm
1990
+ );
1991
+
1992
+ const phase1Result = await runWorkspaceInstallInTempDir(
1993
+ pm,
1994
+ phase1RootPackageJson,
1995
+ {
1996
+ configFiles: rootUpdateInfo.configFiles,
1997
+ workspacePackages
1998
+ }
1999
+ );
2000
+
2001
+ if (!phase1Result.success || !phase1Result.lockFileContent) {
2002
+ // Phase 1 failed, fall back to applying all fixes
2003
+ const modifiedRootPackageJson = this.createModifiedPackageJson(
2004
+ rootOriginalContent,
2005
+ [...rootDirectFixes, ...allTransitiveFixes],
2006
+ pm
2007
+ );
2008
+
2009
+ const result = await runWorkspaceInstallInTempDir(
2010
+ pm,
2011
+ modifiedRootPackageJson,
2012
+ {
2013
+ configFiles: rootUpdateInfo.configFiles,
2014
+ workspacePackages
2015
+ }
2016
+ );
2017
+
2018
+ storeInstallResult(result, acc, rootUpdateInfo, modifiedRootPackageJson);
2019
+ return;
2020
+ }
2021
+
2022
+ // Filter out transitive fixes that are no longer needed
2023
+ let remainingTransitiveFixes = this.filterRemainingTransitiveFixes(
2024
+ allTransitiveFixes,
2025
+ phase1Result.lockFileContent,
2026
+ pm,
2027
+ acc.db
2028
+ );
2029
+
2030
+ if (remainingTransitiveFixes.length === 0) {
2031
+ // All transitives were fixed by direct upgrades - use phase 1 result
2032
+ storeInstallResult(phase1Result, acc, rootUpdateInfo, phase1RootPackageJson);
2033
+ return;
2034
+ }
2035
+
2036
+ // For 'prefer-direct-upgrade', try to find higher direct dependency versions
2037
+ // that fix the remaining transitive vulnerabilities
2038
+ if (this.transitiveFixStrategy === 'prefer-direct-upgrade') {
2039
+ remainingTransitiveFixes = await this.tryDirectUpgradesForTransitives(
2040
+ remainingTransitiveFixes,
2041
+ rootUpdateInfo,
2042
+ acc.db
2043
+ );
2044
+ }
2045
+
2046
+ // Phase 2: Apply remaining transitive fixes (either as direct upgrades or overrides)
2047
+ const finalRootPackageJson = this.createModifiedPackageJson(
2048
+ rootOriginalContent,
2049
+ [...rootDirectFixes, ...remainingTransitiveFixes],
2050
+ pm
2051
+ );
2052
+
2053
+ const finalResult = await runWorkspaceInstallInTempDir(
2054
+ pm,
2055
+ finalRootPackageJson,
2056
+ {
2057
+ configFiles: rootUpdateInfo.configFiles,
2058
+ workspacePackages
2059
+ }
2060
+ );
2061
+
2062
+ storeInstallResult(finalResult, acc, rootUpdateInfo, finalRootPackageJson);
2063
+ return;
2064
+ }
2065
+
2066
+ // When there are no transitive fixes, apply all fixes directly
2067
+ const modifiedRootPackageJson = this.createModifiedPackageJson(
2068
+ rootOriginalContent,
2069
+ [...rootDirectFixes, ...allTransitiveFixes],
2070
+ pm
2071
+ );
1595
2072
 
1596
- // Run workspace install with full workspace structure
1597
- // We keep workspace: dependencies and the workspaces field so Yarn can resolve them
1598
2073
  const result = await runWorkspaceInstallInTempDir(
1599
2074
  pm,
1600
2075
  modifiedRootPackageJson,
@@ -1655,6 +2130,10 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1655
2130
  fixesByPackage.set(fix.packageName, existing);
1656
2131
  }
1657
2132
 
2133
+ // Track overrides added and their CVE comments for the comment field
2134
+ // Maps override key (e.g., "lodash" or "semver@^6") to CVE comment string
2135
+ const overrideComments = new Map<string, string>();
2136
+
1658
2137
  for (const fix of fixes) {
1659
2138
  if (!fix.isTransitive && fix.scope) {
1660
2139
  // Direct dependency: update the version in the scope
@@ -1663,6 +2142,20 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1663
2142
  packageJson[fix.scope][fix.packageName] = applyVersionPrefix(originalVersion, fix.newVersion);
1664
2143
  }
1665
2144
  } 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:
2147
+ // 1. It's cleaner - no override/resolution clutter in package.json
2148
+ // 2. It's more compatible - the direct dep author tested with their transitive deps
2149
+ // 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
2156
+ }
2157
+ }
2158
+
1666
2159
  // Check if this package is also a direct dependency in any scope.
1667
2160
  // Package managers handle overrides for direct dependencies differently:
1668
2161
  // - npm: EOVERRIDE error - doesn't allow overrides that conflict with direct deps
@@ -1697,6 +2190,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1697
2190
  versionSpecificKey,
1698
2191
  fix.newVersion
1699
2192
  );
2193
+ // Track comment for this override
2194
+ overrideComments.set(versionSpecificKey, this.generateOverrideComment(fix));
1700
2195
  } else {
1701
2196
  // Yarn doesn't support version-specific resolutions
1702
2197
  // Skip this fix - the direct dependency at a higher version is already safe
@@ -1739,7 +2234,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1739
2234
  if (useVersionSpecificOverride) {
1740
2235
  // Use version-specific override for packages with multiple major versions
1741
2236
  // e.g., "semver@^6": "6.3.1", "semver@^7": "7.5.2"
1742
- // Note: This only works for npm, pnpm, and bun - not Yarn
2237
+ // Note: This works for npm, pnpm, and bun - not Yarn
1743
2238
  const versionSpecificKey = `${fix.packageName}@^${fix.originalMajorVersion}`;
1744
2239
  packageJson = applyOverrideToPackageJson(
1745
2240
  packageJson,
@@ -1747,6 +2242,8 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1747
2242
  versionSpecificKey,
1748
2243
  fix.newVersion
1749
2244
  );
2245
+ // Track comment for this override
2246
+ overrideComments.set(versionSpecificKey, this.generateOverrideComment(fix));
1750
2247
  } else {
1751
2248
  // Single major version and no existing version-specific overrides
1752
2249
  // Use simple package override
@@ -1756,34 +2253,93 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
1756
2253
  fix.packageName,
1757
2254
  fix.newVersion
1758
2255
  );
1759
- }
1760
-
1761
- // For pnpm, also add as devDependency. This is needed because pnpm's
1762
- // overrides don't work reliably in all cases:
1763
- // 1. Auto-installed peer dependencies when autoInstallPeers is enabled
1764
- // 2. Packages with multiple major versions in the dependency tree
1765
- // 3. Optional dependencies resolved before overrides take effect
1766
- // Adding as devDependency ensures pnpm installs the specified version.
1767
- if (packageManager === PackageManager.Pnpm) {
1768
- if (!packageJson.devDependencies) {
1769
- packageJson.devDependencies = {};
1770
- }
1771
- // Only add if not already a direct dependency in any scope
1772
- if (!packageJson.dependencies?.[fix.packageName] &&
1773
- !packageJson.devDependencies[fix.packageName] &&
1774
- !packageJson.peerDependencies?.[fix.packageName] &&
1775
- !packageJson.optionalDependencies?.[fix.packageName]) {
1776
- // Use version prefix based on maximumUpgradeDelta to allow
1777
- // future patches that might fix additional vulnerabilities
1778
- const prefix = this.getVersionPrefixForDelta();
1779
- packageJson.devDependencies[fix.packageName] = prefix + fix.newVersion;
1780
- }
2256
+ // Track comment for this override
2257
+ overrideComments.set(fix.packageName, this.generateOverrideComment(fix));
1781
2258
  }
1782
2259
  }
1783
2260
  }
1784
2261
 
2262
+ // Add override comments if enabled and there are comments to add
2263
+ if (this.addOverrideComments && overrideComments.size > 0) {
2264
+ packageJson = this.addOverrideCommentsToPackageJson(
2265
+ packageJson,
2266
+ packageManager,
2267
+ overrideComments
2268
+ );
2269
+ }
2270
+
1785
2271
  return JSON.stringify(packageJson, null, 2);
1786
2272
  }
2273
+
2274
+ /**
2275
+ * Generates a comment string for an override from its CVE summaries.
2276
+ * Format: "CVE-XXXX-YYYY: summary; CVE-ZZZZ-WWWW: summary"
2277
+ */
2278
+ private generateOverrideComment(fix: VulnerabilityFix): string {
2279
+ if (!fix.cveSummaries || fix.cveSummaries.size === 0) {
2280
+ // Fallback: just list CVE IDs if no summaries available
2281
+ return fix.cves.join(', ');
2282
+ }
2283
+
2284
+ const parts: string[] = [];
2285
+ for (const cve of fix.cves) {
2286
+ const summary = fix.cveSummaries.get(cve);
2287
+ if (summary) {
2288
+ // Truncate summary if too long (keep first 80 chars)
2289
+ const truncatedSummary = summary.length > 80
2290
+ ? summary.substring(0, 77) + '...'
2291
+ : summary;
2292
+ parts.push(`${cve}: ${truncatedSummary}`);
2293
+ } else {
2294
+ parts.push(cve);
2295
+ }
2296
+ }
2297
+ return parts.join('; ');
2298
+ }
2299
+
2300
+ /**
2301
+ * Adds a comment field alongside the overrides/resolutions to document CVEs.
2302
+ * Merges with existing comments, updating entries for packages being modified.
2303
+ * Uses different field names based on package manager:
2304
+ * - npm/bun: "//overrides"
2305
+ * - pnpm: "//pnpm.overrides"
2306
+ * - yarn: "//resolutions"
2307
+ */
2308
+ private addOverrideCommentsToPackageJson(
2309
+ packageJson: Record<string, any>,
2310
+ packageManager: PackageManager,
2311
+ comments: Map<string, string>
2312
+ ): Record<string, any> {
2313
+ // Determine the comment field name based on package manager
2314
+ let commentFieldName: string;
2315
+ switch (packageManager) {
2316
+ case PackageManager.Npm:
2317
+ case PackageManager.Bun:
2318
+ commentFieldName = '//overrides';
2319
+ break;
2320
+ case PackageManager.Pnpm:
2321
+ commentFieldName = '//pnpm.overrides';
2322
+ break;
2323
+ case PackageManager.YarnClassic:
2324
+ case PackageManager.YarnBerry:
2325
+ commentFieldName = '//resolutions';
2326
+ break;
2327
+ default:
2328
+ return packageJson;
2329
+ }
2330
+
2331
+ // Get existing comments (if any) and merge with new ones
2332
+ const existingComments: Record<string, string> = packageJson[commentFieldName] || {};
2333
+ const mergedComments: Record<string, string> = {...existingComments};
2334
+
2335
+ // Add/update comments for new overrides
2336
+ for (const [key, comment] of comments) {
2337
+ mergedComments[key] = comment;
2338
+ }
2339
+
2340
+ packageJson[commentFieldName] = mergedComments;
2341
+ return packageJson;
2342
+ }
1787
2343
  }
1788
2344
 
1789
2345
  /**