Skip to content

Commit 251b676

Browse files
authored
Guided Remediation: Add dependency relaxation & re-resolution (#765)
The remediation part of guided remediation :) Adds the functionality to attempt to fix vulnerabilities in a manifest by relaxing the requirements of its direct dependencies. I've simplified & refactored `tryRelaxRemediate` (née `RelaxResolve`), so PTAL at that. I still need to migrate the part that uses this to make the list of possible 'patches' to the manifest (hence the `//nolint:unused` everywhere), but I didn't want to keep bringing more and more bits into one PR.
1 parent 44254c8 commit 251b676

File tree

6 files changed

+309
-1
lines changed

6 files changed

+309
-1
lines changed

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.21.5
55
require (
66
deps.dev/api/v3alpha v0.0.0-20240109042716-00b51ef52ece
77
deps.dev/util/resolve v0.0.0-20240109042716-00b51ef52ece
8+
deps.dev/util/semver v0.0.0-20240109040450-1e316b822bc4
89
github.com/BurntSushi/toml v1.3.2
910
github.com/CycloneDX/cyclonedx-go v0.8.0
1011
github.com/gkampitakis/go-snaps v0.4.12
@@ -32,7 +33,6 @@ require (
3233

3334
require (
3435
dario.cat/mergo v1.0.0 // indirect
35-
deps.dev/util/semver v0.0.0-20240109040450-1e316b822bc4 // indirect
3636
github.com/Microsoft/go-winio v0.6.1 // indirect
3737
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect
3838
github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9 // indirect

internal/remediation/relax.go

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package remediation
2+
3+
import (
4+
"context"
5+
"errors"
6+
"slices"
7+
8+
"deps.dev/util/resolve"
9+
"github.com/google/osv-scanner/internal/remediation/relaxer"
10+
"github.com/google/osv-scanner/internal/resolution"
11+
)
12+
13+
//nolint:unused
14+
var errRelaxRemediateImpossible = errors.New("cannot fix vulns by relaxing")
15+
16+
//nolint:unused
17+
func tryRelaxRemediate(
18+
ctx context.Context,
19+
cl resolve.Client,
20+
orig *resolution.ResolutionResult,
21+
vulnIDs []string,
22+
opts RemediationOptions,
23+
) (*resolution.ResolutionResult, error) {
24+
relaxer, err := relaxer.GetRelaxer(orig.Manifest.System())
25+
if err != nil {
26+
return nil, err
27+
}
28+
29+
newRes := orig
30+
toRelax := reqsToRelax(newRes, vulnIDs, opts)
31+
for len(toRelax) > 0 {
32+
// Try relaxing all necessary requirements
33+
manif := newRes.Manifest.Clone()
34+
for _, idx := range toRelax {
35+
rv := manif.Requirements[idx]
36+
// If we'd need to relax a package we want to avoid changing, we cannot fix the vuln
37+
if slices.Contains(opts.AvoidPkgs, rv.Name) {
38+
return nil, errRelaxRemediateImpossible
39+
}
40+
newVer, ok := relaxer.Relax(ctx, cl, rv, opts.AllowMajor)
41+
if !ok {
42+
return nil, errRelaxRemediateImpossible
43+
}
44+
manif.Requirements[idx] = newVer
45+
}
46+
47+
// re-resolve relaxed manifest
48+
newRes, err = resolution.Resolve(ctx, cl, manif)
49+
if err != nil {
50+
return nil, err
51+
}
52+
toRelax = reqsToRelax(newRes, vulnIDs, opts)
53+
}
54+
55+
return newRes, nil
56+
}
57+
58+
//nolint:unused
59+
func reqsToRelax(res *resolution.ResolutionResult, vulnIDs []string, opts RemediationOptions) []int {
60+
toRelax := make(map[resolve.VersionKey]string)
61+
for _, v := range res.Vulns {
62+
// Don't do a full opts.MatchVuln() since we know we don't need to check every condition
63+
if !slices.Contains(vulnIDs, v.Vulnerability.ID) || (!opts.DevDeps && v.DevOnly) {
64+
continue
65+
}
66+
// Only relax dependencies if their chain length is less than MaxDepth
67+
for _, ch := range v.ProblemChains {
68+
if opts.MaxDepth <= 0 || len(ch.Edges) <= opts.MaxDepth {
69+
vk, req := ch.DirectDependency()
70+
toRelax[vk] = req
71+
}
72+
}
73+
}
74+
75+
// Find the index into the Manifest.Requirements of each that needs to be relaxed
76+
reqIdxs := make([]int, 0, len(toRelax))
77+
for vk, req := range toRelax {
78+
idx := slices.IndexFunc(res.Manifest.Requirements, func(rv resolve.RequirementVersion) bool {
79+
return rv.PackageKey == vk.PackageKey && rv.Version == req
80+
})
81+
reqIdxs = append(reqIdxs, idx)
82+
}
83+
84+
return reqIdxs
85+
}

internal/remediation/relaxer/npm.go

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package relaxer
2+
3+
import (
4+
"context"
5+
"slices"
6+
7+
"deps.dev/util/resolve"
8+
"deps.dev/util/semver"
9+
)
10+
11+
type NPMRelaxer struct{}
12+
13+
func (r NPMRelaxer) Relax(ctx context.Context, cl resolve.Client, req resolve.RequirementVersion, allowMajor bool) (resolve.RequirementVersion, bool) {
14+
c, err := semver.NPM.ParseConstraint(req.Version)
15+
if err != nil {
16+
// The specified version is not a valid semver constraint
17+
// Check if it's a version tag (usually 'latest') by seeing if there are matching versions
18+
vks, err := cl.MatchingVersions(ctx, req.VersionKey)
19+
if err != nil || len(vks) == 0 { // no matches, cannot relax
20+
return req, false
21+
}
22+
// Use the first matching version (there should only be one) as a pinned version
23+
c, err = semver.NPM.ParseConstraint(vks[0].Version)
24+
if err != nil {
25+
return req, false
26+
}
27+
}
28+
29+
// Get all the concrete versions of the package
30+
allVKs, err := cl.Versions(ctx, req.PackageKey)
31+
if err != nil {
32+
return req, false
33+
}
34+
var vers []string
35+
for _, vk := range allVKs {
36+
if vk.VersionType == resolve.Concrete {
37+
vers = append(vers, vk.Version)
38+
}
39+
}
40+
slices.SortFunc(vers, semver.NPM.Compare)
41+
42+
// Find the versions on either side of the upper boundary of the requirement
43+
var lastIdx int // highest version matching constraint
44+
var nextIdx int = -1 // next version outside of range, preferring non-prerelease
45+
nextIsPre := true // if the next version is a prerelease version
46+
for lastIdx = len(vers) - 1; lastIdx >= 0; lastIdx-- {
47+
v, err := semver.NPM.Parse(vers[lastIdx])
48+
if err != nil {
49+
continue
50+
}
51+
if c.MatchVersion(v) { // found the upper bound, stop iterating
52+
break
53+
}
54+
55+
// Want to prefer non-prerelease versions, so only select one if we haven't seen any non-prerelease versions
56+
if !v.IsPrerelease() || nextIsPre {
57+
nextIdx = lastIdx
58+
nextIsPre = v.IsPrerelease()
59+
}
60+
}
61+
62+
// Didn't find any higher versions of the package
63+
if nextIdx == -1 {
64+
return req, false
65+
}
66+
67+
// No versions match the existing constraint, something is wrong
68+
if lastIdx == -1 {
69+
return req, false
70+
}
71+
72+
// Our desired relaxation ordering is
73+
// 1.2.3 -> 1.2.* -> 1.*.* -> 2.*.* -> 3.*.* -> ...
74+
// But we want to use npm-like version specifiers e.g.
75+
// 1.2.3 -> ~1.2.4 -> ^1.4.5 -> ^2.6.7 -> ^3.8.9 -> ...
76+
// using the latest versions of the ranges
77+
78+
cmpVer := vers[lastIdx]
79+
_, diff, _ := semver.NPM.Difference(cmpVer, vers[nextIdx])
80+
if diff == semver.DiffMajor {
81+
if !allowMajor {
82+
return req, false
83+
}
84+
// Want to step only one major version at a time
85+
// Instead of looking for a difference larger than major,
86+
// we want to look for a major version bump from the first next version
87+
cmpVer = vers[nextIdx]
88+
diff = semver.DiffMinor
89+
}
90+
91+
// Find the highest version with the same difference
92+
best := vers[nextIdx]
93+
for i := nextIdx + 1; i < len(vers); i++ {
94+
_, d, err := semver.NPM.Difference(cmpVer, vers[i])
95+
if err != nil {
96+
continue
97+
}
98+
// DiffMajor < DiffMinor < DiffPatch < DiffPrerelease
99+
// So if d is less than the original diff, it represents a larger change
100+
if d < diff {
101+
break
102+
}
103+
ver, err := semver.NPM.Parse(vers[i])
104+
if err != nil {
105+
continue
106+
}
107+
if !ver.IsPrerelease() || nextIsPre {
108+
best = vers[i]
109+
}
110+
}
111+
112+
if diff == semver.DiffPatch {
113+
req.Version = "~" + best
114+
} else {
115+
req.Version = "^" + best
116+
}
117+
118+
return req, true
119+
}
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package relaxer
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
"deps.dev/util/resolve"
8+
)
9+
10+
// A RequirementRelaxer provides an ecosystem-specific method for 'relaxing' the
11+
// specified versions of dependencies for vulnerability remediation.
12+
// Relaxing involves incrementally widening and bumping the version specifiers
13+
// of the requirement to allow more recent versions to be selected during
14+
// dependency resolution.
15+
// It has access to the available versions of a package via a resolve client.
16+
//
17+
// e.g. in a semver-like ecosystem, relaxation could follow the sequence:
18+
// 1.2.3 -> 1.2.* -> 1.*.* -> 2.*.* -> 3.*.* -> ...
19+
type RequirementRelaxer interface {
20+
// Relax attempts to relax import requirement.
21+
// Returns the newly relaxed import and true it was successful.
22+
// If unsuccessful, it returns the original import and false.
23+
Relax(ctx context.Context, cl resolve.Client, req resolve.RequirementVersion, allowMajor bool) (resolve.RequirementVersion, bool)
24+
}
25+
26+
func GetRelaxer(ecosystem resolve.System) (RequirementRelaxer, error) {
27+
// TODO: is using ecosystem fine, or should this be per manifest?
28+
switch ecosystem { //nolint:exhaustive
29+
case resolve.NPM:
30+
return NPMRelaxer{}, nil
31+
default:
32+
return nil, errors.New("unsupported ecosystem")
33+
}
34+
}

internal/remediation/remediation.go

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package remediation
2+
3+
import (
4+
"slices"
5+
6+
"github.com/google/osv-scanner/internal/resolution"
7+
)
8+
9+
type RemediationOptions struct {
10+
IgnoreVulns []string // Vulnerability IDs to ignore
11+
ExplicitVulns []string // If set, only consider these vulnerability IDs & ignore all others
12+
13+
DevDeps bool // Whether to consider vulnerabilities in dev dependencies
14+
MinSeverity float64 // Minimum vulnerability CVSS score to consider
15+
MaxDepth int // Maximum depth of dependency to consider vulnerabilities for (e.g. 1 for direct only)
16+
17+
AvoidPkgs []string // Names of direct dependencies to avoid upgrading
18+
AllowMajor bool // Whether to allow changes to major versions of direct dependencies
19+
}
20+
21+
func (opts RemediationOptions) MatchVuln(v resolution.ResolutionVuln) bool {
22+
if slices.Contains(opts.IgnoreVulns, v.Vulnerability.ID) {
23+
return false
24+
}
25+
26+
if len(opts.ExplicitVulns) > 0 && !slices.Contains(opts.ExplicitVulns, v.Vulnerability.ID) {
27+
return false
28+
}
29+
30+
if !opts.DevDeps && v.DevOnly {
31+
return false
32+
}
33+
34+
return opts.matchSeverity(v) && opts.matchDepth(v)
35+
}
36+
37+
func (opts RemediationOptions) matchSeverity(v resolution.ResolutionVuln) bool {
38+
// TODO
39+
return true
40+
}
41+
42+
func (opts RemediationOptions) matchDepth(v resolution.ResolutionVuln) bool {
43+
if opts.MaxDepth <= 0 {
44+
return true
45+
}
46+
47+
if len(v.ProblemChains)+len(v.NonProblemChains) == 0 {
48+
panic("vulnerability with no dependency chains")
49+
}
50+
51+
for _, ch := range v.ProblemChains {
52+
if len(ch.Edges) <= opts.MaxDepth {
53+
return true
54+
}
55+
}
56+
57+
for _, ch := range v.NonProblemChains {
58+
if len(ch.Edges) <= opts.MaxDepth {
59+
return true
60+
}
61+
}
62+
63+
return false
64+
}

internal/resolution/resolve.go

+6
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ func (res *ResolutionResult) computeVulns(ctx context.Context, cl resolve.Client
159159
}
160160
rv.DevOnly = rv.DevOnly && ChainIsDev(chain, res.Manifest)
161161
}
162+
if len(rv.ProblemChains) == 0 {
163+
// There has to be at least one problem chain for the vulnerability to appear.
164+
// If our heuristic couldn't determine any, treat them all as problematic.
165+
rv.ProblemChains = rv.NonProblemChains
166+
rv.NonProblemChains = nil
167+
}
162168
res.Vulns = append(res.Vulns, rv)
163169
}
164170

0 commit comments

Comments
 (0)