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 }