@openrewrite/recipes-nodejs 0.37.0-20251224-170410 → 0.37.0-20260102-170441

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.
@@ -4,21 +4,33 @@
4
4
  * Moderne Proprietary. Only for use by Moderne customers under the terms of a commercial contract.
5
5
  */
6
6
 
7
- import {Column, DataTable, ExecutionContext, Option, Recipe, ScanningRecipe, Tree, TreeVisitor, markupWarn} from "@openrewrite/rewrite";
8
- import {getMemberKeyName, isLiteral, Json, JsonParser, JsonVisitor, isJson} from "@openrewrite/rewrite/json";
9
- import {TreePrinters} from "@openrewrite/rewrite";
10
- import {PlainText, PlainTextParser, isPlainText} from "@openrewrite/rewrite/text";
7
+ import {
8
+ Column,
9
+ DataTable,
10
+ ExecutionContext,
11
+ markupWarn,
12
+ Option,
13
+ ScanningRecipe,
14
+ Tree,
15
+ TreePrinters,
16
+ TreeVisitor
17
+ } from "@openrewrite/rewrite";
18
+ import {isJson, Json, JsonParser} from "@openrewrite/rewrite/json";
19
+ import {isPlainText, PlainText, PlainTextParser} from "@openrewrite/rewrite/text";
11
20
  import {isDocuments, isYaml, Yaml, YamlParser} from "@openrewrite/rewrite/yaml";
12
21
  import {
13
- DependencyScope,
14
- findNodeResolutionResult,
15
- ResolvedDependency,
16
- PackageManager,
22
+ applyOverrideToPackageJson,
17
23
  createDependencyRecipeAccumulator,
18
24
  DependencyRecipeAccumulator,
25
+ DependencyScope,
26
+ findNodeResolutionResult,
19
27
  getUpdatedLockFileContent,
28
+ NpmrcScope,
29
+ PackageManager,
30
+ ResolvedDependency,
20
31
  runInstallIfNeeded,
21
32
  runInstallInTempDir,
33
+ runWorkspaceInstallInTempDir,
22
34
  storeInstallResult,
23
35
  updateNodeResolutionMarker
24
36
  } from "@openrewrite/rewrite/javascript";
@@ -26,6 +38,13 @@ import * as semver from "semver";
26
38
  import * as path from "path";
27
39
  import {parseSeverity, Severity, severityOrdinal, Vulnerability, VulnerabilityDatabase} from "./vulnerability";
28
40
 
41
+ /**
42
+ * All dependency scopes that can contain dependencies in package.json.
43
+ */
44
+ const ALL_DEPENDENCY_SCOPES: DependencyScope[] = [
45
+ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'
46
+ ];
47
+
29
48
  /**
30
49
  * Maximum upgrade delta for version upgrades.
31
50
  * Use 'none' to only report vulnerabilities without making any changes.
@@ -185,20 +204,22 @@ interface VulnerabilityFix {
185
204
  cves: string[];
186
205
  /** The scope where this dependency was found (for direct deps) */
187
206
  scope?: DependencyScope;
207
+ /** The original major version (for version-specific overrides) */
208
+ originalMajorVersion?: number;
188
209
  }
189
210
 
190
211
  /**
191
212
  * Project info for lock file updates.
192
213
  */
193
214
  interface ProjectUpdateInfo {
194
- /** Absolute path to the project directory */
195
- projectDir: string;
196
215
  /** Relative path to package.json (from source root) */
197
216
  packageJsonPath: string;
198
217
  /** Original package.json content */
199
218
  originalPackageJson: string;
200
219
  /** The package manager used by this project */
201
220
  packageManager: PackageManager;
221
+ /** Config file contents extracted from the project (e.g., .npmrc) */
222
+ configFiles?: Record<string, string>;
202
223
  }
203
224
 
204
225
  /**
@@ -211,6 +232,16 @@ interface Accumulator extends DependencyRecipeAccumulator<ProjectUpdateInfo> {
211
232
  vulnerableByProject: Map<string, VulnerableDependency[]>;
212
233
  /** Fixes to apply, grouped by package.json path */
213
234
  fixesByProject: Map<string, VulnerabilityFix[]>;
235
+ /** Original lock file content, keyed by lock file path */
236
+ originalLockFiles: Map<string, string>;
237
+ /** All original package.json contents by path (for workspace support) */
238
+ allPackageJsonContents: Map<string, string>;
239
+ /** Workspace roots with their member paths: root path -> member paths */
240
+ workspaceRoots: Map<string, string[]>;
241
+ /** Modified package.json contents after applying fixes (for workspace members) */
242
+ modifiedWorkspaceMemberContents: Map<string, string>;
243
+ /** Flag to track if workspace detection has been performed */
244
+ workspaceDetectionComplete: boolean;
214
245
  }
215
246
 
216
247
  /**
@@ -285,7 +316,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
285
316
  example: "MODERATE",
286
317
  valid: ["LOW", "MODERATE", "HIGH", "CRITICAL"]
287
318
  })
288
- minimumSeverity?: string;
319
+ minimumSeverity?: Severity;
289
320
 
290
321
  @Option({
291
322
  displayName: "CVE pattern",
@@ -298,42 +329,74 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
298
329
  })
299
330
  cvePattern?: string;
300
331
 
301
- initialValue(_ctx: ExecutionContext): Accumulator {
332
+ @Option({
333
+ displayName: "Fix declared versions",
334
+ description: "When enabled, also upgrades version specifiers declared in package.json that specify vulnerable versions, " +
335
+ "even if the lock file already resolves to a safe version. This is a preventive measure to ensure that " +
336
+ "future installs (e.g., on a different machine or after lock file changes) won't install vulnerable versions. " +
337
+ "These preventive upgrades are NOT reported in the vulnerability data table since there's no actual vulnerability. " +
338
+ "Default is false.",
339
+ required: false,
340
+ example: "true"
341
+ })
342
+ fixDeclaredVersions?: boolean;
343
+
344
+ /** Cached compiled regex for CVE pattern matching */
345
+ private cvePatternRegex?: RegExp;
346
+
347
+ constructor(options?: {
348
+ scope?: DependencyScope;
349
+ overrideTransitive?: boolean;
350
+ maximumUpgradeDelta?: UpgradeDelta;
351
+ minimumSeverity?: string;
352
+ cvePattern?: string;
353
+ fixDeclaredVersions?: boolean;
354
+ }) {
355
+ super(options);
356
+ this.maximumUpgradeDelta ??= 'patch';
357
+ this.minimumSeverity ??= Severity.LOW;
358
+ this.fixDeclaredVersions ??= false;
359
+ if (options?.minimumSeverity) {
360
+ this.minimumSeverity = parseSeverity(options.minimumSeverity);
361
+ }
362
+ // Pre-compile CVE pattern regex for performance
363
+ if (this.cvePattern) {
364
+ try {
365
+ this.cvePatternRegex = new RegExp(this.cvePattern);
366
+ } catch {
367
+ // Invalid regex pattern - will match all CVEs
368
+ }
369
+ }
370
+ }
371
+
372
+ override initialValue(_ctx: ExecutionContext): Accumulator {
302
373
  return {
303
374
  // DependencyRecipeAccumulator fields
304
375
  ...createDependencyRecipeAccumulator<ProjectUpdateInfo>(),
305
376
  // Our custom fields
306
377
  db: VulnerabilityDatabase.load(),
307
378
  vulnerableByProject: new Map(),
308
- fixesByProject: new Map()
379
+ fixesByProject: new Map(),
380
+ originalLockFiles: new Map(),
381
+ allPackageJsonContents: new Map(),
382
+ workspaceRoots: new Map(),
383
+ modifiedWorkspaceMemberContents: new Map(),
384
+ workspaceDetectionComplete: false
309
385
  };
310
386
  }
311
387
 
312
- private getMinimumSeverity(): Severity {
313
- return this.minimumSeverity ? parseSeverity(this.minimumSeverity) : Severity.LOW;
314
- }
315
-
316
- private getMaximumUpgradeDelta(): UpgradeDelta {
317
- return this.maximumUpgradeDelta || 'patch';
318
- }
319
-
320
388
  private isReportOnly(): boolean {
321
- return this.getMaximumUpgradeDelta() === 'none';
389
+ return this.maximumUpgradeDelta === 'none';
322
390
  }
