github.com/google/osv-scalibr@v0.4.1/guidedremediation/guidedremediation.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package guidedremediation provides vulnerability fixing through dependency
    16  // updates in manifest and lockfiles.
    17  package guidedremediation
    18  
    19  import (
    20  	"cmp"
    21  	"context"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	golog "log"
    26  	"os"
    27  	"os/exec"
    28  	"path/filepath"
    29  	"slices"
    30  	"strings"
    31  
    32  	"deps.dev/util/resolve"
    33  	tea "github.com/charmbracelet/bubbletea"
    34  	"github.com/google/osv-scalibr/clients/datasource"
    35  	"github.com/google/osv-scalibr/enricher"
    36  	"github.com/google/osv-scalibr/guidedremediation/internal/lockfile"
    37  	npmlock "github.com/google/osv-scalibr/guidedremediation/internal/lockfile/npm"
    38  	pythonlock "github.com/google/osv-scalibr/guidedremediation/internal/lockfile/python"
    39  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    40  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest/maven"
    41  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest/npm"
    42  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest/python"
    43  	"github.com/google/osv-scalibr/guidedremediation/internal/parser"
    44  	"github.com/google/osv-scalibr/guidedremediation/internal/remediation"
    45  	"github.com/google/osv-scalibr/guidedremediation/internal/resolution"
    46  	"github.com/google/osv-scalibr/guidedremediation/internal/strategy/common"
    47  	"github.com/google/osv-scalibr/guidedremediation/internal/strategy/inplace"
    48  	"github.com/google/osv-scalibr/guidedremediation/internal/strategy/override"
    49  	"github.com/google/osv-scalibr/guidedremediation/internal/strategy/relax"
    50  	"github.com/google/osv-scalibr/guidedremediation/internal/suggest"
    51  	"github.com/google/osv-scalibr/guidedremediation/internal/tui/components"
    52  	"github.com/google/osv-scalibr/guidedremediation/internal/tui/model"
    53  	"github.com/google/osv-scalibr/guidedremediation/internal/util"
    54  	"github.com/google/osv-scalibr/guidedremediation/options"
    55  	"github.com/google/osv-scalibr/guidedremediation/result"
    56  	"github.com/google/osv-scalibr/guidedremediation/strategy"
    57  	"github.com/google/osv-scalibr/log"
    58  )
    59  
    60  // FixVulns remediates vulnerabilities in the manifest/lockfile using a remediation strategy,
    61  // which are specified in the RemediationOptions.
    62  // FixVulns will overwrite the manifest/lockfile(s) on disk with the dependencies
    63  // patched to remove vulnerabilities. It also returns a Result describing the changes made.
    64  func FixVulns(opts options.FixVulnsOptions) (result.Result, error) {
    65  	var (
    66  		hasManifest = opts.Manifest != ""
    67  		hasLockfile = opts.Lockfile != ""
    68  		manifestRW  manifest.ReadWriter
    69  		lockfileRW  lockfile.ReadWriter
    70  	)
    71  	if !hasManifest && !hasLockfile {
    72  		return result.Result{}, errors.New("no manifest or lockfile provided")
    73  	}
    74  	if opts.VulnEnricher == nil || !strings.HasPrefix(opts.VulnEnricher.Name(), "vulnmatch/") {
    75  		return result.Result{}, errors.New("vulnmatch/ enricher is required for guided remediation")
    76  	}
    77  
    78  	if hasManifest {
    79  		var err error
    80  		manifestRW, err = readWriterForManifest(opts.Manifest, opts.MavenClient)
    81  		if err != nil {
    82  			return result.Result{}, err
    83  		}
    84  	}
    85  	if hasLockfile {
    86  		var err error
    87  		lockfileRW, err = readWriterForLockfile(opts.Lockfile)
    88  		if err != nil {
    89  			return result.Result{}, err
    90  		}
    91  	}
    92  
    93  	// If a strategy is specified, try to use it (if it's supported).
    94  	if opts.Strategy != "" {
    95  		// Prefer modifying the manifest over the lockfile, if both are provided.
    96  		// (Though, there are no strategies that work on both)
    97  		if hasManifest && slices.Contains(manifestRW.SupportedStrategies(), opts.Strategy) {
    98  			return doManifestStrategy(context.Background(), opts.Strategy, manifestRW, opts)
    99  		}
   100  		if hasLockfile && slices.Contains(lockfileRW.SupportedStrategies(), opts.Strategy) {
   101  			return doLockfileStrategy(context.Background(), opts.Strategy, lockfileRW, opts)
   102  		}
   103  		return result.Result{}, fmt.Errorf("unsupported strategy: %q", opts.Strategy)
   104  	}
   105  
   106  	// No strategy specified, so use the first supported strategy.
   107  	// With manifest strategies taking precedence over lockfile.
   108  	if hasManifest {
   109  		strats := manifestRW.SupportedStrategies()
   110  		if len(strats) > 0 {
   111  			return doManifestStrategy(context.Background(), strats[0], manifestRW, opts)
   112  		}
   113  	} else if hasLockfile {
   114  		strats := lockfileRW.SupportedStrategies()
   115  		if len(strats) > 0 {
   116  			return doLockfileStrategy(context.Background(), strats[0], lockfileRW, opts)
   117  		}
   118  	}
   119  
   120  	// This should be unreachable.
   121  	// Supported manifests/lockfiles should have at least one strategy.
   122  	return result.Result{}, errors.New("no supported strategies found")
   123  }
   124  
   125  // VulnDetailsRenderer provides a Render function for the markdown details of a vulnerability.
   126  type VulnDetailsRenderer components.DetailsRenderer
   127  
   128  // FixVulnsInteractive launches the guided remediation interactive TUI.
   129  // detailsRenderer is used to render the markdown details of vulnerabilities, if nil, a fallback renderer is used.
   130  func FixVulnsInteractive(opts options.FixVulnsOptions, detailsRenderer VulnDetailsRenderer) error {
   131  	if opts.VulnEnricher == nil || !strings.HasPrefix(opts.VulnEnricher.Name(), "vulnmatch/") {
   132  		return errors.New("vulnmatch/ enricher is required for guided remediation")
   133  	}
   134  	// Explicitly specifying vulns by cli flag doesn't really make sense in interactive mode.
   135  	opts.ExplicitVulns = []string{}
   136  	var manifestRW manifest.ReadWriter
   137  	var lockfileRW lockfile.ReadWriter
   138  	if opts.Manifest != "" {
   139  		var err error
   140  		manifestRW, err = readWriterForManifest(opts.Manifest, opts.MavenClient)
   141  		if err != nil {
   142  			return err
   143  		}
   144  		if !slices.Contains(manifestRW.SupportedStrategies(), strategy.StrategyRelax) {
   145  			return errors.New("interactive mode only supports relax strategy for manifests")
   146  		}
   147  	}
   148  	if opts.Lockfile != "" {
   149  		var err error
   150  		lockfileRW, err = readWriterForLockfile(opts.Lockfile)
   151  		if err != nil {
   152  			return err
   153  		}
   154  		if !slices.Contains(lockfileRW.SupportedStrategies(), strategy.StrategyInPlace) {
   155  			return errors.New("interactive mode only supports inplace strategy for lockfiles")
   156  		}
   157  	}
   158  
   159  	var m tea.Model
   160  	var err error
   161  	m, err = model.NewModel(manifestRW, lockfileRW, opts, detailsRenderer)
   162  	if err != nil {
   163  		return err
   164  	}
   165  	p := tea.NewProgram(m, tea.WithAltScreen())
   166  
   167  	// Disable scalibr logging to avoid polluting the terminal.
   168  	golog.SetOutput(io.Discard)
   169  	m, err = p.Run()
   170  	golog.SetOutput(os.Stderr)
   171  	if err != nil {
   172  		return err
   173  	}
   174  
   175  	md, ok := m.(model.Model)
   176  	if !ok {
   177  		log.Warnf("tui exited in unexpected state: %v", m)
   178  		return nil
   179  	}
   180  	return md.Error()
   181  }
   182  
   183  // Update updates the dependencies to the latest version based on the UpdateOptions provided.
   184  // Update overwrites the manifest on disk with the updated dependencies.
   185  func Update(opts options.UpdateOptions) (result.Result, error) {
   186  	var (
   187  		hasManifest = (opts.Manifest != "")
   188  		manifestRW  manifest.ReadWriter
   189  	)
   190  	if !hasManifest {
   191  		return result.Result{}, errors.New("no manifest provided")
   192  	}
   193  
   194  	var err error
   195  	manifestRW, err = readWriterForManifest(opts.Manifest, opts.MavenClient)
   196  	if err != nil {
   197  		return result.Result{}, err
   198  	}
   199  
   200  	mf, err := parser.ParseManifest(opts.Manifest, manifestRW)
   201  	if err != nil {
   202  		return result.Result{}, err
   203  	}
   204  
   205  	suggester, err := suggest.NewSuggester(manifestRW.System())
   206  	if err != nil {
   207  		return result.Result{}, err
   208  	}
   209  	patch, err := suggester.Suggest(context.Background(), mf, opts)
   210  	if err != nil {
   211  		return result.Result{}, err
   212  	}
   213  
   214  	err = parser.WriteManifestPatches(opts.Manifest, mf, []result.Patch{patch}, manifestRW)
   215  
   216  	return result.Result{
   217  		Path:      opts.Manifest,
   218  		Ecosystem: util.DepsDevToOSVEcosystem(manifestRW.System()),
   219  		Patches:   []result.Patch{patch},
   220  	}, err
   221  }
   222  
   223  func doManifestStrategy(ctx context.Context, s strategy.Strategy, rw manifest.ReadWriter, opts options.FixVulnsOptions) (result.Result, error) {
   224  	var computePatches func(context.Context, resolve.Client, enricher.Enricher, *remediation.ResolvedManifest, *options.RemediationOptions) (common.PatchResult, error)
   225  	switch s {
   226  	case strategy.StrategyOverride:
   227  		computePatches = override.ComputePatches
   228  	case strategy.StrategyRelax:
   229  		computePatches = relax.ComputePatches
   230  	case strategy.StrategyInPlace:
   231  		fallthrough
   232  	default:
   233  		return result.Result{}, fmt.Errorf("unsupported strategy: %q", s)
   234  	}
   235  	m, err := parser.ParseManifest(opts.Manifest, rw)
   236  	if err != nil {
   237  		return result.Result{}, err
   238  	}
   239  
   240  	res := result.Result{
   241  		Path:      opts.Manifest,
   242  		Strategy:  s,
   243  		Ecosystem: util.DepsDevToOSVEcosystem(rw.System()),
   244  	}
   245  
   246  	if opts.DepCachePopulator != nil {
   247  		opts.DepCachePopulator.PopulateCache(ctx, opts.ResolveClient, m.Requirements(), opts.Manifest)
   248  	}
   249  
   250  	resolved, err := remediation.ResolveManifest(ctx, opts.ResolveClient, opts.VulnEnricher, m, &opts.RemediationOptions)
   251  	if err != nil {
   252  		return result.Result{}, fmt.Errorf("failed resolving manifest: %w", err)
   253  	}
   254  
   255  	res.Errors = computeResolveErrors(resolved.Graph)
   256  
   257  	writeLockfile := false
   258  	if opts.Lockfile != "" {
   259  		if isLockfileForManifest(opts.Manifest, opts.Lockfile) {
   260  			writeLockfile = true
   261  			err := computeRelockPatches(ctx, &res, resolved, opts)
   262  			if err != nil {
   263  				log.Errorf("failed computing vulnerabilies fixed by relock: %v", err)
   264  				// just ignore the lockfile and continue.
   265  			}
   266  		} else {
   267  			log.Warnf("ignoring lockfile %q because it is not for manifest %q", opts.Lockfile, opts.Manifest)
   268  		}
   269  	}
   270  
   271  	allPatchResults, err := computePatches(ctx, opts.ResolveClient, opts.VulnEnricher, resolved, &opts.RemediationOptions)
   272  	if err != nil {
   273  		return result.Result{}, fmt.Errorf("failed computing patches: %w", err)
   274  	}
   275  	allPatches := allPatchResults.Patches
   276  
   277  	res.Vulnerabilities = append(res.Vulnerabilities, computeVulnsResult(resolved, allPatches)...)
   278  	res.Patches = append(res.Patches, choosePatches(allPatches, opts.MaxUpgrades, opts.NoIntroduce, false)...)
   279  	if m.System() == resolve.Maven && opts.NoMavenNewDepMgmt {
   280  		res.Patches = filterMavenPatches(res.Patches, m.EcosystemSpecific())
   281  	}
   282  	if err := parser.WriteManifestPatches(opts.Manifest, m, res.Patches, rw); err != nil {
   283  		return res, err
   284  	}
   285  
   286  	if writeLockfile {
   287  		err := writeLockfileFromManifest(ctx, opts.Manifest)
   288  		if err != nil {
   289  			log.Errorf("failed writing lockfile from manifest: %v", err)
   290  		}
   291  	}
   292  
   293  	return res, nil
   294  }
   295  
   296  func doLockfileStrategy(ctx context.Context, s strategy.Strategy, rw lockfile.ReadWriter, opts options.FixVulnsOptions) (result.Result, error) {
   297  	if s != strategy.StrategyInPlace {
   298  		return result.Result{}, fmt.Errorf("unsupported strategy: %q", s)
   299  	}
   300  	g, err := parser.ParseLockfile(opts.Lockfile, rw)
   301  	if err != nil {
   302  		return result.Result{}, err
   303  	}
   304  
   305  	res := result.Result{
   306  		Path:      opts.Lockfile,
   307  		Strategy:  s,
   308  		Ecosystem: util.DepsDevToOSVEcosystem(rw.System()),
   309  	}
   310  
   311  	resolved, err := remediation.ResolveGraphVulns(ctx, opts.ResolveClient, opts.VulnEnricher, g, nil, &opts.RemediationOptions)
   312  	if err != nil {
   313  		return result.Result{}, fmt.Errorf("failed resolving lockfile vulnerabilities: %w", err)
   314  	}
   315  	res.Errors = computeResolveErrors(resolved.Graph)
   316  	allPatches, err := inplace.ComputePatches(ctx, opts.ResolveClient, resolved, &opts.RemediationOptions)
   317  	if err != nil {
   318  		return result.Result{}, fmt.Errorf("failed computing patches: %w", err)
   319  	}
   320  	res.Vulnerabilities = computeVulnsResultsLockfile(resolved, allPatches, opts.RemediationOptions)
   321  	res.Patches = choosePatches(allPatches, opts.MaxUpgrades, opts.NoIntroduce, true)
   322  	err = parser.WriteLockfilePatches(opts.Lockfile, res.Patches, rw)
   323  	return res, err
   324  }
   325  
   326  // computeVulnsResult computes the vulnerabilities that were found in the resolved manifest,
   327  // where vulnerabilities are unique by ID only, and are actionable only if it can be fixed in all affected packages.
   328  func computeVulnsResult(resolved *remediation.ResolvedManifest, allPatches []result.Patch) []result.Vuln {
   329  	fixableVulns := make(map[string]struct{})
   330  	for _, p := range allPatches {
   331  		for _, v := range p.Fixed {
   332  			fixableVulns[v.ID] = struct{}{}
   333  		}
   334  	}
   335  	vulns := make([]result.Vuln, 0, len(resolved.Vulns))
   336  	for _, v := range resolved.Vulns {
   337  		_, fixable := fixableVulns[v.OSV.Id]
   338  		vuln := result.Vuln{
   339  			ID:           v.OSV.Id,
   340  			Unactionable: !fixable,
   341  			Packages:     make([]result.Package, 0, len(v.Subgraphs)),
   342  		}
   343  		for _, sg := range v.Subgraphs {
   344  			vk := sg.Nodes[sg.Dependency].Version
   345  			vuln.Packages = append(vuln.Packages, result.Package{Name: vk.Name, Version: vk.Version})
   346  		}
   347  		// Sort and remove any possible duplicate packages.
   348  		cmpFn := func(a, b result.Package) int {
   349  			if c := strings.Compare(a.Name, b.Name); c != 0 {
   350  				return c
   351  			}
   352  			return strings.Compare(a.Version, b.Version)
   353  		}
   354  		slices.SortFunc(vuln.Packages, cmpFn)
   355  		vuln.Packages = slices.CompactFunc(vuln.Packages, func(a, b result.Package) bool { return cmpFn(a, b) == 0 })
   356  		vulns = append(vulns, vuln)
   357  	}
   358  	slices.SortFunc(vulns, func(a, b result.Vuln) int { return strings.Compare(a.ID, b.ID) })
   359  	return vulns
   360  }
   361  
   362  // computeVulnsResultsLockfile computes the vulnerabilities that were found in the resolved lockfile,
   363  // where vulnerabilities are unique by ID AND affected package + version.
   364  // e.g. CVE-123-456 affecting foo@1.0.0 is different from CVE-123-456 affecting foo@2.0.0.
   365  // Vulnerabilities are actionable if it can be fixed in all instances of the affected package version.
   366  // (in the case of npm, where a version of a package can be installed in multiple places in the project)
   367  func computeVulnsResultsLockfile(resolved remediation.ResolvedGraph, allPatches []result.Patch, opts options.RemediationOptions) []result.Vuln {
   368  	type vuln struct {
   369  		id         string
   370  		pkgName    string
   371  		pkgVersion string
   372  	}
   373  	fixableVulns := make(map[vuln]struct{})
   374  	for _, p := range allPatches {
   375  		for _, v := range p.Fixed {
   376  			for _, pkg := range v.Packages {
   377  				fixableVulns[vuln{v.ID, pkg.Name, pkg.Version}] = struct{}{}
   378  			}
   379  		}
   380  	}
   381  
   382  	var vulns []result.Vuln
   383  	for _, v := range resolved.Vulns {
   384  		vks := make(map[resolve.VersionKey]struct{})
   385  		for _, sg := range v.Subgraphs {
   386  			// Check if the split vulnerability should've been filtered out.
   387  			vuln := resolution.Vulnerability{
   388  				OSV:       v.OSV,
   389  				Subgraphs: []*resolution.DependencySubgraph{sg},
   390  				DevOnly:   sg.IsDevOnly(nil),
   391  			}
   392  			if remediation.MatchVuln(opts, vuln) {
   393  				vks[sg.Nodes[sg.Dependency].Version] = struct{}{}
   394  			}
   395  		}
   396  		for vk := range vks {
   397  			_, fixable := fixableVulns[vuln{v.OSV.Id, vk.Name, vk.Version}]
   398  			vulns = append(vulns, result.Vuln{
   399  				ID:           v.OSV.Id,
   400  				Unactionable: !fixable,
   401  				Packages: []result.Package{{
   402  					Name:    vk.Name,
   403  					Version: vk.Version,
   404  				}},
   405  			})
   406  		}
   407  	}
   408  	slices.SortFunc(vulns, func(a, b result.Vuln) int {
   409  		return cmp.Or(
   410  			strings.Compare(a.ID, b.ID),
   411  			strings.Compare(a.Packages[0].Name, b.Packages[0].Name),
   412  			strings.Compare(a.Packages[0].Version, b.Packages[0].Version),
   413  		)
   414  	})
   415  	return vulns
   416  }
   417  
   418  // filterMavenPatches filters out Maven patches that are not allowed.
   419  func filterMavenPatches(allPatches []result.Patch, ecosystemSpecific any) []result.Patch {
   420  	specific, ok := ecosystemSpecific.(maven.ManifestSpecific)
   421  	if !ok {
   422  		return allPatches
   423  	}
   424  	for i := range allPatches {
   425  		allPatches[i].PackageUpdates = slices.DeleteFunc(allPatches[i].PackageUpdates, func(update result.PackageUpdate) bool {
   426  			origDep := maven.OriginalDependency(update, specific.LocalRequirements)
   427  			// An empty name indicates the original dependency is not in the base project.
   428  			// If so, delete the patch if the new dependency management is not allowed.
   429  			return origDep.Name() == ":"
   430  		})
   431  	}
   432  	// Delete the patch if there are no package updates.
   433  	return slices.DeleteFunc(allPatches, func(patch result.Patch) bool {
   434  		return len(patch.PackageUpdates) == 0
   435  	})
   436  }
   437  
   438  // choosePatches chooses up to maxUpgrades compatible patches to apply.
   439  // If maxUpgrades <= 0, chooses as many as possible.
   440  // If lockfileVulns is true, vulns are considered unique by ID AND affected package + version,
   441  // so a patch may be chosen that fixes one occurrence of a vulnerability, but not all.
   442  // If lockfileVulns is false, vulns are considered unique by ID only,
   443  // so patches must fix all occurrences of a vulnerability to be chosen.
   444  func choosePatches(allPatches []result.Patch, maxUpgrades int, noIntroduce bool, lockfileVulns bool) []result.Patch {
   445  	var patches []result.Patch
   446  	pkgChanges := make(map[result.Package]struct{}) // dependencies we've already applied a patch to
   447  	type vulnIdentifier struct {
   448  		id         string
   449  		pkgName    string
   450  		pkgVersion string
   451  	}
   452  	fixedVulns := make(map[vulnIdentifier]struct{}) // vulns that have already been fixed by a patch
   453  	for _, patch := range allPatches {
   454  		// If this patch is incompatible with existing patches, skip adding it to the patch list.
   455  
   456  		// A patch is incompatible if any of its changed packages have already been changed by an existing patch.
   457  		if slices.ContainsFunc(patch.PackageUpdates, func(p result.PackageUpdate) bool {
   458  			_, ok := pkgChanges[result.Package{Name: p.Name, Version: p.VersionFrom}]
   459  			return ok
   460  		}) {
   461  			continue
   462  		}
   463  		// A patch is also incompatible if any fixed vulnerability has already been fixed by another patch.
   464  		// This would happen if updating the version of one package has a side effect of also updating or removing one of its vulnerable dependencies.
   465  		// e.g. We have {foo@1 -> bar@1}, and two possible patches [foo@3, bar@2].
   466  		// Patching foo@3 makes {foo@3 -> bar@3}, which also fixes the vulnerability in bar.
   467  		// Applying both patches would force {foo@3 -> bar@2}, which is less desirable.
   468  		if slices.ContainsFunc(patch.Fixed, func(v result.Vuln) bool {
   469  			identifier := vulnIdentifier{id: v.ID}
   470  			if lockfileVulns {
   471  				identifier.pkgName = patch.PackageUpdates[0].Name
   472  				identifier.pkgVersion = patch.PackageUpdates[0].VersionFrom
   473  			}
   474  			_, ok := fixedVulns[identifier]
   475  			return ok
   476  		}) {
   477  			continue
   478  		}
   479  
   480  		if noIntroduce && len(patch.Introduced) > 0 {
   481  			continue
   482  		}
   483  
   484  		patches = append(patches, patch)
   485  		for _, pkg := range patch.PackageUpdates {
   486  			pkgChanges[result.Package{Name: pkg.Name, Version: pkg.VersionFrom}] = struct{}{}
   487  		}
   488  		for _, v := range patch.Fixed {
   489  			identifier := vulnIdentifier{id: v.ID}
   490  			if lockfileVulns {
   491  				identifier.pkgName = patch.PackageUpdates[0].Name
   492  				identifier.pkgVersion = patch.PackageUpdates[0].VersionFrom
   493  			}
   494  			fixedVulns[identifier] = struct{}{}
   495  		}
   496  		maxUpgrades--
   497  		if maxUpgrades == 0 {
   498  			break
   499  		}
   500  	}
   501  	return patches
   502  }
   503  
   504  func computeResolveErrors(g *resolve.Graph) []result.ResolveError {
   505  	var errs []result.ResolveError
   506  	for _, n := range g.Nodes {
   507  		for _, e := range n.Errors {
   508  			errs = append(errs, result.ResolveError{
   509  				Package: result.Package{
   510  					Name:    n.Version.Name,
   511  					Version: n.Version.Version,
   512  				},
   513  				Requirement: result.Package{
   514  					Name:    e.Req.Name,
   515  					Version: e.Req.Version,
   516  				},
   517  				Error: e.Error,
   518  			})
   519  		}
   520  	}
   521  
   522  	return errs
   523  }
   524  
   525  // computeRelockPatches computes the vulnerabilities that were fixed by just relocking the manifest.
   526  // Vulns present in the lockfile only are added to the result's vulns,
   527  // and a patch upgraded packages is added to the result's patches.
   528  func computeRelockPatches(ctx context.Context, res *result.Result, resolvedManif *remediation.ResolvedManifest, opts options.FixVulnsOptions) error {
   529  	lockfileRW, err := readWriterForLockfile(opts.Lockfile)
   530  	if err != nil {
   531  		return err
   532  	}
   533  
   534  	g, err := parser.ParseLockfile(opts.Lockfile, lockfileRW)
   535  	if err != nil {
   536  		return err
   537  	}
   538  	resolvedLockf, err := remediation.ResolveGraphVulns(ctx, opts.ResolveClient, opts.VulnEnricher, g, nil, &opts.RemediationOptions)
   539  	if err != nil {
   540  		return err
   541  	}
   542  
   543  	manifestVulns := make(map[string]struct{})
   544  	for _, v := range resolvedManif.Vulns {
   545  		manifestVulns[v.OSV.Id] = struct{}{}
   546  	}
   547  
   548  	var vulns []result.Vuln
   549  	for _, v := range resolvedLockf.Vulns {
   550  		if _, ok := manifestVulns[v.OSV.Id]; !ok {
   551  			vuln := result.Vuln{ID: v.OSV.Id, Unactionable: false}
   552  			for _, sg := range v.Subgraphs {
   553  				n := resolvedLockf.Graph.Nodes[sg.Dependency]
   554  				vuln.Packages = append(vuln.Packages, result.Package{Name: n.Version.Name, Version: n.Version.Version})
   555  			}
   556  			vulns = append(vulns, vuln)
   557  		}
   558  	}
   559  
   560  	slices.SortFunc(vulns, func(a, b result.Vuln) int { return strings.Compare(a.ID, b.ID) })
   561  	res.Vulnerabilities = append(res.Vulnerabilities, vulns...)
   562  	res.Patches = append(res.Patches, result.Patch{Fixed: vulns})
   563  
   564  	return nil
   565  }
   566  
   567  func writeLockfileFromManifest(ctx context.Context, manifestPath string) error {
   568  	base := filepath.Base(manifestPath)
   569  	switch base {
   570  	case "package.json":
   571  		return writeNpmLockfile(ctx, manifestPath)
   572  	case "requirements.in":
   573  		return writePythonLockfile(ctx, manifestPath, "pip-compile", "requirements.txt", "--generate-hashes", "requirements.in")
   574  	case "pyproject.toml":
   575  		return writePythonLockfile(ctx, manifestPath, "poetry", "poetry.lock", "lock")
   576  	case "Pipfile":
   577  		return writePythonLockfile(ctx, manifestPath, "pipenv", "Pipfile.lock", "lock")
   578  	default:
   579  		return fmt.Errorf("unsupported manifest type: %s", base)
   580  	}
   581  }
   582  
   583  func writeNpmLockfile(ctx context.Context, path string) error {
   584  	// shell out to npm to write the package-lock.json file.
   585  	dir := filepath.Dir(path)
   586  	npmPath, err := exec.LookPath("npm")
   587  	if err != nil {
   588  		return fmt.Errorf("cannot find npm executable: %w", err)
   589  	}
   590  
   591  	// Must remove preexisting package-lock.json and node_modules directory for a clean install.
   592  	// Use RemoveAll to avoid errors if the files doesn't exist.
   593  	if err := os.RemoveAll(filepath.Join(dir, "package-lock.json")); err != nil {
   594  		return fmt.Errorf("failed removing old package-lock.json/: %w", err)
   595  	}
   596  	if err := os.RemoveAll(filepath.Join(dir, "node_modules")); err != nil {
   597  		return fmt.Errorf("failed removing old node_modules/: %w", err)
   598  	}
   599  
   600  	cmd := exec.CommandContext(ctx, npmPath, "install", "--package-lock-only", "--ignore-scripts")
   601  	cmd.Dir = dir
   602  	cmd.Stdout = io.Discard
   603  	cmd.Stderr = io.Discard
   604  	if err := cmd.Run(); err == nil {
   605  		// succeeded on first try
   606  		return nil
   607  	}
   608  
   609  	// Guided remediation does not currently support peer dependencies.
   610  	// Try with `--legacy-peer-deps` in case the previous install errored from peer dependencies.
   611  	log.Warnf("npm install failed. Trying again with `--legacy-peer-deps`")
   612  	cmd = exec.CommandContext(ctx, npmPath, "install", "--package-lock-only", "--legacy-peer-deps", "--ignore-scripts")
   613  	cmd.Dir = dir
   614  	cmdOut := &strings.Builder{}
   615  	cmd.Stdout = cmdOut
   616  	cmd.Stderr = cmdOut
   617  	if err := cmd.Run(); err != nil {
   618  		log.Infof("npm install output:\n%s", cmdOut.String())
   619  		return fmt.Errorf("npm install failed: %w", err)
   620  	}
   621  
   622  	return nil
   623  }
   624  
   625  // writePythonLockfile executes a command-line tool to generate or update a lockfile.
   626  func writePythonLockfile(ctx context.Context, path, executable, lockfileName string, args ...string) error {
   627  	dir := filepath.Dir(path)
   628  	execPath, err := exec.LookPath(executable)
   629  	if err != nil {
   630  		return fmt.Errorf("cannot find %s executable: %w", executable, err)
   631  	}
   632  
   633  	log.Infof("Running %s to regenerate %s", executable, lockfileName)
   634  	cmd := exec.CommandContext(ctx, execPath, args...)
   635  	cmd.Dir = dir
   636  	cmd.Stdout = io.Discard
   637  	cmd.Stderr = io.Discard
   638  	return cmd.Run()
   639  }
   640  
   641  // readWriterForManifest returns the manifest read/write interface for the given manifest path.
   642  // mavenClient is used to read/write Maven manifests, and may be nil for other ecosystems.
   643  func readWriterForManifest(manifestPath string, mavenClient *datasource.MavenRegistryAPIClient) (manifest.ReadWriter, error) {
   644  	baseName := filepath.Base(manifestPath)
   645  	switch strings.ToLower(baseName) {
   646  	case "pom.xml":
   647  		if mavenClient == nil {
   648  			return nil, errors.New("a maven client must be provided for pom.xml")
   649  		}
   650  		return maven.GetReadWriter(mavenClient)
   651  	case "package.json":
   652  		return npm.GetReadWriter()
   653  	case "requirements.in", "requirements.txt":
   654  		return python.GetRequirementsReadWriter()
   655  	case "pyproject.toml":
   656  		return python.GetPoetryReadWriter()
   657  	case "pipfile":
   658  		return python.GetPipfileReadWriter()
   659  	}
   660  	return nil, fmt.Errorf("unsupported manifest: %q", baseName)
   661  }
   662  
   663  // readWriterForLockfile returns the lockfile read/write interface for the given lockfile path.
   664  func readWriterForLockfile(lockfilePath string) (lockfile.ReadWriter, error) {
   665  	baseName := filepath.Base(lockfilePath)
   666  	switch strings.ToLower(baseName) {
   667  	case "package-lock.json":
   668  		return npmlock.GetReadWriter()
   669  	case "requirements.txt":
   670  		return pythonlock.GetReadWriter()
   671  	}
   672  	return nil, fmt.Errorf("unsupported lockfile: %q", baseName)
   673  }
   674  
   675  // isLockfileForManifest returns true if the lockfile is for the manifest.
   676  // This is a heuristic that works for npm, but not for other ecosystems.
   677  func isLockfileForManifest(manifestPath, lockfilePath string) bool {
   678  	manifestDir := filepath.Dir(manifestPath)
   679  	manifestBaseName := filepath.Base(manifestPath)
   680  	lockfileDir := filepath.Dir(lockfilePath)
   681  	lockfileBaseName := filepath.Base(lockfilePath)
   682  
   683  	if manifestDir != lockfileDir {
   684  		return false
   685  	}
   686  	if manifestBaseName == "requirements.in" {
   687  		return lockfileBaseName == "requirements.txt"
   688  	}
   689  	if manifestBaseName == "pyproject.toml" {
   690  		return lockfileBaseName == "poetry.lock"
   691  	}
   692  	if manifestBaseName == "Pipfile" {
   693  		return lockfileBaseName == "Pipfile.lock"
   694  	}
   695  	return manifestBaseName == "package.json" && lockfileBaseName == "package-lock.json"
   696  }