@openrewrite/recipes-nodejs 0.36.0-20251211-172625 → 0.36.0-20251212-170419

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.
@@ -0,0 +1,934 @@
1
+ /*
2
+ * Copyright 2025 the original author or authors.
3
+ *
4
+ * Moderne Proprietary. Only for use by Moderne customers under the terms of a commercial contract.
5
+ */
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";
11
+ import {isDocuments, isYaml, Yaml, YamlParser} from "@openrewrite/rewrite/yaml";
12
+ import {
13
+ DependencyScope,
14
+ findNodeResolutionResult,
15
+ ResolvedDependency,
16
+ PackageManager,
17
+ createDependencyRecipeAccumulator,
18
+ DependencyRecipeAccumulator,
19
+ getUpdatedLockFileContent,
20
+ runInstallIfNeeded,
21
+ runInstallInTempDir,
22
+ storeInstallResult,
23
+ updateNodeResolutionMarker
24
+ } from "@openrewrite/rewrite/javascript";
25
+ import * as semver from "semver";
26
+ import * as path from "path";
27
+ import {parseSeverity, Severity, severityOrdinal, Vulnerability, VulnerabilityDatabase} from "./vulnerability";
28
+
29
+ /**
30
+ * Maximum upgrade delta for version upgrades.
31
+ * Use 'none' to only report vulnerabilities without making any changes.
32
+ */
33
+ export type UpgradeDelta = 'none' | 'patch' | 'minor' | 'major';
34
+
35
+ /**
36
+ * Row for the vulnerability report data table.
37
+ */
38
+ class VulnerabilityReportRow {
39
+ @Column({
40
+ displayName: "Source Path",
41
+ description: "Path to the package.json file where the vulnerability was found"
42
+ })
43
+ sourcePath!: string;
44
+
45
+ @Column({
46
+ displayName: "CVE",
47
+ description: "The CVE identifier of the vulnerability"
48
+ })
49
+ cve!: string;
50
+
51
+ @Column({
52
+ displayName: "Package",
53
+ description: "The name of the vulnerable package"
54
+ })
55
+ packageName!: string;
56
+
57
+ @Column({
58
+ displayName: "Version",
59
+ description: "The resolved version of the package"
60
+ })
61
+ version!: string;
62
+
63
+ @Column({
64
+ displayName: "Fixed Version",
65
+ description: "The version that fixes the vulnerability"
66
+ })
67
+ fixedVersion!: string;
68
+
69
+ @Column({
70
+ displayName: "Last Affected",
71
+ description: "The last version affected by the vulnerability"
72
+ })
73
+ lastAffectedVersion!: string;
74
+
75
+ @Column({
76
+ displayName: "Upgradeable",
77
+ description: "Whether the vulnerability can be fixed with a version upgrade within the maximum delta"
78
+ })
79
+ upgradeable!: boolean;
80
+
81
+ @Column({
82
+ displayName: "Summary",
83
+ description: "Brief description of the vulnerability"
84
+ })
85
+ summary!: string;
86
+
87
+ @Column({
88
+ displayName: "Severity",
89
+ description: "Severity level of the vulnerability"
90
+ })
91
+ severity!: string;
92
+
93
+ @Column({
94
+ displayName: "Depth",
95
+ description: "Depth in the dependency tree (0 = direct, 1+ = transitive)"
96
+ })
97
+ depth!: number;
98
+
99
+ @Column({
100
+ displayName: "CWEs",
101
+ description: "CWE identifiers associated with this vulnerability"
102
+ })
103
+ cwes!: string;
104
+
105
+ @Column({
106
+ displayName: "Is Direct",
107
+ description: "Whether this is a direct dependency"
108
+ })
109
+ isDirect!: boolean;
110
+
111
+ @Column({
112
+ displayName: "Dependency Path",
113
+ description: "Path showing how the vulnerable dependency is brought in (e.g., 'dependencies > lodash@4.0.0 > vulnerable-pkg@1.0.0')"
114
+ })
115
+ dependencyPath!: string;
116
+
117
+ constructor(
118
+ sourcePath: string,
119
+ cve: string,
120
+ packageName: string,
121
+ version: string,
122
+ fixedVersion: string,
123
+ lastAffectedVersion: string,
124
+ upgradeable: boolean,
125
+ summary: string,
126
+ severity: string,
127
+ depth: number,
128
+ cwes: string,
129
+ isDirect: boolean,
130
+ dependencyPath: string
131
+ ) {
132
+ this.sourcePath = sourcePath;
133
+ this.cve = cve;
134
+ this.packageName = packageName;
135
+ this.version = version;
136
+ this.fixedVersion = fixedVersion;
137
+ this.lastAffectedVersion = lastAffectedVersion;
138
+ this.upgradeable = upgradeable;
139
+ this.summary = summary;
140
+ this.severity = severity;
141
+ this.depth = depth;
142
+ this.cwes = cwes;
143
+ this.isDirect = isDirect;
144
+ this.dependencyPath = dependencyPath;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Represents a segment in the dependency path.
150
+ */
151
+ interface PathSegment {
152
+ name: string;
153
+ version: string;
154
+ }
155
+
156
+ /**
157
+ * Represents a vulnerable dependency found during scanning.
158
+ */
159
+ interface VulnerableDependency {
160
+ /** The resolved dependency that is vulnerable */
161
+ resolved: ResolvedDependency;
162
+ /** The vulnerability affecting it */
163
+ vulnerability: Vulnerability;
164
+ /** Depth in dependency tree (0 = direct) */
165
+ depth: number;
166
+ /** Whether this is a direct dependency */
167
+ isDirect: boolean;
168
+ /** The scope where this dependency was found (for direct deps) */
169
+ scope?: DependencyScope;
170
+ /** Path from root to this dependency */
171
+ path: PathSegment[];
172
+ }
173
+
174
+ /**
175
+ * Represents a fix to apply for a vulnerability.
176
+ */
177
+ interface VulnerabilityFix {
178
+ /** Package name to upgrade */
179
+ packageName: string;
180
+ /** Version to upgrade to */
181
+ newVersion: string;
182
+ /** Whether this is a transitive dependency fix */
183
+ isTransitive: boolean;
184
+ /** CVEs this fix resolves */
185
+ cves: string[];
186
+ /** The scope where this dependency was found (for direct deps) */
187
+ scope?: DependencyScope;
188
+ }
189
+
190
+ /**
191
+ * Project info for lock file updates.
192
+ */
193
+ interface ProjectUpdateInfo {
194
+ /** Absolute path to the project directory */
195
+ projectDir: string;
196
+ /** Relative path to package.json (from source root) */
197
+ packageJsonPath: string;
198
+ /** Original package.json content */
199
+ originalPackageJson: string;
200
+ /** The package manager used by this project */
201
+ packageManager: PackageManager;
202
+ }
203
+
204
+ /**
205
+ * Accumulator for the scanning recipe.
206
+ */
207
+ interface Accumulator extends DependencyRecipeAccumulator<ProjectUpdateInfo> {
208
+ /** Vulnerability database */
209
+ db: VulnerabilityDatabase;
210
+ /** Vulnerable dependencies found, grouped by package.json path */
211
+ vulnerableByProject: Map<string, VulnerableDependency[]>;
212
+ /** Fixes to apply, grouped by package.json path */
213
+ fixesByProject: Map<string, VulnerabilityFix[]>;
214
+ }
215
+
216
+ /**
217
+ * Finds and fixes vulnerable npm dependencies.
218
+ *
219
+ * This software composition analysis (SCA) tool detects and upgrades dependencies with publicly
220
+ * disclosed vulnerabilities. This recipe both generates a report of vulnerable dependencies and
221
+ * upgrades to newer versions with fixes.
222
+ *
223
+ * This recipe by default only upgrades to the latest **patch** version. If a minor or major upgrade
224
+ * is required to reach the fixed version, this can be controlled using the `maximumUpgradeDelta` option.
225
+ *
226
+ * Vulnerability information comes from the GitHub Security Advisory Database,
227
+ * which aggregates vulnerability data from several public databases, including
228
+ * the National Vulnerability Database maintained by the United States government.
229
+ */
230
+ export class DependencyVulnerabilityCheck extends ScanningRecipe<Accumulator> {
231
+ readonly name = "org.openrewrite.node.dependency-vulnerability-check";
232
+ readonly displayName = "Find and fix vulnerable npm dependencies";
233
+ readonly description = "This software composition analysis (SCA) tool detects and upgrades dependencies with publicly " +
234
+ "disclosed vulnerabilities. This recipe both generates a report of vulnerable dependencies and " +
235
+ "upgrades to newer versions with fixes. This recipe by default only upgrades to the latest **patch** version. " +
236
+ "If a minor or major upgrade is required to reach the fixed version, this can be controlled using the `maximumUpgradeDelta` option. " +
237
+ "Vulnerability information comes from the GitHub Security Advisory Database.";
238
+
239
+ private readonly vulnerabilityReport = new DataTable<VulnerabilityReportRow>(
240
+ "org.openrewrite.nodejs.table.VulnerabilityReport",
241
+ "Vulnerability Report",
242
+ "Lists all vulnerabilities found in project dependencies.",
243
+ VulnerabilityReportRow
244
+ );
245
+
246
+ @Option({
247
+ displayName: "Scope",
248
+ description: "Match dependencies with the specified scope. Default includes all scopes. " +
249
+ "Use 'dependencies' for production dependencies, 'devDependencies' for development only, etc.",
250
+ required: false,
251
+ example: "dependencies",
252
+ valid: ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
253
+ })
254
+ scope?: DependencyScope;
255
+
256
+ @Option({
257
+ displayName: "Override transitives",
258
+ description: "When enabled, transitive dependencies with vulnerabilities will have their versions overridden " +
259
+ "using npm overrides, yarn resolutions, or pnpm overrides. " +
260
+ "By default only direct dependencies have their version numbers upgraded.",
261
+ required: false,
262
+ example: "true"
263
+ })
264
+ overrideTransitive?: boolean;
265
+
266
+ @Option({
267
+ displayName: "Maximum upgrade delta",
268
+ description: "The maximum difference to allow when upgrading a dependency version. " +
269
+ "Use 'none' to only report vulnerabilities without making any changes. " +
270
+ "Patch version upgrades are the default and safest option. " +
271
+ "Minor version upgrades can introduce new features but typically no breaking changes. " +
272
+ "Major version upgrades may require code changes.",
273
+ required: false,
274
+ example: "patch",
275
+ valid: ["none", "patch", "minor", "major"]
276
+ })
277
+ maximumUpgradeDelta?: UpgradeDelta;
278
+
279
+ @Option({
280
+ displayName: "Minimum severity",
281
+ description: "Only fix vulnerabilities with a severity level equal to or higher than the specified minimum. " +
282
+ "Vulnerabilities are classified as LOW, MODERATE, HIGH, or CRITICAL based on their potential impact. " +
283
+ "Default is LOW, which includes all severity levels.",
284
+ required: false,
285
+ example: "MODERATE",
286
+ valid: ["LOW", "MODERATE", "HIGH", "CRITICAL"]
287
+ })
288
+ minimumSeverity?: string;
289
+
290
+ @Option({
291
+ displayName: "CVE pattern",
292
+ description: "Only fix vulnerabilities matching this regular expression pattern. " +
293
+ "This allows filtering to specific CVEs or CVE ranges. " +
294
+ "For example, 'CVE-2023-.*' will only check for CVEs from 2023. " +
295
+ "If not specified, all CVEs will be checked.",
296
+ required: false,
297
+ example: "CVE-2023-.*"
298
+ })
299
+ cvePattern?: string;
300
+
301
+ initialValue(_ctx: ExecutionContext): Accumulator {
302
+ return {
303
+ // DependencyRecipeAccumulator fields
304
+ ...createDependencyRecipeAccumulator<ProjectUpdateInfo>(),
305
+ // Our custom fields
306
+ db: VulnerabilityDatabase.load(),
307
+ vulnerableByProject: new Map(),
308
+ fixesByProject: new Map()
309
+ };
310
+ }
311
+
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
+ private isReportOnly(): boolean {
321
+ return this.getMaximumUpgradeDelta() === 'none';
322
+ }
323
+
324
+ /**
325
+ * Checks if a vulnerability matches the CVE pattern filter.
326
+ */
327
+ 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
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Checks if a version is affected by a vulnerability.
341
+ */
342
+ private isVersionAffected(version: string, vulnerability: Vulnerability): boolean {
343
+ try {
344
+ const v = semver.parse(version);
345
+ if (!v) return false;
346
+
347
+ // Check introduced version
348
+ if (vulnerability.introducedVersion && vulnerability.introducedVersion !== '0') {
349
+ const introduced = semver.parse(vulnerability.introducedVersion);
350
+ if (introduced && semver.lt(v, introduced)) {
351
+ return false; // Version is before the vulnerability was introduced
352
+ }
353
+ }
354
+
355
+ // Check if fixed
356
+ if (vulnerability.fixedVersion) {
357
+ const fixed = semver.parse(vulnerability.fixedVersion);
358
+ if (fixed && semver.gte(v, fixed)) {
359
+ return false; // Version is at or after the fix
360
+ }
361
+ return true; // Version is in the vulnerable range
362
+ }
363
+
364
+ // Check last affected version
365
+ if (vulnerability.lastAffectedVersion) {
366
+ const lastAffected = semver.parse(vulnerability.lastAffectedVersion);
367
+ if (lastAffected && semver.gt(v, lastAffected)) {
368
+ return false; // Version is after the last affected
369
+ }
370
+ return true;
371
+ }
372
+
373
+ return true; // No fix information, assume vulnerable
374
+ } catch {
375
+ return false; // Invalid version, skip
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Checks if a vulnerability can be fixed within the maximum upgrade delta.
381
+ */
382
+ private isUpgradeableWithinDelta(currentVersion: string, vulnerability: Vulnerability): boolean {
383
+ if (this.isReportOnly()) {
384
+ return false;
385
+ }
386
+
387
+ try {
388
+ const current = semver.parse(currentVersion);
389
+ if (!current) return false;
390
+
391
+ const delta = this.getMaximumUpgradeDelta();
392
+
393
+ // If we have a fixed version, check if it's reachable within delta
394
+ if (vulnerability.fixedVersion) {
395
+ const fixed = semver.parse(vulnerability.fixedVersion);
396
+ if (!fixed) return false;
397
+
398
+ switch (delta) {
399
+ case 'patch':
400
+ return current.major === fixed.major && current.minor === fixed.minor;
401
+ case 'minor':
402
+ return current.major === fixed.major;
403
+ case 'major':
404
+ return true;
405
+ case 'none':
406
+ return false;
407
+ }
408
+ }
409
+
410
+ // If we only have lastAffectedVersion, check if upgrading within delta
411
+ // could get us past it (i.e., to a non-vulnerable version)
412
+ if (vulnerability.lastAffectedVersion) {
413
+ const lastAffected = semver.parse(vulnerability.lastAffectedVersion);
414
+ if (!lastAffected) return false;
415
+
416
+ switch (delta) {
417
+ case 'patch':
418
+ return current.major === lastAffected.major &&
419
+ current.minor === lastAffected.minor;
420
+ case 'minor':
421
+ return current.major === lastAffected.major;
422
+ case 'major':
423
+ return true;
424
+ case 'none':
425
+ return false;
426
+ }
427
+ }
428
+
429
+ return false;
430
+ } catch {
431
+ return false;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Gets the version to upgrade to for a vulnerability.
437
+ */
438
+ private getUpgradeVersion(vulnerability: Vulnerability): string | undefined {
439
+ if (vulnerability.fixedVersion) {
440
+ return vulnerability.fixedVersion;
441
+ }
442
+ // For lastAffectedVersion, we need the next version after it
443
+ // We can't determine this exactly, so return undefined and let the
444
+ // upgrade recipe handle version selection
445
+ return undefined;
446
+ }
447
+
448
+ /**
449
+ * Renders a dependency path as a string.
450
+ * Example: "dependencies > lodash@4.17.20 > vulnerable-pkg@1.0.0"
451
+ */
452
+ private renderPath(scope: DependencyScope | undefined, path: PathSegment[]): string {
453
+ const parts: string[] = [];
454
+ if (scope) {
455
+ parts.push(scope);
456
+ }
457
+ for (const seg of path) {
458
+ parts.push(`${seg.name}@${seg.version}`);
459
+ }
460
+ return parts.join(' > ');
461
+ }
462
+
463
+ /**
464
+ * Finds vulnerabilities in a resolved dependency and its transitives.
465
+ */
466
+ private findVulnerabilities(
467
+ resolved: ResolvedDependency,
468
+ db: VulnerabilityDatabase,
469
+ depth: number,
470
+ isDirect: boolean,
471
+ scope: DependencyScope | undefined,
472
+ path: PathSegment[],
473
+ visited: Set<string>,
474
+ results: VulnerableDependency[]
475
+ ): void {
476
+ const key = `${resolved.name}@${resolved.version}`;
477
+ if (visited.has(key)) return;
478
+ visited.add(key);
479
+
480
+ // Build current path
481
+ const currentPath = [...path, { name: resolved.name, version: resolved.version }];
482
+
483
+ // Check for vulnerabilities in this package
484
+ const vulns = db.getVulnerabilities(resolved.name);
485
+ for (const vuln of vulns) {
486
+ // Filter by severity
487
+ if (severityOrdinal(vuln.severity) < severityOrdinal(this.getMinimumSeverity())) {
488
+ continue;
489
+ }
490
+
491
+ // Filter by CVE pattern
492
+ if (!this.matchesCvePattern(vuln)) {
493
+ continue;
494
+ }
495
+
496
+ // Check if this version is affected
497
+ if (this.isVersionAffected(resolved.version, vuln)) {
498
+ results.push({
499
+ resolved,
500
+ vulnerability: vuln,
501
+ depth,
502
+ isDirect,
503
+ scope,
504
+ path: currentPath
505
+ });
506
+ }
507
+ }
508
+
509
+ // Recurse into transitive dependencies if overrideTransitive is enabled
510
+ if (this.overrideTransitive) {
511
+ const transitives = [
512
+ ...(resolved.dependencies || []),
513
+ ...(resolved.devDependencies || []),
514
+ ...(resolved.peerDependencies || []),
515
+ ...(resolved.optionalDependencies || [])
516
+ ];
517
+
518
+ for (const dep of transitives) {
519
+ if (dep.resolved) {
520
+ this.findVulnerabilities(
521
+ dep.resolved,
522
+ db,
523
+ depth + 1,
524
+ false,
525
+ scope, // Keep original scope for path rendering
526
+ currentPath,
527
+ visited,
528
+ results
529
+ );
530
+ }
531
+ }
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Computes the fixes to apply for the vulnerabilities found.
537
+ * Groups vulnerabilities by package and determines the best fix version.
538
+ */
539
+ private computeFixes(vulnerabilities: VulnerableDependency[]): VulnerabilityFix[] {
540
+ if (this.isReportOnly()) {
541
+ return [];
542
+ }
543
+
544
+ // Group vulnerabilities by package name
545
+ const byPackage = new Map<string, VulnerableDependency[]>();
546
+ for (const vuln of vulnerabilities) {
547
+ const existing = byPackage.get(vuln.resolved.name) || [];
548
+ existing.push(vuln);
549
+ byPackage.set(vuln.resolved.name, existing);
550
+ }
551
+
552
+ const fixes: VulnerabilityFix[] = [];
553
+
554
+ for (const [packageName, vulns] of byPackage) {
555
+ // Find the highest fix version needed across all vulnerabilities for this package
556
+ let highestFixVersion: string | undefined;
557
+ const cves: string[] = [];
558
+ let isTransitive = true;
559
+ let scope: DependencyScope | undefined;
560
+
561
+ for (const vuln of vulns) {
562
+ // Check if upgradeable within delta
563
+ if (!this.isUpgradeableWithinDelta(vuln.resolved.version, vuln.vulnerability)) {
564
+ continue;
565
+ }
566
+
567
+ const fixVersion = this.getUpgradeVersion(vuln.vulnerability);
568
+ if (fixVersion) {
569
+ if (!highestFixVersion || semver.gt(fixVersion, highestFixVersion)) {
570
+ highestFixVersion = fixVersion;
571
+ }
572
+ }
573
+
574
+ cves.push(vuln.vulnerability.cve);
575
+
576
+ if (vuln.isDirect) {
577
+ isTransitive = false;
578
+ scope = vuln.scope;
579
+ }
580
+ }
581
+
582
+ if (highestFixVersion && cves.length > 0) {
583
+ fixes.push({
584
+ packageName,
585
+ newVersion: highestFixVersion,
586
+ isTransitive,
587
+ cves,
588
+ scope
589
+ });
590
+ }
591
+ }
592
+
593
+ return fixes;
594
+ }
595
+
596
+ async scanner(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
597
+ const recipe = this;
598
+
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
602
+ if (!doc.sourcePath.endsWith('package.json')) {
603
+ return doc;
604
+ }
605
+
606
+ const marker = findNodeResolutionResult(doc);
607
+ if (!marker) {
608
+ return doc;
609
+ }
610
+
611
+ const vulnerabilities: VulnerableDependency[] = [];
612
+ const visited = new Set<string>();
613
+
614
+ // Determine which scopes to check
615
+ const scopesToCheck: DependencyScope[] = recipe.scope
616
+ ? [recipe.scope]
617
+ : ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
618
+
619
+ for (const scope of scopesToCheck) {
620
+ const deps = marker[scope] || [];
621
+ for (const dep of deps) {
622
+ if (dep.resolved) {
623
+ recipe.findVulnerabilities(
624
+ dep.resolved,
625
+ acc.db,
626
+ 0,
627
+ true,
628
+ scope,
629
+ [], // Start with empty path
630
+ visited,
631
+ vulnerabilities
632
+ );
633
+ }
634
+ }
635
+ }
636
+
637
+ if (vulnerabilities.length > 0) {
638
+ acc.vulnerableByProject.set(doc.sourcePath, vulnerabilities);
639
+
640
+ // Compute fixes
641
+ const fixes = recipe.computeFixes(vulnerabilities);
642
+ if (fixes.length > 0) {
643
+ acc.fixesByProject.set(doc.sourcePath, fixes);
644
+
645
+ // Store project info for lock file updates
646
+ const projectDir = path.dirname(path.resolve(doc.sourcePath));
647
+ const pm = marker.packageManager ?? PackageManager.Npm;
648
+
649
+ acc.projectsToUpdate.set(doc.sourcePath, {
650
+ projectDir,
651
+ packageJsonPath: doc.sourcePath,
652
+ originalPackageJson: await this.printDocument(doc),
653
+ packageManager: pm
654
+ });
655
+ }
656
+ }
657
+
658
+ return doc;
659
+ }
660
+
661
+ private async printDocument(doc: Json.Document): Promise<string> {
662
+ return TreePrinters.print(doc);
663
+ }
664
+ };
665
+ }
666
+
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
+ }
675
+
676
+ async editorWithData(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
677
+ const recipe = this;
678
+
679
+ // YAML lock file names (pnpm-lock.yaml, and yarn.lock for Berry)
680
+ const YAML_LOCK_FILES = ['pnpm-lock.yaml', 'yarn.lock'];
681
+
682
+ // Use a TreeVisitor that handles JSON, YAML, and PlainText lock files
683
+ return new class extends TreeVisitor<Tree, ExecutionContext> {
684
+ protected async accept(tree: Tree, ctx: ExecutionContext): Promise<Tree | undefined> {
685
+ // Handle JSON documents (package.json and JSON lock files like bun.lock, package-lock.json)
686
+ if (isJson(tree) && tree.kind === Json.Kind.Document) {
687
+ return this.handleJsonDocument(tree as Json.Document, ctx);
688
+ }
689
+
690
+ // Handle YAML documents (pnpm-lock.yaml and Yarn Berry yarn.lock)
691
+ if (isYaml(tree) && isDocuments(tree)) {
692
+ const basename = path.basename(tree.sourcePath);
693
+ if (YAML_LOCK_FILES.includes(basename)) {
694
+ return this.handleYamlLockFile(tree, ctx);
695
+ }
696
+ }
697
+
698
+ // Handle PlainText files only for Yarn Classic yarn.lock
699
+ if (isPlainText(tree)) {
700
+ const basename = path.basename(tree.sourcePath);
701
+ if (basename === 'yarn.lock') {
702
+ return this.handlePlainTextLockFile(tree as PlainText, ctx);
703
+ }
704
+ }
705
+
706
+ return tree;
707
+ }
708
+
709
+ private async handleJsonDocument(doc: Json.Document, ctx: ExecutionContext): Promise<Json | undefined> {
710
+ const sourcePath = doc.sourcePath;
711
+
712
+ // Handle package.json files
713
+ if (sourcePath.endsWith('package.json')) {
714
+ return this.handlePackageJson(doc, ctx);
715
+ }
716
+
717
+ // Handle JSON lock files (bun.lock, package-lock.json)
718
+ const updatedLockContent = getUpdatedLockFileContent(sourcePath, acc);
719
+ if (updatedLockContent) {
720
+ return await new JsonParser({}).parseOne({
721
+ text: updatedLockContent,
722
+ sourcePath: doc.sourcePath
723
+ }) as Json.Document;
724
+ }
725
+
726
+ return doc;
727
+ }
728
+
729
+ private async handleYamlLockFile(docs: Yaml.Documents, _ctx: ExecutionContext): Promise<Yaml.Documents | undefined> {
730
+ const sourcePath = docs.sourcePath;
731
+
732
+ // Handle YAML lock files (pnpm-lock.yaml, Yarn Berry yarn.lock)
733
+ const updatedLockContent = getUpdatedLockFileContent(sourcePath, acc);
734
+ if (updatedLockContent) {
735
+ return await new YamlParser({}).parseOne({
736
+ text: updatedLockContent,
737
+ sourcePath: docs.sourcePath
738
+ }) as Yaml.Documents;
739
+ }
740
+
741
+ return docs;
742
+ }
743
+
744
+ private async handlePlainTextLockFile(text: PlainText, _ctx: ExecutionContext): Promise<PlainText | undefined> {
745
+ const sourcePath = text.sourcePath;
746
+
747
+ // Handle Yarn Classic yarn.lock (plain text format)
748
+ const updatedLockContent = getUpdatedLockFileContent(sourcePath, acc);
749
+ if (updatedLockContent) {
750
+ // Parse as plain text and return with new content
751
+ const parsed = await new PlainTextParser({}).parseOne({
752
+ text: updatedLockContent,
753
+ sourcePath: text.sourcePath
754
+ });
755
+ return parsed as PlainText;
756
+ }
757
+
758
+ return text;
759
+ }
760
+
761
+ private async handlePackageJson(doc: Json.Document, ctx: ExecutionContext): Promise<Json | undefined> {
762
+ const vulnerabilities = acc.vulnerableByProject.get(doc.sourcePath);
763
+ if (!vulnerabilities || vulnerabilities.length === 0) {
764
+ return doc;
765
+ }
766
+
767
+ // Insert rows into the data table for each vulnerability
768
+ for (const vuln of vulnerabilities) {
769
+ const upgradeable = recipe.isUpgradeableWithinDelta(
770
+ vuln.resolved.version,
771
+ vuln.vulnerability
772
+ );
773
+
774
+ recipe.vulnerabilityReport.insertRow(ctx, new VulnerabilityReportRow(
775
+ doc.sourcePath,
776
+ vuln.vulnerability.cve,
777
+ vuln.resolved.name,
778
+ vuln.resolved.version,
779
+ vuln.vulnerability.fixedVersion || '',
780
+ vuln.vulnerability.lastAffectedVersion || '',
781
+ upgradeable,
782
+ vuln.vulnerability.summary,
783
+ vuln.vulnerability.severity,
784
+ vuln.depth,
785
+ vuln.vulnerability.cwes,
786
+ vuln.isDirect,
787
+ recipe.renderPath(vuln.scope, vuln.path)
788
+ ));
789
+ }
790
+
791
+ // Apply fixes if not in report-only mode
792
+ if (recipe.isReportOnly()) {
793
+ return doc;
794
+ }
795
+
796
+ const fixes = acc.fixesByProject.get(doc.sourcePath);
797
+ const updateInfo = acc.projectsToUpdate.get(doc.sourcePath);
798
+ if (!fixes || fixes.length === 0 || !updateInfo) {
799
+ return doc;
800
+ }
801
+
802
+ // Run package manager install if needed
803
+ const failureMessage = await runInstallIfNeeded(doc.sourcePath, acc, () =>
804
+ recipe.runPackageManagerInstall(acc, updateInfo, fixes)
805
+ );
806
+ if (failureMessage) {
807
+ return markupWarn(
808
+ doc,
809
+ `Failed to fix vulnerabilities in ${doc.sourcePath}`,
810
+ failureMessage
811
+ );
812
+ }
813
+
814
+ // Update the dependency versions in the JSON AST (preserves formatting)
815
+ let result: Json.Document = doc;
816
+ for (const fix of fixes) {
817
+ if (!fix.isTransitive && fix.scope) {
818
+ const visitor = new UpdateVersionVisitor(
819
+ fix.packageName,
820
+ fix.newVersion,
821
+ fix.scope
822
+ );
823
+ result = await visitor.visit(result, undefined) as Json.Document;
824
+ }
825
+ }
826
+
827
+ // Update the NodeResolutionResult marker
828
+ return updateNodeResolutionMarker(result, updateInfo, acc);
829
+ }
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Runs the package manager in a temporary directory to update the lock file.
835
+ * Writes a modified package.json with all new versions, then runs install.
836
+ */
837
+ private async runPackageManagerInstall(
838
+ acc: Accumulator,
839
+ updateInfo: ProjectUpdateInfo,
840
+ fixes: VulnerabilityFix[]
841
+ ): Promise<void> {
842
+ // Create modified package.json with all the new version constraints
843
+ const modifiedPackageJson = this.createModifiedPackageJson(
844
+ updateInfo.originalPackageJson,
845
+ fixes
846
+ );
847
+
848
+ const result = await runInstallInTempDir(
849
+ updateInfo.projectDir,
850
+ updateInfo.packageManager,
851
+ modifiedPackageJson
852
+ );
853
+
854
+ storeInstallResult(result, acc, updateInfo, modifiedPackageJson);
855
+ }
856
+
857
+ /**
858
+ * Creates a modified package.json with all the updated dependency versions.
859
+ */
860
+ private createModifiedPackageJson(
861
+ originalContent: string,
862
+ fixes: VulnerabilityFix[]
863
+ ): string {
864
+ const packageJson = JSON.parse(originalContent);
865
+
866
+ for (const fix of fixes) {
867
+ if (!fix.isTransitive && fix.scope) {
868
+ if (packageJson[fix.scope] && packageJson[fix.scope][fix.packageName]) {
869
+ packageJson[fix.scope][fix.packageName] = fix.newVersion;
870
+ }
871
+ }
872
+ }
873
+
874
+ return JSON.stringify(packageJson, null, 2);
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Visitor that updates the version of a specific dependency in a specific scope.
880
+ */
881
+ class UpdateVersionVisitor extends JsonVisitor<void> {
882
+ private readonly packageName: string;
883
+ private readonly newVersion: string;
884
+ private readonly targetScope: DependencyScope;
885
+ private inTargetScope = false;
886
+
887
+ constructor(packageName: string, newVersion: string, targetScope: DependencyScope) {
888
+ super();
889
+ this.packageName = packageName;
890
+ this.newVersion = newVersion;
891
+ this.targetScope = targetScope;
892
+ }
893
+
894
+ protected async visitMember(member: Json.Member, p: void): Promise<Json | undefined> {
895
+ // Check if we're entering the target scope
896
+ const keyName = getMemberKeyName(member);
897
+
898
+ if (keyName === this.targetScope) {
899
+ // We're entering the dependencies scope
900
+ this.inTargetScope = true;
901
+ const result = await super.visitMember(member, p);
902
+ this.inTargetScope = false;
903
+ return result;
904
+ }
905
+
906
+ // Check if this is the dependency we're looking for
907
+ if (this.inTargetScope && keyName === this.packageName) {
908
+ // Update the version value
909
+ return this.updateVersion(member);
910
+ }
911
+
912
+ return super.visitMember(member, p);
913
+ }
914
+
915
+ private updateVersion(member: Json.Member): Json.Member {
916
+ const value = member.value;
917
+
918
+ if (!isLiteral(value)) {
919
+ return member; // Not a literal value, can't update
920
+ }
921
+
922
+ // Create new literal with updated version
923
+ const newLiteral: Json.Literal = {
924
+ ...value,
925
+ source: `"${this.newVersion}"`,
926
+ value: this.newVersion
927
+ };
928
+
929
+ return {
930
+ ...member,
931
+ value: newLiteral
932
+ };
933
+ }
934
+ }