323
391
 
324
392
  /**
325
393
  * Checks if a vulnerability matches the CVE pattern filter.
326
394
  */
327
395
  private matchesCvePattern(vulnerability: Vulnerability): boolean {
328
- if (!this.cvePattern) {
329
- return true;
330
- }
331
- try {
332
- const regex = new RegExp(this.cvePattern);
333
- return regex.test(vulnerability.cve);
334
- } catch {
335
- return true; // Invalid regex, don't filter
396
+ if (!this.cvePatternRegex) {
397
+ return true; // No pattern or invalid pattern - match all
336
398
  }
399
+ return this.cvePatternRegex.test(vulnerability.cve);
337
400
  }
338
401
 
339
402
  /**
@@ -388,7 +451,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
388
451
  const current = semver.parse(currentVersion);
389
452
  if (!current) return false;
390
453
 
391
- const delta = this.getMaximumUpgradeDelta();
454
+ const delta = this.maximumUpgradeDelta!;
392
455
 
393
456
  // If we have a fixed version, check if it's reachable within delta
394
457
  if (vulnerability.fixedVersion) {
@@ -416,7 +479,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
416
479
  switch (delta) {
417
480
  case 'patch':
418
481
  return current.major === lastAffected.major &&
419
- current.minor === lastAffected.minor;
482
+ current.minor === lastAffected.minor;
420
483
  case 'minor':
421
484
  return current.major === lastAffected.major;
422
485
  case 'major':
@@ -445,6 +508,26 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
445
508
  return undefined;
446
509
  }
447
510
 
511
+ /**
512
+ * Gets the version prefix to use based on the maximum upgrade delta.
513
+ * This allows future patches that might fix additional vulnerabilities.
514
+ *
515
+ * - 'patch' → '~' (allows patch updates, e.g., ~1.2.3 matches 1.2.x)
516
+ * - 'minor' → '^' (allows minor and patch updates, e.g., ^1.2.3 matches 1.x.x)
517
+ * - 'major' → '^' (same as minor; ^ is reasonable even for major upgrades)
518
+ */
519
+ private getVersionPrefixForDelta(): string {
520
+ switch (this.maximumUpgradeDelta) {
521
+ case 'patch':
522
+ return '~';
523
+ case 'minor':
524
+ case 'major':
525
+ return '^';
526
+ default:
527
+ return '';
528
+ }
529
+ }
530
+
448
531
  /**
449
532
  * Renders a dependency path as a string.
450
533
  * Example: "dependencies > lodash@4.17.20 > vulnerable-pkg@1.0.0"
@@ -478,13 +561,13 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
478
561
  visited.add(key);
479
562
 
480
563
  // Build current path
481
- const currentPath = [...path, { name: resolved.name, version: resolved.version }];
564
+ const currentPath = [...path, {name: resolved.name, version: resolved.version}];
482
565
 
483
566
  // Check for vulnerabilities in this package
484
567
  const vulns = db.getVulnerabilities(resolved.name);
485
568
  for (const vuln of vulns) {
486
569
  // Filter by severity
487
- if (severityOrdinal(vuln.severity) < severityOrdinal(this.getMinimumSeverity())) {
570
+ if (severityOrdinal(vuln.severity) < severityOrdinal(this.minimumSeverity!)) {
488
571
  continue;
489
572
  }
490
573
 
@@ -532,33 +615,266 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
532
615
  }
533
616
  }
534
617
 
618
+ /**
619
+ * Finds preventive fixes for declared versions that are vulnerable,
620
+ * even if the resolved version is safe. This ensures package.json
621
+ * specifies a safe version constraint for future installs.
622
+ */
623
+ private findPreventiveFixes(
624
+ marker: { [key: string]: any },
625
+ scopes: DependencyScope[],
626
+ db: VulnerabilityDatabase
627
+ ): VulnerabilityFix[] {
628
+ const fixes: VulnerabilityFix[] = [];
629
+
630
+ for (const scope of scopes) {
631
+ const deps = marker[scope] || [];
632
+ for (const dep of deps) {
633
+ // Skip if no resolved version (can't compare)
634
+ if (!dep.resolved) continue;
635
+
636
+ // Extract the minimum version from the version constraint
637
+ const declaredMinVersion = this.extractMinimumVersion(dep.versionConstraint);
638
+ if (!declaredMinVersion) continue;
639
+
640
+ // Skip if declared version equals resolved version (no discrepancy)
641
+ if (declaredMinVersion === dep.resolved.version) continue;
642
+
643
+ // Check if the declared minimum version is vulnerable
644
+ const vulns = db.getVulnerabilities(dep.name);
645
+ const affectedCves: string[] = [];
646
+ let highestFixVersion: string | undefined;
647
+
648
+ for (const vuln of vulns) {
649
+ // Filter by severity
650
+ if (severityOrdinal(vuln.severity) < severityOrdinal(this.minimumSeverity!)) {
651
+ continue;
652
+ }
653
+
654
+ // Filter by CVE pattern
655
+ if (!this.matchesCvePattern(vuln)) {
656
+ continue;
657
+ }
658
+
659
+ // Check if declared version is affected but resolved is not
660
+ if (this.isVersionAffected(declaredMinVersion, vuln) &&
661
+ !this.isVersionAffected(dep.resolved.version, vuln)) {
662
+ affectedCves.push(vuln.cve);
663
+
664
+ // Find fix version for this vulnerability.
665
+ // Only use fixedVersion - don't guess based on lastAffectedVersion
666
+ // since the guessed version may not exist (e.g., vm2@3.9.20 doesn't exist,
667
+ // vue-template-compiler@3.0.0 will never exist).
668
+ const fixVersion = vuln.fixedVersion;
669
+
670
+ if (fixVersion && (!highestFixVersion || semver.gt(fixVersion, highestFixVersion))) {
671
+ highestFixVersion = fixVersion;
672
+ }
673
+ }
674
+ }
675
+
676
+ if (affectedCves.length > 0 && highestFixVersion) {
677
+ // Check if upgrade is within allowed delta
678
+ if (this.isUpgradeWithinDelta(declaredMinVersion, highestFixVersion)) {
679
+ const majorVersion = semver.major(declaredMinVersion);
680
+ fixes.push({
681
+ packageName: dep.name,
682
+ newVersion: highestFixVersion,
683
+ scope,
684
+ isTransitive: false,
685
+ cves: affectedCves,
686
+ originalMajorVersion: majorVersion
687
+ });
688
+ }
689
+ }
690
+ }
691
+ }
692
+
693
+ return fixes;
694
+ }
695
+
696
+ /**
697
+ * Checks if upgrading from one version to another is within the allowed delta.
698
+ * Returns false in report-only mode.
699
+ */
700
+ private isUpgradeWithinDelta(fromVersion: string, toVersion: string): boolean {
701
+ if (this.isReportOnly()) {
702
+ return false;
703
+ }
704
+ return this.isVersionWithinDelta(fromVersion, toVersion);
705
+ }
706
+
707
+ /**
708
+ * Extracts the minimum version from a version constraint.
709
+ * For example: "^3.0.2" -> "3.0.2", "~1.2.3" -> "1.2.3", ">=2.0.0" -> "2.0.0"
710
+ */
711
+ private extractMinimumVersion(constraint: string): string | undefined {
712
+ if (!constraint) return undefined;
713
+
714
+ // Handle exact versions
715
+ if (semver.valid(constraint)) {
716
+ return constraint;
717
+ }
718
+
719
+ // Handle ranges with prefix: ^, ~, >=, >, etc.
720
+ const match = constraint.match(/^[~^>=<]*\s*(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)/);
721
+ if (match && semver.valid(match[1])) {
722
+ return match[1];
723
+ }
724
+
725
+ // Try to coerce other formats
726
+ const coerced = semver.coerce(constraint);
727
+ return coerced?.version;
728
+ }
729
+
730
+ /**
731
+ * Finds the highest safe version for a package, recursively checking if fix versions
732
+ * themselves have vulnerabilities. Respects maximumUpgradeDelta from the original version.
733
+ *
734
+ * @param packageName The package to find a safe version for
735
+ * @param originalVersion The original installed version (used to check delta constraints)
736
+ * @param initialFixVersion The initial fix version to start from
737
+ * @param db The vulnerability database
738
+ * @param visited Set of versions already checked (to prevent infinite loops)
739
+ * @returns The highest safe version within delta, or undefined if none found
740
+ */
741
+ private findHighestSafeVersion(
742
+ packageName: string,
743
+ originalVersion: string,
744
+ initialFixVersion: string,
745
+ db: VulnerabilityDatabase,
746
+ visited: Set<string> = new Set()
747
+ ): string | undefined {
748
+ // Prevent infinite loops
749
+ if (visited.has(initialFixVersion)) {
750
+ return initialFixVersion;
751
+ }
752
+ visited.add(initialFixVersion);
753
+
754
+ // Check if the fix version itself has vulnerabilities
755
+ const vulnsInFixVersion = db.getVulnerabilities(packageName)
756
+ .filter(v => this.isVersionAffected(initialFixVersion, v));
757
+
758
+ if (vulnsInFixVersion.length === 0) {
759
+ // No vulnerabilities in this version - it's safe
760
+ return initialFixVersion;
761
+ }
762
+
763
+ // Find fix versions for vulnerabilities in the current fix version
764
+ let highestFixVersion = initialFixVersion;
765
+ for (const vuln of vulnsInFixVersion) {
766
+ const fixVersion = this.getUpgradeVersion(vuln);
767
+ if (fixVersion && semver.valid(fixVersion)) {
768
+ // Check if this fix version is within delta from the ORIGINAL version
769
+ if (this.isVersionWithinDelta(originalVersion, fixVersion)) {
770
+ if (semver.gt(fixVersion, highestFixVersion)) {
771
+ highestFixVersion = fixVersion;
772
+ }
773
+ }
774
+ }
775
+ }
776
+
777
+ // If we found a higher version, recursively check it
778
+ if (highestFixVersion !== initialFixVersion) {
779
+ return this.findHighestSafeVersion(
780
+ packageName,
781
+ originalVersion,
782
+ highestFixVersion,
783
+ db,
784
+ visited
785
+ );
786
+ }
787
+
788
+ // Couldn't find a higher safe version within delta
789
+ return initialFixVersion;
790
+ }
791
+
792
+ /**
793
+ * Checks if a target version is within the allowed upgrade delta from the original version.
794
+ */
795
+ private isVersionWithinDelta(originalVersion: string, targetVersion: string): boolean {
796
+ try {
797
+ const original = semver.parse(originalVersion);
798
+ const target = semver.parse(targetVersion);
799
+ if (!original || !target) return false;
800
+
801
+ switch (this.maximumUpgradeDelta) {
802
+ case 'patch':
803
+ return original.major === target.major && original.minor === target.minor;
804
+ case 'minor':
805
+ return original.major === target.major;
806
+ case 'major':
807
+ return true;
808
+ case 'none':
809
+ return false;
810
+ default:
811
+ return false;
812
+ }
813
+ } catch {
814
+ return false;
815
+ }
816
+ }
817
+
535
818
  /**
536
819
  * Computes the fixes to apply for the vulnerabilities found.
537
- * Groups vulnerabilities by package and determines the best fix version.
820
+ * Groups vulnerabilities by package AND major version to handle packages with multiple
821
+ * major versions in the dependency tree (e.g., semver@6.x and semver@7.x).
822
+ * Recursively checks if fix versions have their own vulnerabilities.
823
+ *
824
+ * @param vulnerabilities The vulnerabilities found during scanning
825
+ * @param db The vulnerability database
538
826
  */
539
- private computeFixes(vulnerabilities: VulnerableDependency[]): VulnerabilityFix[] {
827
+ private computeFixes(
828
+ vulnerabilities: VulnerableDependency[],
829
+ db: VulnerabilityDatabase
830
+ ): VulnerabilityFix[] {
540
831
  if (this.isReportOnly()) {
541
832
  return [];
542
833
  }
543
834
 
544
- // Group vulnerabilities by package name
545
- const byPackage = new Map<string, VulnerableDependency[]>();
835
+ // Group vulnerabilities by package name AND major version
836
+ // This handles packages like semver that may have multiple major versions
837
+ // (e.g., semver@6.3.0 and semver@7.5.0 both in the tree)
838
+ const byPackageAndMajor = new Map<string, VulnerableDependency[]>();
546
839
  for (const vuln of vulnerabilities) {
547
- const existing = byPackage.get(vuln.resolved.name) || [];
840
+ const parsed = semver.parse(vuln.resolved.version);
841
+ const major = parsed?.major ?? 0;
842
+ const key = `${vuln.resolved.name}@${major}`;
843
+ const existing = byPackageAndMajor.get(key) || [];
548
844
  existing.push(vuln);
549
- byPackage.set(vuln.resolved.name, existing);
845
+ byPackageAndMajor.set(key, existing);
846
+ }
847
+
848
+ // Count how many major versions exist for each package
849
+ const majorVersionsByPackage = new Map<string, Set<number>>();
850
+ for (const vuln of vulnerabilities) {
851
+ const parsed = semver.parse(vuln.resolved.version);
852
+ const major = parsed?.major ?? 0;
853
+ const existing = majorVersionsByPackage.get(vuln.resolved.name) || new Set();
854
+ existing.add(major);
855
+ majorVersionsByPackage.set(vuln.resolved.name, existing);
550
856
  }
551
857
 
552
858
  const fixes: VulnerabilityFix[] = [];
553
859
 
554
- for (const [packageName, vulns] of byPackage) {
555
- // Find the highest fix version needed across all vulnerabilities for this package
860
+ for (const [key, vulns] of byPackageAndMajor) {
861
+ const packageName = vulns[0].resolved.name;
862
+ const originalMajor = semver.parse(vulns[0].resolved.version)?.major ?? 0;
863
+ const hasMultipleMajorVersions = (majorVersionsByPackage.get(packageName)?.size ?? 0) > 1;
864
+
865
+ // Find the highest fix version needed across all vulnerabilities for this package@major
556
866
  let highestFixVersion: string | undefined;
557
867
  const cves: string[] = [];
558
868
  let isTransitive = true;
559
869
  let scope: DependencyScope | undefined;
870
+ let originalVersion: string | undefined;
560
871
 
561
872
  for (const vuln of vulns) {
873
+ // Track the original version for delta checking
874
+ if (!originalVersion) {
875
+ originalVersion = vuln.resolved.version;
876
+ }
877
+
562
878
  // Check if upgradeable within delta
563
879
  if (!this.isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability)) {
564
880
  continue;
@@ -566,8 +882,19 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
566
882
 
567
883
  const fixVersion = this.getUpgradeVersion(vuln.vulnerability);
568
884
  if (fixVersion) {
569
- if (!highestFixVersion || semver.gt(fixVersion, highestFixVersion)) {
570
- highestFixVersion = fixVersion;
885
+ // When there are multiple major versions of the same package in the tree
886
+ // (e.g., semver@6.x and semver@7.x), only consider fix versions that match
887
+ // the same major version to avoid suggesting semver@7.5.2 as a fix for semver@6.3.0.
888
+ // When there's only one major version, respect maximumUpgradeDelta instead.
889
+ const fixMajor = semver.parse(fixVersion)?.major;
890
+ const shouldConsiderFix = hasMultipleMajorVersions
891
+ ? fixMajor === originalMajor
892
+ : true; // Let isUpgradeableWithinDelta handle the constraint
893
+
894
+ if (shouldConsiderFix) {
895
+ if (!highestFixVersion || semver.gt(fixVersion, highestFixVersion)) {
896
+ highestFixVersion = fixVersion;
897
+ }
571
898
  }
572
899
  }
573
900
 
@@ -579,13 +906,22 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
579
906
  }
580
907
  }
581
908
 
582
- if (highestFixVersion && cves.length > 0) {
909
+ if (highestFixVersion && cves.length > 0 && originalVersion) {
910
+ // Recursively find the highest safe version (checking if fix versions have vulns)
911
+ const safeVersion = this.findHighestSafeVersion(
912
+ packageName,
913
+ originalVersion,
914
+ highestFixVersion,
915
+ db
916
+ );
917
+
583
918
  fixes.push({
584
919
  packageName,
585
- newVersion: highestFixVersion,
920
+ newVersion: safeVersion || highestFixVersion,
586
921
  isTransitive,
587
922
  cves,
588
- scope
923
+ scope,
924
+ originalMajorVersion: originalMajor
589
925
  });
590
926
  }
591
927
  }
@@ -593,28 +929,67 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
593
929
  return fixes;
594
930
  }
595
931
 
596
- async scanner(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
932
+ override async scanner(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
597
933
  const recipe = this;
598
934
 
599
- return new class extends JsonVisitor<ExecutionContext> {
600
- protected async visitDocument(doc: Json.Document, _ctx: ExecutionContext): Promise<Json | undefined> {
601
- // Only process package.json files
935
+ // Lock file names to capture
936
+ const LOCK_FILE_NAMES = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'bun.lock'];
937
+
938
+ return new class extends TreeVisitor<Tree, ExecutionContext> {
939
+ protected async accept(tree: Tree, ctx: ExecutionContext): Promise<Tree | undefined> {
940
+ // Handle JSON documents (package.json and JSON lock files)
941
+ if (isJson(tree) && tree.kind === Json.Kind.Document) {
942
+ return this.handleJsonDocument(tree as Json.Document, ctx);
943
+ }
944
+
945
+ // Handle YAML documents (pnpm-lock.yaml)
946
+ if (isYaml(tree) && isDocuments(tree)) {
947
+ return this.handleYamlDocument(tree, ctx);
948
+ }
949
+
950
+ // Handle PlainText files (yarn.lock for Yarn Classic)
951
+ if (isPlainText(tree)) {
952
+ return this.handlePlainTextDocument(tree as PlainText, ctx);
953
+ }
954
+
955
+ return tree;
956
+ }
957
+
958
+ private async handleJsonDocument(doc: Json.Document, _ctx: ExecutionContext): Promise<Json | undefined> {
959
+ const basename = path.basename(doc.sourcePath);
960
+
961
+ // Capture JSON lock file content (package-lock.json, bun.lock)
962
+ if (LOCK_FILE_NAMES.includes(basename)) {
963
+ acc.originalLockFiles.set(doc.sourcePath, await TreePrinters.print(doc));
964
+ return doc;
965
+ }
966
+
967
+ // Only process package.json files for vulnerability analysis
602
968
  if (!doc.sourcePath.endsWith('package.json')) {
603
969
  return doc;
604
970
  }
605
971
 
972
+ // Store all package.json contents for workspace support
973
+ const packageJsonContent = await TreePrinters.print(doc);
974
+ acc.allPackageJsonContents.set(doc.sourcePath, packageJsonContent);
975
+
606
976
  const marker = findNodeResolutionResult(doc);
607
977
  if (!marker) {
608
978
  return doc;
609
979
  }
610
980
 
981
+ // Track workspace roots and their member paths
982
+ if (marker.workspacePackagePaths && marker.workspacePackagePaths.length > 0) {
983
+ acc.workspaceRoots.set(doc.sourcePath, [...marker.workspacePackagePaths]);
984
+ }
985
+
611
986
  const vulnerabilities: VulnerableDependency[] = [];
612
987
  const visited = new Set<string>();
613
988
 
614
989
  // Determine which scopes to check
615
990
  const scopesToCheck: DependencyScope[] = recipe.scope
616
991
  ? [recipe.scope]
617
- : ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
992
+ : ALL_DEPENDENCY_SCOPES;
618
993
 
619
994
  for (const scope of scopesToCheck) {
620
995
  const deps = marker[scope] || [];
@@ -634,46 +1009,100 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
634
1009
  }
635
1010
  }
636
1011
 
637
- if (vulnerabilities.length > 0) {
638
- acc.vulnerableByProject.set(doc.sourcePath, vulnerabilities);
1012
+ // Extract package manager and config files for potential updates
1013
+ const pm = marker.packageManager ?? PackageManager.Npm;
1014
+ const configFiles: Record<string, string> = {};
1015
+ const projectNpmrc = marker.npmrcConfigs?.find(c => c.scope === NpmrcScope.Project);
1016
+ if (projectNpmrc) {
1017
+ const lines = Object.entries(projectNpmrc.properties)
1018
+ .map(([key, value]) => `${key}=${value}`);
1019
+ configFiles['.npmrc'] = lines.join('\n');
1020
+ }
639
1021
 
640
- // Compute fixes
641
- const fixes = recipe.computeFixes(vulnerabilities);
642
- if (fixes.length > 0) {
643
- acc.fixesByProject.set(doc.sourcePath, fixes);
1022
+ // Check if this is a workspace root (has workspaces field)
1023
+ // We need to store project info for workspace roots even if they don't have
1024
+ // vulnerabilities themselves, because their members might have vulnerabilities
1025
+ const storedContent = acc.allPackageJsonContents.get(doc.sourcePath);
1026
+ let isWorkspaceRoot = false;
1027
+ if (storedContent) {
1028
+ try {
1029
+ isWorkspaceRoot = JSON.parse(storedContent).workspaces !== undefined;
1030
+ } catch {
1031
+ // Invalid JSON, not a workspace root
1032
+ }
1033
+ }
644
1034
 
645
- // Store project info for lock file updates
646
- const projectDir = path.dirname(path.resolve(doc.sourcePath));
647
- const pm = marker.packageManager ?? PackageManager.Npm;
1035
+ // Compute fixes from actual vulnerabilities (these get reported in data table)
1036
+ let fixes: VulnerabilityFix[] = [];
1037
+ if (vulnerabilities.length > 0) {
1038
+ acc.vulnerableByProject.set(doc.sourcePath, vulnerabilities);
1039
+ fixes = recipe.computeFixes(vulnerabilities, acc.db);
1040
+ }
648
1041
 
649
- acc.projectsToUpdate.set(doc.sourcePath, {
650
- projectDir,
651
- packageJsonPath: doc.sourcePath,
652
- originalPackageJson: await this.printDocument(doc),
653
- packageManager: pm
654
- });
1042
+ // Fix declared versions for preventive upgrades (when enabled)
1043
+ // These are NOT reported in the data table since the resolved version is safe
1044
+ if (recipe.fixDeclaredVersions) {
1045
+ const preventiveFixes = recipe.findPreventiveFixes(marker, scopesToCheck, acc.db);
1046
+ // Merge preventive fixes, avoiding duplicates with actual fixes
1047
+ const existingPackages = new Set(fixes.map(f => `${f.packageName}@${f.scope}`));
1048
+ for (const fix of preventiveFixes) {
1049
+ const key = `${fix.packageName}@${fix.scope}`;
1050
+ if (!existingPackages.has(key)) {
1051
+ fixes.push(fix);
1052
+ existingPackages.add(key);
1053
+ }
655
1054
  }
656
1055
  }
657
1056
 
1057
+ if (fixes.length > 0) {
1058
+ acc.fixesByProject.set(doc.sourcePath, fixes);
1059
+
1060
+ // Store project info for lock file updates
1061
+ acc.projectsToUpdate.set(doc.sourcePath, {
1062
+ packageJsonPath: doc.sourcePath,
1063
+ originalPackageJson: await TreePrinters.print(doc),
1064
+ packageManager: pm,
1065
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
1066
+ });
1067
+ } else if (isWorkspaceRoot && !acc.projectsToUpdate.has(doc.sourcePath)) {
1068
+ // Workspace root without vulnerabilities - still need project info
1069
+ // for processing workspace members with vulnerabilities
1070
+ acc.projectsToUpdate.set(doc.sourcePath, {
1071
+ packageJsonPath: doc.sourcePath,
1072
+ originalPackageJson: await TreePrinters.print(doc),
1073
+ packageManager: pm,
1074
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
1075
+ });
1076
+ }
1077
+
658
1078
  return doc;
659
1079
  }
660
1080
 
661
- private async printDocument(doc: Json.Document): Promise<string> {
662
- return TreePrinters.print(doc);
1081
+ private async handleYamlDocument(docs: Yaml.Documents, _ctx: ExecutionContext): Promise<Yaml.Documents | undefined> {
1082
+ const basename = path.basename(docs.sourcePath);
1083
+
1084
+ // Capture YAML lock file content (pnpm-lock.yaml)
1085
+ if (LOCK_FILE_NAMES.includes(basename)) {
1086
+ acc.originalLockFiles.set(docs.sourcePath, await TreePrinters.print(docs));
1087
+ }
1088
+
1089
+ return docs;
663
1090
  }
664
- };
665
- }
666
1091
 
667
- /**
668
- * Returns sub-recipes to apply the fixes.
669
- */
670
- async getRecipeList(): Promise<Recipe[]> {
671
- // This method is called before scanning, so we can't return fix recipes here.
672
- // Instead, we'll apply fixes in the editor phase.
673
- return [];
674
- }
1092
+ private async handlePlainTextDocument(text: PlainText, _ctx: ExecutionContext): Promise<PlainText | undefined> {
1093
+ const basename = path.basename(text.sourcePath);
1094
+
1095
+ // Capture plain text lock file content (yarn.lock for Yarn Classic)
1096
+ if (LOCK_FILE_NAMES.includes(basename)) {
1097
+ acc.originalLockFiles.set(text.sourcePath, await TreePrinters.print(text));
1098
+ }
675
1099
 
676
- async editorWithData(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
1100
+ return text;
1101
+ }
1102
+ }
1103
+ };
1104
+
1105
+ override async editorWithData(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
677
1106
  const recipe = this;
678
1107
 
679
1108
  // YAML lock file names (pnpm-lock.yaml, and yarn.lock for Berry)
@@ -776,32 +1205,31 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
776
1205
 
777
1206
  private async handlePackageJson(doc: Json.Document, ctx: ExecutionContext): Promise<Json | undefined> {
778
1207
  const vulnerabilities = acc.vulnerableByProject.get(doc.sourcePath);
779
- if (!vulnerabilities || vulnerabilities.length === 0) {
780
- return doc;
781
- }
782
1208
 
783
- // Insert rows into the data table for each vulnerability
784
- for (const vuln of vulnerabilities) {
785
- const upgradeable = recipe.isUpgradeableWithinDelta(
786
- vuln.resolved.version,
787
- vuln.vulnerability
788
- );
1209
+ // Insert rows into the data table for each vulnerability (always, even in report-only mode)
1210
+ if (vulnerabilities && vulnerabilities.length > 0) {
1211
+ for (const vuln of vulnerabilities) {
1212
+ const upgradeable = recipe.isUpgradeableWithinDelta(
1213
+ vuln.resolved.version,
1214
+ vuln.vulnerability
1215
+ );
789
1216
 
790
- recipe.vulnerabilityReport.insertRow(ctx, new VulnerabilityReportRow(
791
- doc.sourcePath,
792
- vuln.vulnerability.cve,
793
- vuln.resolved.name,
794
- vuln.resolved.version,
795
- vuln.vulnerability.fixedVersion || '',
796
- vuln.vulnerability.lastAffectedVersion || '',
797
- upgradeable,
798
- vuln.vulnerability.summary,
799
- vuln.vulnerability.severity,
800
- vuln.depth,
801
- vuln.vulnerability.cwes,
802
- vuln.isDirect,
803
- recipe.renderPath(vuln.scope, vuln.path)
804
- ));
1217
+ recipe.vulnerabilityReport.insertRow(ctx, new VulnerabilityReportRow(
1218
+ doc.sourcePath,
1219
+ vuln.vulnerability.cve,
1220
+ vuln.resolved.name,
1221
+ vuln.resolved.version,
1222
+ vuln.vulnerability.fixedVersion || '',
1223
+ vuln.vulnerability.lastAffectedVersion || '',
1224
+ upgradeable,
1225
+ vuln.vulnerability.summary,
1226
+ vuln.vulnerability.severity,
1227
+ vuln.depth,
1228
+ vuln.vulnerability.cwes,
1229
+ vuln.isDirect,
1230
+ recipe.renderPath(vuln.scope, vuln.path)
1231
+ ));
1232
+ }
805
1233
  }
806
1234
 
807
1235
  // Apply fixes if not in report-only mode
@@ -809,13 +1237,97 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
809
1237
  return doc;
810
1238
  }
811
1239
 
1240
+ // Lazy workspace detection: build workspace map from collected package.json contents
1241
+ // This is needed because the parser might not have detected workspaces if the files
1242
+ // weren't on disk yet, or the marker wasn't populated correctly.
1243
+ if (!acc.workspaceDetectionComplete) {
1244
+ this.detectWorkspacesFromContents();
1245
+ acc.workspaceDetectionComplete = true;
1246
+ }
1247
+
1248
+ // Check if this file was already modified by workspace processing
1249
+ const preModifiedContent = acc.modifiedWorkspaceMemberContents.get(doc.sourcePath)
1250
+ || acc.updatedPackageJsons.get(doc.sourcePath);
1251
+ if (preModifiedContent) {
1252
+ return this.applyModifiedContent(doc, preModifiedContent);
1253
+ }
1254
+
1255
+ // Determine if this is a workspace root or member
1256
+ const isWorkspaceRoot = acc.workspaceRoots.has(doc.sourcePath);
1257
+ const workspaceRootPath = this.findWorkspaceRootFor(doc.sourcePath);
1258
+ const isWorkspaceMember = workspaceRootPath !== undefined;
1259
+
1260
+ // For workspace members, trigger workspace processing via the root
1261
+ if (isWorkspaceMember && !isWorkspaceRoot) {
1262
+ // Check if we need to process this workspace (any fixes in root or members)
1263
+ const needsProcessing = this.workspaceNeedsProcessing(workspaceRootPath);
1264
+ if (needsProcessing && !acc.processedProjects.has(workspaceRootPath)) {
1265
+ // Process the entire workspace
1266
+ const rootUpdateInfo = acc.projectsToUpdate.get(workspaceRootPath);
1267
+ if (rootUpdateInfo) {
1268
+ const failureMessage = await runInstallIfNeeded(workspaceRootPath, acc, () =>
1269
+ recipe.runWorkspacePackageManagerInstall(acc, workspaceRootPath, rootUpdateInfo)
1270
+ );
1271
+ if (failureMessage) {
1272
+ return markupWarn(
1273
+ doc,
1274
+ `Failed to fix vulnerabilities in workspace`,
1275
+ failureMessage
1276
+ );
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ // Return modified content if available
1282
+ const modifiedContent = acc.modifiedWorkspaceMemberContents.get(doc.sourcePath);
1283
+ if (modifiedContent) {
1284
+ return this.applyModifiedContent(doc, modifiedContent);
1285
+ }
1286
+ return doc;
1287
+ }
1288
+
1289
+ // For workspace roots, process the entire workspace
1290
+ if (isWorkspaceRoot) {
1291
+ const updateInfo = acc.projectsToUpdate.get(doc.sourcePath);
1292
+ if (!updateInfo) {
1293
+ return doc;
1294
+ }
1295
+
1296
+ const needsProcessing = this.workspaceNeedsProcessing(doc.sourcePath);
1297
+ if (!needsProcessing) {
1298
+ return doc;
1299
+ }
1300
+
1301
+ const failureMessage = await runInstallIfNeeded(doc.sourcePath, acc, () =>
1302
+ recipe.runWorkspacePackageManagerInstall(acc, doc.sourcePath, updateInfo)
1303
+ );
1304
+ if (failureMessage) {
1305
+ return markupWarn(
1306
+ doc,
1307
+ `Failed to fix vulnerabilities in ${doc.sourcePath}`,
1308
+ failureMessage
1309
+ );
1310
+ }
1311
+
1312
+ // Only apply changes to root if it has actual fixes (direct or transitive overrides)
1313
+ const rootFixes = acc.fixesByProject.get(doc.sourcePath) || [];
1314
+ if (rootFixes.length > 0) {
1315
+ const modifiedContent = acc.updatedPackageJsons.get(doc.sourcePath);
1316
+ if (modifiedContent) {
1317
+ const result = await this.applyModifiedContent(doc, modifiedContent);
1318
+ return updateNodeResolutionMarker(result as Json.Document, updateInfo, acc);
1319
+ }
1320
+ }
1321
+ return doc;
1322
+ }
1323
+
1324
+ // Non-workspace package.json: process normally
812
1325
  const fixes = acc.fixesByProject.get(doc.sourcePath);
813
1326
  const updateInfo = acc.projectsToUpdate.get(doc.sourcePath);
814
1327
  if (!fixes || fixes.length === 0 || !updateInfo) {
815
1328
  return doc;
816
1329
  }
817
1330
 
818
- // Run package manager install if needed
819
1331
  const failureMessage = await runInstallIfNeeded(doc.sourcePath, acc, () =>
820
1332
  recipe.runPackageManagerInstall(acc, updateInfo, fixes)
821
1333
  );
@@ -827,21 +1339,146 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
827
1339
  );
828
1340
  }
829
1341
 
830
- // Update the dependency versions in the JSON AST (preserves formatting)
831
- let result: Json.Document = doc;
832
- for (const fix of fixes) {
833
- if (!fix.isTransitive && fix.scope) {
834
- const visitor = new UpdateVersionVisitor(
835
- fix.packageName,
836
- fix.newVersion,
837
- fix.scope
838
- );
839
- result = await visitor.visit(result, undefined) as Json.Document;
1342
+ const modifiedPackageJson = acc.updatedPackageJsons.get(doc.sourcePath);
1343
+ if (!modifiedPackageJson) {
1344
+ return doc;
1345
+ }
1346
+
1347
+ const result = await this.applyModifiedContent(doc, modifiedPackageJson);
1348
+ return updateNodeResolutionMarker(result as Json.Document, updateInfo, acc);
1349
+ }
1350
+
1351
+ /**
1352
+ * Find the workspace root that contains a given package.json path.
1353
+ */
1354
+ private findWorkspaceRootFor(packageJsonPath: string): string | undefined {
1355
+ for (const [rootPath, memberPaths] of acc.workspaceRoots) {
1356
+ if (memberPaths.includes(packageJsonPath)) {
1357
+ return rootPath;
840
1358
  }
841
1359
  }
1360
+ return undefined;
1361
+ }
842
1362
 
843
- // Update the NodeResolutionResult marker
844
- return updateNodeResolutionMarker(result, updateInfo, acc);
1363
+ /**
1364
+ * Check if a workspace needs processing (has any fixes in root or members).
1365
+ */
1366
+ private workspaceNeedsProcessing(rootPath: string): boolean {
1367
+ // Check if root has fixes
1368
+ if (acc.fixesByProject.has(rootPath)) {
1369
+ return true;
1370
+ }
1371
+ // Check if any member has fixes
1372
+ const memberPaths = acc.workspaceRoots.get(rootPath) || [];
1373
+ for (const memberPath of memberPaths) {
1374
+ if (acc.fixesByProject.has(memberPath)) {
1375
+ return true;
1376
+ }
1377
+ }
1378
+ return false;
1379
+ }
1380
+
1381
+ /**
1382
+ * Detects workspaces from the collected package.json contents.
1383
+ * This is a fallback for when the parser doesn't detect workspaces
1384
+ * (e.g., marker.workspacePackagePaths wasn't populated).
1385
+ */
1386
+ private detectWorkspacesFromContents(): void {
1387
+ // Find package.json files with workspaces field
1388
+ for (const [pkgPath, content] of acc.allPackageJsonContents) {
1389
+ // Skip if already in workspaceRoots (parser detected it)
1390
+ if (acc.workspaceRoots.has(pkgPath)) {
1391
+ continue;
1392
+ }
1393
+
1394
+ try {
1395
+ const pkgJson = JSON.parse(content);
1396
+ if (!pkgJson.workspaces) {
1397
+ continue;
1398
+ }
1399
+
1400
+ // Get workspace patterns
1401
+ const patterns: string[] = Array.isArray(pkgJson.workspaces)
1402
+ ? pkgJson.workspaces
1403
+ : pkgJson.workspaces.packages || [];
1404
+
1405
+ if (patterns.length === 0) {
1406
+ continue;
1407
+ }
1408
+
1409
+ // Get the directory of this package.json
1410
+ const rootDir = path.dirname(pkgPath);
1411
+
1412
+ // Find member package.json paths that match the patterns
1413
+ const memberPaths: string[] = [];
1414
+ for (const [otherPath] of acc.allPackageJsonContents) {
1415
+ if (otherPath === pkgPath) continue;
1416
+
1417
+ // Check if this path matches any workspace pattern
1418
+ const relativePath = rootDir === '.'
1419
+ ? otherPath
1420
+ : otherPath.startsWith(rootDir + '/')
1421
+ ? otherPath.slice(rootDir.length + 1)
1422
+ : null;
1423
+
1424
+ if (relativePath && this.matchesWorkspacePattern(relativePath, patterns)) {
1425
+ memberPaths.push(otherPath);
1426
+ }
1427
+ }
1428
+
1429
+ if (memberPaths.length > 0) {
1430
+ acc.workspaceRoots.set(pkgPath, memberPaths);
1431
+ }
1432
+ } catch {
1433
+ // Invalid JSON, skip
1434
+ }
1435
+ }
1436
+ }
1437
+
1438
+ /**
1439
+ * Checks if a package.json path matches any of the workspace patterns.
1440
+ * Supports simple patterns like "packages/*" and "apps/*".
1441
+ */
1442
+ private matchesWorkspacePattern(relativePath: string, patterns: string[]): boolean {
1443
+ // relativePath is like "packages/member-a/package.json"
1444
+ // We need to check if the directory matches the pattern
1445
+
1446
+ for (const pattern of patterns) {
1447
+ if (pattern.endsWith('/*')) {
1448
+ // Pattern like "packages/*" should match "packages/anything/package.json"
1449
+ const baseDir = pattern.slice(0, -2);
1450
+ const pathParts = relativePath.split('/');
1451
+ // Check: baseDir/name/package.json
1452
+ if (pathParts.length === 3 &&
1453
+ pathParts[0] === baseDir &&
1454
+ pathParts[2] === 'package.json') {
1455
+ return true;
1456
+ }
1457
+ } else if (!pattern.includes('*')) {
1458
+ // Exact match pattern like "tools/eslint-config"
1459
+ if (relativePath === `${pattern}/package.json`) {
1460
+ return true;
1461
+ }
1462
+ }
1463
+ // TODO: Support more complex glob patterns if needed
1464
+ }
1465
+ return false;
1466
+ }
1467
+
1468
+ /**
1469
+ * Apply modified content to a document, preserving its ID and markers.
1470
+ */
1471
+ private async applyModifiedContent(doc: Json.Document, content: string): Promise<Json.Document> {
1472
+ const parsedModified = await new JsonParser({}).parseOne({
1473
+ text: content,
1474
+ sourcePath: doc.sourcePath
1475
+ }) as Json.Document;
1476
+
1477
+ return {
1478
+ ...doc,
1479
+ value: parsedModified.value,
1480
+ eof: parsedModified.eof
1481
+ } as Json.Document;
845
1482
  }
846
1483
  };
847
1484
  }
@@ -849,6 +1486,7 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
849
1486
  /**
850
1487
  * Runs the package manager in a temporary directory to update the lock file.
851
1488
  * Writes a modified package.json with all new versions, then runs install.
1489
+ * All file contents are provided from in-memory sources (SourceFiles), not read from disk.
852
1490
  */
853
1491
  private async runPackageManagerInstall(
854
1492
  acc: Accumulator,
@@ -858,33 +1496,289 @@ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
858
1496
  // Create modified package.json with all the new version constraints
859
1497
  const modifiedPackageJson = this.createModifiedPackageJson(
860
1498
  updateInfo.originalPackageJson,
861
- fixes
1499
+ fixes,
1500
+ updateInfo.packageManager
862
1501
  );
863
1502
 
1503
+ // Note: We intentionally do NOT pass the original lock file content.
1504
+ // npm (and other package managers) don't apply overrides to already-resolved
1505
+ // dependencies in an existing lock file. By not passing the original lock file,
1506
+ // we force a fresh resolution that respects the overrides.
1507
+ // This may cause more version changes in unrelated packages, but it's necessary
1508
+ // to actually fix the vulnerabilities.
1509
+
864
1510
  const result = await runInstallInTempDir(
865
- updateInfo.projectDir,
866
1511
  updateInfo.packageManager,
867
- modifiedPackageJson
1512
+ modifiedPackageJson,
1513
+ {
1514
+ configFiles: updateInfo.configFiles
1515
+ }
868
1516
  );
869
1517
 
870
1518
  storeInstallResult(result, acc, updateInfo, modifiedPackageJson);
871
1519
  }
872
1520
 
1521
+ /**
1522
+ * Runs the package manager in a temporary directory for a workspace.
1523
+ * Handles direct dependency fixes in workspace members and transitive fixes in root.
1524
+ */
1525
+ private async runWorkspacePackageManagerInstall(
1526
+ acc: Accumulator,
1527
+ rootPath: string,
1528
+ rootUpdateInfo: ProjectUpdateInfo
1529
+ ): Promise<void> {
1530
+ const memberPaths = acc.workspaceRoots.get(rootPath) || [];
1531
+ const pm = rootUpdateInfo.packageManager;
1532
+
1533
+ // Collect all transitive fixes from root and members (for root overrides)
1534
+ const allTransitiveFixes: VulnerabilityFix[] = [];
1535
+
1536
+ // Process root fixes
1537
+ const rootFixes = acc.fixesByProject.get(rootPath) || [];
1538
+ const rootDirectFixes = rootFixes.filter(f => !f.isTransitive);
1539
+ const rootTransitiveFixes = rootFixes.filter(f => f.isTransitive);
1540
+ allTransitiveFixes.push(...rootTransitiveFixes);
1541
+
1542
+ // Process member fixes and collect transitive fixes
1543
+ const memberDirectFixes = new Map<string, VulnerabilityFix[]>();
1544
+ for (const memberPath of memberPaths) {
1545
+ const memberFixes = acc.fixesByProject.get(memberPath) || [];
1546
+ const directFixes = memberFixes.filter(f => !f.isTransitive);
1547
+ const transitiveFixes = memberFixes.filter(f => f.isTransitive);
1548
+
1549
+ if (directFixes.length > 0) {
1550
+ memberDirectFixes.set(memberPath, directFixes);
1551
+ }
1552
+ allTransitiveFixes.push(...transitiveFixes);
1553
+ }
1554
+
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
+ // 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
+ const workspacePackages: Record<string, string> = {};
1567
+ for (const memberPath of memberPaths) {
1568
+ const originalContent = acc.allPackageJsonContents.get(memberPath);
1569
+ if (!originalContent) {
1570
+ continue;
1571
+ }
1572
+
1573
+ const directFixes = memberDirectFixes.get(memberPath);
1574
+ let modifiedContent: string;
1575
+ if (directFixes && directFixes.length > 0) {
1576
+ // Apply direct dependency fixes to this member
1577
+ modifiedContent = this.createModifiedPackageJsonDirectOnly(
1578
+ originalContent,
1579
+ directFixes
1580
+ );
1581
+ // Store the modified content for the actual output
1582
+ acc.modifiedWorkspaceMemberContents.set(memberPath, modifiedContent);
1583
+ } else {
1584
+ modifiedContent = originalContent;
1585
+ }
1586
+
1587
+ // Keep the full package.json including workspace: dependencies
1588
+ workspacePackages[memberPath] = modifiedContent;
1589
+ }
1590
+
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.
1595
+
1596
+ // Run workspace install with full workspace structure
1597
+ // We keep workspace: dependencies and the workspaces field so Yarn can resolve them
1598
+ const result = await runWorkspaceInstallInTempDir(
1599
+ pm,
1600
+ modifiedRootPackageJson,
1601
+ {
1602
+ configFiles: rootUpdateInfo.configFiles,
1603
+ workspacePackages
1604
+ }
1605
+ );
1606
+
1607
+ storeInstallResult(result, acc, rootUpdateInfo, modifiedRootPackageJson);
1608
+ }
1609
+
1610
+ /**
1611
+ * Creates a modified package.json with only direct dependency fixes.
1612
+ * Used for workspace members where transitive fixes go to the root.
1613
+ */
1614
+ private createModifiedPackageJsonDirectOnly(
1615
+ originalContent: string,
1616
+ fixes: VulnerabilityFix[]
1617
+ ): string {
1618
+ let packageJson = JSON.parse(originalContent);
1619
+
1620
+ for (const fix of fixes) {
1621
+ if (!fix.isTransitive && fix.scope) {
1622
+ // Direct dependency: update the version in the scope
1623
+ if (packageJson[fix.scope] && packageJson[fix.scope][fix.packageName]) {
1624
+ const originalVersion = packageJson[fix.scope][fix.packageName];
1625
+ packageJson[fix.scope][fix.packageName] = applyVersionPrefix(originalVersion, fix.newVersion);
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ return JSON.stringify(packageJson, null, 2);
1631
+ }
1632
+
873
1633
  /**
874
1634
  * Creates a modified package.json with all the updated dependency versions.
1635
+ * For direct dependencies, updates the version in the appropriate scope.
1636
+ * For transitive dependencies, adds overrides/resolutions based on the package manager.
1637
+ *
1638
+ * Note: For pnpm, transitive dependencies are also added as devDependencies because
1639
+ * pnpm's overrides don't work reliably for auto-installed peer dependencies when
1640
+ * autoInstallPeers is enabled (the default). Adding as a devDependency ensures
1641
+ * pnpm installs the specified version which then satisfies peer dependency requirements.
875
1642
  */
876
1643
  private createModifiedPackageJson(
877
1644
  originalContent: string,
878
- fixes: VulnerabilityFix[]
1645
+ fixes: VulnerabilityFix[],
1646
+ packageManager: PackageManager
879
1647
  ): string {
880
- const packageJson = JSON.parse(originalContent);
1648
+ let packageJson = JSON.parse(originalContent);
1649
+
1650
+ // Check if there are multiple fixes for the same package with different major versions
1651
+ const fixesByPackage = new Map<string, VulnerabilityFix[]>();
1652
+ for (const fix of fixes) {
1653
+ const existing = fixesByPackage.get(fix.packageName) || [];
1654
+ existing.push(fix);
1655
+ fixesByPackage.set(fix.packageName, existing);
1656
+ }
881
1657
 
882
1658
  for (const fix of fixes) {
883
1659
  if (!fix.isTransitive && fix.scope) {
1660
+ // Direct dependency: update the version in the scope
884
1661
  if (packageJson[fix.scope] && packageJson[fix.scope][fix.packageName]) {
885
1662
  const originalVersion = packageJson[fix.scope][fix.packageName];
886
1663
  packageJson[fix.scope][fix.packageName] = applyVersionPrefix(originalVersion, fix.newVersion);
887
1664
  }
1665
+ } else if (fix.isTransitive) {
1666
+ // Check if this package is also a direct dependency in any scope.
1667
+ // Package managers handle overrides for direct dependencies differently:
1668
+ // - npm: EOVERRIDE error - doesn't allow overrides that conflict with direct deps
1669
+ // - pnpm: May work but behavior can be inconsistent
1670
+ // - yarn: Resolutions may conflict with direct dependencies
1671
+ // - bun: Similar to npm
1672
+ // For all package managers, it's safest to update the direct dependency version
1673
+ // when the package is already a direct dependency.
1674
+ const directDepScope = findDirectDependencyScope(packageJson, fix.packageName);
1675
+
1676
+ if (directDepScope) {
1677
+ // Package is a direct dependency.
1678
+ // Check if the major version of the direct dependency matches the fix's major version.
1679
+ // This handles the case where a package exists at multiple major versions:
1680
+ // e.g., form-data@^4 (direct, safe) and form-data@3.0.1 (transitive, vulnerable)
1681
+ // We should NOT downgrade the direct dependency to fix the transitive.
1682
+ const directVersion = packageJson[directDepScope][fix.packageName];
1683
+ const directMajor = semver.major(semver.coerce(directVersion) || '0.0.0');
1684
+
1685
+ if (fix.originalMajorVersion !== undefined && directMajor !== fix.originalMajorVersion) {
1686
+ // Major versions don't match - use version-specific override for the transitive
1687
+ // instead of modifying the direct dependency
1688
+ const isYarn = packageManager === PackageManager.YarnClassic ||
1689
+ packageManager === PackageManager.YarnBerry;
1690
+
1691
+ if (!isYarn) {
1692
+ // Use version-specific override: e.g., "form-data@^3": "3.0.4"
1693
+ const versionSpecificKey = `${fix.packageName}@^${fix.originalMajorVersion}`;
1694
+ packageJson = applyOverrideToPackageJson(
1695
+ packageJson,
1696
+ packageManager,
1697
+ versionSpecificKey,
1698
+ fix.newVersion
1699
+ );
1700
+ } else {
1701
+ // Yarn doesn't support version-specific resolutions
1702
+ // Skip this fix - the direct dependency at a higher version is already safe
1703
+ // and we can't override just the transitive without affecting the direct
1704
+ }
1705
+ continue;
1706
+ }
1707
+
1708
+ // Major versions match - safe to update the direct dependency version
1709
+ packageJson[directDepScope][fix.packageName] = applyVersionPrefix(directVersion, fix.newVersion);
1710
+ // Don't add an override - it would conflict with the direct dependency
1711
+ continue;
1712
+ }
1713
+
1714
+ // Check if this package has multiple major versions needing fixes
1715
+ const packageFixes = fixesByPackage.get(fix.packageName) || [];
1716
+ const hasMultipleMajorVersions = packageFixes.length > 1 &&
1717
+ new Set(packageFixes.map(f => f.originalMajorVersion)).size > 1;
1718
+
1719
+ // Check if there are already version-specific overrides for this package.
1720
+ // If so, we should continue using version-specific overrides to avoid
1721
+ // a generic override clobbering the existing version-specific ones.
1722
+ const existingOverrides = getOverridesFromPackageJson(packageJson, packageManager);
1723
+ const hasExistingVersionSpecificOverrides = existingOverrides &&
1724
+ Object.keys(existingOverrides).some(key =>
1725
+ key.startsWith(`${fix.packageName}@`)
1726
+ );
1727
+
1728
+ // Yarn resolutions don't support version-specific keys like "package@^version".
1729
+ // When Yarn sees "js-yaml@^4": "4.1.1", it misinterprets it as resolving
1730
+ // package "js-yaml" to version "^4@4.1.1" which is invalid.
1731
+ // For Yarn, we must use simple package names and apply the highest fix version.
1732
+ const isYarn = packageManager === PackageManager.YarnClassic ||
1733
+ packageManager === PackageManager.YarnBerry;
1734
+
1735
+ const useVersionSpecificOverride = !isYarn &&
1736
+ (hasMultipleMajorVersions || hasExistingVersionSpecificOverrides) &&
1737
+ fix.originalMajorVersion !== undefined;
1738
+
1739
+ if (useVersionSpecificOverride) {
1740
+ // Use version-specific override for packages with multiple major versions
1741
+ // e.g., "semver@^6": "6.3.1", "semver@^7": "7.5.2"
1742
+ // Note: This only works for npm, pnpm, and bun - not Yarn
1743
+ const versionSpecificKey = `${fix.packageName}@^${fix.originalMajorVersion}`;
1744
+ packageJson = applyOverrideToPackageJson(
1745
+ packageJson,
1746
+ packageManager,
1747
+ versionSpecificKey,
1748
+ fix.newVersion
1749
+ );
1750
+ } else {
1751
+ // Single major version and no existing version-specific overrides
1752
+ // Use simple package override
1753
+ packageJson = applyOverrideToPackageJson(
1754
+ packageJson,
1755
+ packageManager,
1756
+ fix.packageName,
1757
+ fix.newVersion
1758
+ );
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
+ }
1781
+ }
888
1782
  }
889
1783
  }
890
1784
 
@@ -905,75 +1799,52 @@ export function extractVersionPrefix(versionString: string): { prefix: string; v
905
1799
  version: match[2]
906
1800
  };
907
1801
  }
908
- return { prefix: '', version: versionString };
1802
+ return {prefix: '', version: versionString};
909
1803
  }
910
1804
 
911
1805
  /**
912
1806
  * Applies the original version prefix to a new version.
913
1807
  */
914
1808
  export function applyVersionPrefix(originalVersion: string, newVersion: string): string {
915
- const { prefix } = extractVersionPrefix(originalVersion);
1809
+ const {prefix} = extractVersionPrefix(originalVersion);
916
1810
  return prefix + newVersion;
917
1811
  }
918
1812
 
919
1813
  /**
920
- * Visitor that updates the version of a specific dependency in a specific scope.
1814
+ * Finds the dependency scope where a package is declared as a direct dependency.
1815
+ * Returns the scope name (dependencies, devDependencies, etc.) or undefined if not found.
921
1816
  */
922
- class UpdateVersionVisitor extends JsonVisitor<void> {
923
- private readonly packageName: string;
924
- private readonly newVersion: string;
925
- private readonly targetScope: DependencyScope;
926
- private inTargetScope = false;
927
-
928
- constructor(packageName: string, newVersion: string, targetScope: DependencyScope) {
929
- super();
930
- this.packageName = packageName;
931
- this.newVersion = newVersion;
932
- this.targetScope = targetScope;
933
- }
934
-
935
- protected async visitMember(member: Json.Member, p: void): Promise<Json | undefined> {
936
- // Check if we're entering the target scope
937
- const keyName = getMemberKeyName(member);
938
-
939
- if (keyName === this.targetScope) {
940
- // We're entering the dependencies scope
941
- this.inTargetScope = true;
942
- const result = await super.visitMember(member, p);
943
- this.inTargetScope = false;
944
- return result;
945
- }
946
-
947
- // Check if this is the dependency we're looking for
948
- if (this.inTargetScope && keyName === this.packageName) {
949
- // Update the version value
950
- return this.updateVersion(member);
1817
+ function findDirectDependencyScope(
1818
+ packageJson: Record<string, any>,
1819
+ packageName: string
1820
+ ): DependencyScope | undefined {
1821
+ for (const scope of ALL_DEPENDENCY_SCOPES) {
1822
+ if (packageJson[scope]?.[packageName]) {
1823
+ return scope;
951
1824
  }
952
-
953
- return super.visitMember(member, p);
954
1825
  }
1826
+ return undefined;
1827
+ }
955
1828
 
956
- private updateVersion(member: Json.Member): Json.Member {
957
- const value = member.value;
958
-
959
- if (!isLiteral(value)) {
960
- return member; // Not a literal value, can't update
961
- }
962
-
963
- // Preserve the original version prefix (^, ~, etc.)
964
- const originalVersion = String(value.value);
965
- const newVersionWithPrefix = applyVersionPrefix(originalVersion, this.newVersion);
966
-
967
- // Create new literal with updated version
968
- const newLiteral: Json.Literal = {
969
- ...value,
970
- source: `"${newVersionWithPrefix}"`,
971
- value: newVersionWithPrefix
972
- };
973
-
974
- return {
975
- ...member,
976
- value: newLiteral
977
- };
1829
+ /**
1830
+ * Extracts the overrides section from a package.json based on the package manager.
1831
+ * Returns the overrides object or undefined if not present.
1832
+ */
1833
+ function getOverridesFromPackageJson(
1834
+ packageJson: Record<string, any>,
1835
+ packageManager: PackageManager
1836
+ ): Record<string, string> | undefined {
1837
+ switch (packageManager) {
1838
+ case PackageManager.Npm:
1839
+ case PackageManager.Bun:
1840
+ return packageJson.overrides;
1841
+ case PackageManager.Pnpm:
1842
+ return packageJson.pnpm?.overrides;
1843
+ case PackageManager.YarnClassic:
1844
+ case PackageManager.YarnBerry:
1845
+ return packageJson.resolutions;
1846
+ default:
1847
+ return undefined;
978
1848
  }
979
1849
  }
1850
+