From 135cf268af5b244639f0a2ccafd9e248e81e877e Mon Sep 17 00:00:00 2001 From: RTann Date: Wed, 24 May 2023 10:32:27 -0700 Subject: [PATCH] ruby: add vuln matching Signed-off-by: RTann --- ruby/matcher.go | 97 +++++++++++ ruby/matcher_integration_test.go | 94 +++++++++++ ruby/matcher_test.go | 159 +++++++++++++++++++ ruby/testdata/indexreport-bullseye-ruby.json | 69 ++++++++ updater/osv/osv.go | 15 +- 5 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 ruby/matcher.go create mode 100644 ruby/matcher_integration_test.go create mode 100644 ruby/matcher_test.go create mode 100644 ruby/testdata/indexreport-bullseye-ruby.json diff --git a/ruby/matcher.go b/ruby/matcher.go new file mode 100644 index 000000000..d38c2c240 --- /dev/null +++ b/ruby/matcher.go @@ -0,0 +1,97 @@ +package ruby + +import ( + "context" + "fmt" + "net/url" + + "github.com/Masterminds/semver" + "github.com/quay/zlog" + + "github.com/quay/claircore" + "github.com/quay/claircore/libvuln/driver" +) + +var _ driver.Matcher = (*Matcher)(nil) + +// Matcher attempts to correlate discovered ruby packages with reported +// vulnerabilities. +type Matcher struct{} + +// Name implements driver.Matcher. +func (*Matcher) Name() string { return "ruby" } + +// Filter implements driver.Matcher. +func (*Matcher) Filter(record *claircore.IndexRecord) bool { + return record.Repository != nil && record.Repository.Name == repository +} + +// Query implements driver.Matcher. +func (*Matcher) Query() []driver.MatchConstraint { + return []driver.MatchConstraint{driver.RepositoryName} +} + +// Vulnerable implements driver.Matcher. +func (*Matcher) Vulnerable(ctx context.Context, record *claircore.IndexRecord, vuln *claircore.Vulnerability) (bool, error) { + if vuln.FixedInVersion == "" { + return true, nil + } + + decodedVersions, err := url.ParseQuery(vuln.FixedInVersion) + if err != nil { + return false, err + } + + // Check for missing upper version + if !decodedVersions.Has("fixed") && !decodedVersions.Has("lastAffected") { + return false, fmt.Errorf("ruby: missing upper version") + } + + upperVersion := decodedVersions.Get("fixed") + if upperVersion == "" { + upperVersion = decodedVersions.Get("lastAffected") + } + + rv, err := semver.NewVersion(record.Package.Version) + if err != nil { + zlog.Warn(ctx). + Str("package", record.Package.Name). + Str("version", record.Package.Version). + Msg("unable to parse ruby package version") + return false, err + } + + uv, err := semver.NewVersion(upperVersion) + if err != nil { + zlog.Warn(ctx). + Str("vulnerability", vuln.Name). + Str("package", vuln.Package.Name). + Str("version", upperVersion). + Msg("unable to parse ruby vulnerability 'fixed version' or 'last affected'") + return false, err + } + + switch { + case decodedVersions.Has("fixed") && rv.Compare(uv) >= 0: + return false, nil + case decodedVersions.Has("lastAffected") && rv.Compare(uv) > 0: + return false, nil + case decodedVersions.Has("introduced"): + introduced := decodedVersions.Get("introduced") + iv, err := semver.NewVersion(introduced) + if err != nil { + zlog.Warn(ctx). + Str("vulnerability", vuln.Name). + Str("package", vuln.Package.Name). + Str("version", introduced). + Msg("unable to parse ruby vulnerability 'introduced version'") + return false, err + } + + if rv.Compare(iv) < 0 { + return false, nil + } + } + + return true, nil +} diff --git a/ruby/matcher_integration_test.go b/ruby/matcher_integration_test.go new file mode 100644 index 000000000..079e2fde4 --- /dev/null +++ b/ruby/matcher_integration_test.go @@ -0,0 +1,94 @@ +package ruby + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/quay/zlog" + + "github.com/quay/claircore" + "github.com/quay/claircore/datastore/postgres" + internalMatcher "github.com/quay/claircore/internal/matcher" + "github.com/quay/claircore/libvuln/driver" + "github.com/quay/claircore/libvuln/updates" + "github.com/quay/claircore/pkg/ctxlock" + "github.com/quay/claircore/test/integration" + pgtest "github.com/quay/claircore/test/postgres" + "github.com/quay/claircore/updater/osv" +) + +func TestMain(m *testing.M) { + var c int + defer func() { os.Exit(c) }() + defer integration.DBSetup()() + c = m.Run() +} + +func TestMatcherIntegration(t *testing.T) { + integration.NeedDB(t) + ctx := zlog.Test(context.Background(), t) + pool := pgtest.TestMatcherDB(ctx, t) + store := postgres.NewMatcherStore(pool) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + })) + defer srv.Close() + + m := &Matcher{} + locks, err := ctxlock.New(ctx, pool) + if err != nil { + t.Fatalf("%v", err) + } + defer locks.Close(ctx) + + cfg := map[string]driver.ConfigUnmarshaler{ + "osv": func(v interface{}) error { + cfg := v.(*osv.Config) + cfg.URL = osv.DefaultURL + return nil + }, + } + + facs := map[string]driver.UpdaterSetFactory{ + "osv": osv.Factory, + } + mgr, err := updates.NewManager(ctx, store, locks, srv.Client(), + updates.WithFactories(facs), updates.WithConfigs(cfg)) + if err != nil { + t.Fatalf("%v", err) + } + + // force update + if err := mgr.Run(ctx); err != nil { + t.Fatalf("%v", err) + } + + path := filepath.Join("testdata", "indexreport-bullseye-ruby.json") + f, err := os.Open(path) + if err != nil { + t.Fatalf("%v", err) + } + defer f.Close() + var ir claircore.IndexReport + err = json.NewDecoder(f).Decode(&ir) + if err != nil { + t.Fatalf("failed to decode IndexReport: %v", err) + } + vr, err := internalMatcher.Match(ctx, &ir, []driver.Matcher{m}, store) + if err != nil { + t.Fatalf("expected error to be nil but got %v", err) + } + + vulns := vr.Vulnerabilities + t.Logf("Number of Vulnerabilities found: %d", len(vulns)) + + if len(vulns) < 1 { + t.Fatalf("failed to match vulns: %v", err) + } +} diff --git a/ruby/matcher_test.go b/ruby/matcher_test.go new file mode 100644 index 000000000..5be2abcb0 --- /dev/null +++ b/ruby/matcher_test.go @@ -0,0 +1,159 @@ +package ruby + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/quay/claircore" +) + +func TestVulnerable(t *testing.T) { + matcher := &Matcher{} + + testcases := []struct { + name string + record *claircore.IndexRecord + vuln *claircore.Vulnerability + want bool + }{ + { + name: "bootstrap affected", + record: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bootstrap", + Version: "3.2.9", + Kind: "binary", + }, + }, + vuln: &claircore.Vulnerability{ + Updater: "osv", + Name: "GHSA-7mvr-5x2g-wfc8", + Description: "Bootstrap Cross-site Scripting vulnerability", + Package: &claircore.Package{ + Name: "bootstrap", + RepositoryHint: "RubyGems", + }, + FixedInVersion: "fixed=4.1.2", + }, + want: true, + }, + { + name: "bootstrap unaffected", + record: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "bootstrap", + Version: "4.1.2", + Kind: "binary", + }, + }, + vuln: &claircore.Vulnerability{ + Updater: "osv", + Name: "GHSA-7mvr-5x2g-wfc8", + Description: "Bootstrap Cross-site Scripting vulnerability", + Package: &claircore.Package{ + Name: "bootstrap", + RepositoryHint: "rubygems", + }, + FixedInVersion: "fixed=4.1.2-alpha", + }, + want: false, + }, + { + name: "openshift-origin-node unfixed", + record: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "openshift-origin-node", + Version: "1.3.2", + Kind: "binary", + }, + }, + vuln: &claircore.Vulnerability{ + Updater: "osv", + Name: "GHSA-2c25-xfpq-8n9r", + Description: "Ruby gem openshift-origin-node before 2014-02-14 does not contain a cronjob timeout which could result in a denial of service in cron.daily and cron.weekly.", + Package: &claircore.Package{ + Name: "openshift-origin-node", + RepositoryHint: "rubygems", + }, + FixedInVersion: "lastAffected=1.3.3", + }, + want: true, + }, + { + name: "openshift-origin-node unfixed again", + record: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "openshift-origin-node", + Version: "1.3.3", + Kind: "binary", + }, + }, + vuln: &claircore.Vulnerability{ + Updater: "osv", + Name: "GHSA-2c25-xfpq-8n9r", + Description: "Ruby gem openshift-origin-node before 2014-02-14 does not contain a cronjob timeout which could result in a denial of service in cron.daily and cron.weekly.", + Package: &claircore.Package{ + Name: "openshift-origin-node", + RepositoryHint: "rubygems", + }, + FixedInVersion: "lastAffected=1.3.3", + }, + want: true, + }, + { + name: "dependabot-omnibus affected", + record: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "dependabot-omnibus", + Version: "0.120.0-beta2", + Kind: "binary", + }, + }, + vuln: &claircore.Vulnerability{ + Updater: "osv", + Name: "GHSA-23f7-99jx-m54r", + Description: "Remote code execution in dependabot-core branch names when cloning", + Package: &claircore.Package{ + Name: "dependabot-omnibus", + RepositoryHint: "rubygems", + }, + FixedInVersion: "fixed=0.125.1&introduced=0.119.0-beta1", + }, + want: true, + }, + { + name: "dependabot-omnibus unaffected", + record: &claircore.IndexRecord{ + Package: &claircore.Package{ + Name: "dependabot-omnibus", + Version: "0.119.0-alpha3", + Kind: "binary", + }, + }, + vuln: &claircore.Vulnerability{ + Updater: "osv", + Name: "GHSA-23f7-99jx-m54r", + Description: "Remote code execution in dependabot-core branch names when cloning", + Package: &claircore.Package{ + Name: "dependabot-omnibus", + RepositoryHint: "rubygems", + }, + FixedInVersion: "fixed=0.125.1&introduced=0.119.0-beta1", + }, + want: false, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + got, err := matcher.Vulnerable(context.Background(), testcase.record, testcase.vuln) + if err != nil { + t.Fatal(err) + } + if !cmp.Equal(got, testcase.want) { + t.Error(cmp.Diff(got, testcase.want)) + } + }) + } +} diff --git a/ruby/testdata/indexreport-bullseye-ruby.json b/ruby/testdata/indexreport-bullseye-ruby.json new file mode 100644 index 000000000..faa98273f --- /dev/null +++ b/ruby/testdata/indexreport-bullseye-ruby.json @@ -0,0 +1,69 @@ +{ + "manifest_hash": "sha256:a69611773e59734e43c2c6a892ee477bbbb3c7fe450c67621f466424215c9d1e", + "packages": { + "2590": { + "id": "2590", + "name": "secure_headers", + "version": "6.0.0", + "kind": "binary", + "source": { + "id": "1", + "name": "", + "version": "" + } + }, + "2578": { + "id": "2578", + "name": "rake", + "version": "13.0.6", + "kind": "binary", + "source": { + "id": "1", + "name": "", + "version": "" + } + } + }, + "distributions": { + "1": { + "id": "1", + "did": "debian", + "name": "Debian GNU/Linux", + "version": "11 (bullseye)", + "version_code_name": "bullseye", + "version_id": "11", + "arch": "", + "cpe": "", + "pretty_name": "Debian GNU/Linux 11 (bullseye)" + } + }, + "repository": { + "1": { + "id": "1", + "name": "rubygems", + "uri": "https://rubygems.org/gems/" + } + }, + "environments": { + "2590": [ + { + "package_db": "usr/local/bundle/specifications/secure_headers-6.0.0.gemspec", + "introduced_in": "sha256:efc0c4e55a1d12c7228db037bab3d4022487ec971a85b29b6131a33138360b12", + "distribution_id": "", + "repository_ids": [ + "1" + ] + } + ], + "2578": [ + { + "package_db": "usr/local/bundle/specifications/rake-13.0.6.gemspec", + "introduced_in": "sha256:2deda8efde35fbfe5a4372dee905669e687558e590ee858a368e25c4bcc20afb", + "distribution_id": "", + "repository_ids": [ + "1" + ] + } + ] + } +} diff --git a/updater/osv/osv.go b/updater/osv/osv.go index 8562506c0..c6151edb6 100644 --- a/updater/osv/osv.go +++ b/updater/osv/osv.go @@ -446,9 +446,10 @@ type ecs struct { } const ( - ecosystemMaven = `Maven` - ecosystemPyPI = `PyPI` - ecosystemGo = `Go` + ecosystemGo = `Go` + ecosystemMaven = `Maven` + ecosystemPyPI = `PyPI` + ecosystemRubyGems = `RubyGems` ) func newECS(u string) ecs { @@ -558,7 +559,7 @@ func (e *ecs) Insert(ctx context.Context, skipped *stats, name string, a *adviso } case `ECOSYSTEM`: switch af.Package.Ecosystem { - case ecosystemMaven, ecosystemPyPI: + case ecosystemMaven, ecosystemPyPI, ecosystemRubyGems: switch { case ev.Introduced == "0": case ev.Introduced != "": @@ -588,7 +589,7 @@ func (e *ecs) Insert(ctx context.Context, skipped *stats, name string, a *adviso } if len(ranges) > 0 { switch af.Package.Ecosystem { - case ecosystemMaven, ecosystemPyPI: + case ecosystemMaven, ecosystemPyPI, ecosystemRubyGems: v.FixedInVersion = ranges.Encode() } } @@ -613,13 +614,13 @@ func (e *ecs) Insert(ctx context.Context, skipped *stats, name string, a *adviso } pkgName := af.Package.PURL switch af.Package.Ecosystem { - case ecosystemMaven, ecosystemPyPI, ecosystemGo: + case ecosystemGo, ecosystemMaven, ecosystemPyPI, ecosystemRubyGems: pkgName = af.Package.Name } pkg, novel := e.LookupPackage(pkgName, vs) v.Package = pkg switch af.Package.Ecosystem { - case ecosystemMaven, ecosystemPyPI, ecosystemGo: + case ecosystemGo, ecosystemMaven, ecosystemPyPI, ecosystemRubyGems: v.Package.Kind = claircore.BINARY } if novel {