@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.
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -24
- package/dist/index.js.map +1 -1
- package/dist/resources/advisories-npm.csv +17 -2
- package/dist/security/dependency-vulnerability-check.d.ts +29 -8
- package/dist/security/dependency-vulnerability-check.d.ts.map +1 -1
- package/dist/security/dependency-vulnerability-check.js +579 -102
- package/dist/security/dependency-vulnerability-check.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +29 -25
- package/src/security/dependency-vulnerability-check.ts +1049 -178
|
@@ -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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 [
|
|
555
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
662
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
1809
|
+
const {prefix} = extractVersionPrefix(originalVersion);
|
|
916
1810
|
return prefix + newVersion;
|
|
917
1811
|
}
|
|
918
1812
|
|
|
919
1813
|
/**
|
|
920
|
-
*
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
+
|