diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go index a1417c2fa83..7b694de6c36 100644 --- a/cmd/grype/cli/commands/root.go +++ b/cmd/grype/cli/commands/root.go @@ -29,6 +29,7 @@ import ( "github.com/anchore/grype/grype/presenter/models" "github.com/anchore/grype/grype/store" "github.com/anchore/grype/grype/vex" + vexStatus "github.com/anchore/grype/grype/vex/status" "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/bus" "github.com/anchore/grype/internal/format" @@ -95,8 +96,8 @@ var ignoreFixedMatches = []match.IgnoreRule{ } var ignoreVEXFixedNotAffected = []match.IgnoreRule{ - {VexStatus: string(vex.StatusNotAffected)}, - {VexStatus: string(vex.StatusFixed)}, + {VexStatus: string(vexStatus.NotAffected)}, + {VexStatus: string(vexStatus.Fixed)}, } var ignoreLinuxKernelHeaders = []match.IgnoreRule{ @@ -180,6 +181,13 @@ func runGrype(app clio.Application, opts *options.Grype, userInput string) (errs } applyDistroHint(packages, &pkgContext, opts) + vexProcessor, err := vex.NewProcessor(vex.ProcessorOptions{ + Documents: opts.VexDocuments, + IgnoreRules: opts.Ignore, + }) + if err != nil { + return fmt.Errorf("failed to create VEX processor: %w", err) + } vulnMatcher := grype.VulnerabilityMatcher{ Store: *str, @@ -187,10 +195,7 @@ func runGrype(app clio.Application, opts *options.Grype, userInput string) (errs NormalizeByCVE: opts.ByCVE, FailSeverity: opts.FailOnSeverity(), Matchers: getMatchers(opts), - VexProcessor: vex.NewProcessor(vex.ProcessorOptions{ - Documents: opts.VexDocuments, - IgnoreRules: opts.Ignore, - }), + VexProcessor: vexProcessor, } remainingMatches, ignoredMatches, err := vulnMatcher.FindMatches(packages, pkgContext) @@ -356,18 +361,18 @@ func applyVexRules(opts *options.Grype) error { opts.Ignore = append(opts.Ignore, ignoreVEXFixedNotAffected...) } - for _, vexStatus := range opts.VexAdd { - switch vexStatus { - case string(vex.StatusAffected): + for _, status := range opts.VexAdd { + switch status { + case string(vexStatus.Affected): opts.Ignore = append( - opts.Ignore, match.IgnoreRule{VexStatus: string(vex.StatusAffected)}, + opts.Ignore, match.IgnoreRule{VexStatus: string(vexStatus.Affected)}, ) - case string(vex.StatusUnderInvestigation): + case string(vexStatus.UnderInvestigation): opts.Ignore = append( - opts.Ignore, match.IgnoreRule{VexStatus: string(vex.StatusUnderInvestigation)}, + opts.Ignore, match.IgnoreRule{VexStatus: string(vexStatus.UnderInvestigation)}, ) default: - return fmt.Errorf("invalid VEX status in vex-add setting: %s", vexStatus) + return fmt.Errorf("invalid VEX status in vex-add setting: %s", status) } } diff --git a/go.mod b/go.mod index 153899ca504..86348e1b750 100644 --- a/go.mod +++ b/go.mod @@ -56,11 +56,15 @@ require ( github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 - golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa gorm.io/gorm v1.25.10 ) -require github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 +require ( + github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 + github.com/aws/smithy-go v1.6.0 + github.com/csaf-poc/csaf_distribution/v3 v3.0.0 +) require ( cloud.google.com/go v0.110.10 // indirect @@ -72,6 +76,8 @@ require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect github.com/DataDog/zstd v1.5.5 // indirect + github.com/Intevation/gval v1.3.0 // indirect + github.com/Intevation/jsonpath v0.2.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect @@ -203,8 +209,9 @@ require ( github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/sassoftware/go-rpmutils v0.3.0 // indirect - github.com/shopspring/decimal v1.2.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spdx/tools-golang v0.5.4 // indirect @@ -228,6 +235,7 @@ require ( github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect github.com/zclconf/go-cty v1.14.0 // indirect github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 // indirect + go.etcd.io/bbolt v1.3.8 // indirect go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect diff --git a/go.sum b/go.sum index f66f9e3fb2d..fd94d0e2202 100644 --- a/go.sum +++ b/go.sum @@ -204,6 +204,10 @@ github.com/CycloneDX/cyclonedx-go v0.8.0/go.mod h1:K2bA+324+Og0X84fA8HhN2X066K7B github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/Intevation/gval v1.3.0 h1:+Ze5sft5MmGbZrHj06NVUbcxCb67l9RaPTLMNr37mjw= +github.com/Intevation/gval v1.3.0/go.mod h1:xmGyGpP5be12EL0P12h+dqiYG8qn2j3PJxIgkoOHO5o= +github.com/Intevation/jsonpath v0.2.1 h1:rINNQJ0Pts5XTFEG+zamtdL7l9uuE1z0FBA+r55Sw+A= +github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNYnibV7CiaYIw= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -282,6 +286,8 @@ github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.44.288 h1:Ln7fIao/nl0ACtelgR1I4AiEw/GLNkKcXfCaHupUW5Q= github.com/aws/aws-sdk-go v1.44.288/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/smithy-go v1.6.0 h1:T6puApfBcYiTIsaI+SYWqanjMt5pc3aoyyDrI+0YH54= +github.com/aws/smithy-go v1.6.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= @@ -359,6 +365,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/csaf-poc/csaf_distribution/v3 v3.0.0 h1:ob9+Fmpff0YWgTP3dYaw7G2hKQ9cegh9l3zksc+q3sM= +github.com/csaf-poc/csaf_distribution/v3 v3.0.0/go.mod h1:uilCTiNKivq+6zrDvjtZaUeLk70oe21iwKivo6ILwlQ= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= @@ -885,6 +893,8 @@ github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7 github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sassoftware/go-rpmutils v0.3.0 h1:tE4TZ8KcOXay5iIP64P291s6Qxd9MQCYhI7DU+f3gFA= github.com/sassoftware/go-rpmutils v0.3.0/go.mod h1:hM9wdxFsjUFR/tJ6SMsLrJuChcucCa0DsCzE9RMfwMo= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= @@ -896,8 +906,9 @@ github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -1014,6 +1025,8 @@ github.com/zclconf/go-cty v1.14.0 h1:/Xrd39K7DXbHzlisFP9c4pHao4yyf+/Ug9LEz+Y/yhc github.com/zclconf/go-cty v1.14.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg= github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis= +go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= +go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= @@ -1081,8 +1094,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= -golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/grype/match/matcher_type.go b/grype/match/matcher_type.go index ad547c6d94c..4aa44e867af 100644 --- a/grype/match/matcher_type.go +++ b/grype/match/matcher_type.go @@ -15,6 +15,7 @@ const ( PortageMatcher MatcherType = "portage-matcher" GoModuleMatcher MatcherType = "go-module-matcher" OpenVexMatcher MatcherType = "openvex-matcher" + CsafVexMatcher MatcherType = "csafvex-matcher" RustMatcher MatcherType = "rust-matcher" ) @@ -31,6 +32,7 @@ var AllMatcherTypes = []MatcherType{ PortageMatcher, GoModuleMatcher, OpenVexMatcher, + CsafVexMatcher, RustMatcher, } diff --git a/grype/presenter/internal/test_helpers.go b/grype/presenter/internal/test_helpers.go index 7797f7e9954..6fa8b70a1fc 100644 --- a/grype/presenter/internal/test_helpers.go +++ b/grype/presenter/internal/test_helpers.go @@ -8,7 +8,7 @@ import ( "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/presenter/models" - "github.com/anchore/grype/grype/vex" + vexStatus "github.com/anchore/grype/grype/vex/status" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/syft/syft/cpe" @@ -213,7 +213,7 @@ func generateIgnoredMatches(t *testing.T, p pkg.Package) []match.IgnoredMatch { Vulnerability: "CVE-1999-0004", Namespace: "vex", Package: match.IgnoreRulePackage{}, - VexStatus: string(vex.StatusNotAffected), + VexStatus: string(vexStatus.NotAffected), VexJustification: "this isn't the vulnerability match you're looking for... *waves hand*", }, }, diff --git a/grype/vex/csaf/csaf.go b/grype/vex/csaf/csaf.go new file mode 100644 index 00000000000..050481211fd --- /dev/null +++ b/grype/vex/csaf/csaf.go @@ -0,0 +1,128 @@ +package csaf + +import ( + "slices" + + "github.com/csaf-poc/csaf_distribution/v3/csaf" +) + +// advisoryMatch captures the criteria that caused a vulnerability to match a CSAF advisory +type advisoryMatch struct { + Vulnerability *csaf.Vulnerability + Status status + ProductID csaf.ProductID +} + +// cve returns the CVE of the vulnerability that matched +func (m *advisoryMatch) cve() string { + if m == nil || m.Vulnerability == nil || m.Vulnerability.CVE == nil { + return "" + } + + return string(*m.Vulnerability.CVE) +} + +// statement returns the statement of the vulnerability that matched +func (m *advisoryMatch) statement() string { + if m == nil || m.Vulnerability == nil { + return "" + } + + // an impact statement SHALL exist as machine readable flag in /vulnerabilities[]/flags (...) + for _, flag := range m.Vulnerability.Flags { + if flag == nil || flag.ProductIds == nil || flag.Label == nil { + continue + } + for _, pID := range *flag.ProductIds { + if pID == nil { + continue + } + if *pID == m.ProductID { + return string(*flag.Label) + } + } + } + // (...) or as human readable justification in /vulnerabilities[]/threats + for _, th := range m.Vulnerability.Threats { + if th == nil || th.Category == nil || th.Details == nil { + continue + } + if *th.Category != csaf.CSAFThreatCategoryImpact { + continue + } + for _, pID := range *th.ProductIds { + if pID == nil { + continue + } + if *pID == m.ProductID { + return string(*th.Details) + } + } + } + + return "" +} + +type advisories []*csaf.Advisory + +// Matches returns the first CSAF advisory to match for a given vulnerability ID and package URL +func (advisories advisories) matches(vulnID, purl string) *advisoryMatch { + + for _, adv := range advisories { + if adv == nil || adv.Vulnerabilities == nil { + continue + } + + // Auxiliary function to find in the advisory the 1st product ID that matches a given pURL + findProductID := func(products csaf.Products, purl string) csaf.ProductID { + for _, p := range products { + if p == nil { + continue + } + if slices.Contains(purlsFromProductIdentificationHelpers(adv.ProductTree.CollectProductIdentificationHelpers(*p)), purl) { + return *p + } + } + return "" + } + + for _, vuln := range adv.Vulnerabilities { + if vuln == nil || vuln.CVE == nil || string(*vuln.CVE) != vulnID { + continue + } + + productsByStatus := map[status]*csaf.Products{ + firstAffected: vuln.ProductStatus.FirstAffected, + firstFixed: vuln.ProductStatus.FirstFixed, + fixed: vuln.ProductStatus.Fixed, + knownAffected: vuln.ProductStatus.KnownAffected, + knownNotAffected: vuln.ProductStatus.KnownNotAffected, + lastAffected: vuln.ProductStatus.LastAffected, + recommended: vuln.ProductStatus.Recommended, + underInvestigation: vuln.ProductStatus.UnderInvestigation, + } + for status, products := range productsByStatus { + if products == nil { + continue + } + if productID := findProductID(*products, purl); productID != "" { + return &advisoryMatch{vuln, status, productID} + } + } + } + } + + return nil +} + +// purlsFromProductIdentificationHelpers returns a slice of PackageURLs (string format) given a slice of ProductIdentificationHelpers. +func purlsFromProductIdentificationHelpers(helpers []*csaf.ProductIdentificationHelper) []string { + var purls []string + for _, helper := range helpers { + if helper == nil || helper.PURL == nil { + continue + } + purls = append(purls, string(*helper.PURL)) + } + return purls +} diff --git a/grype/vex/csaf/csaf_test.go b/grype/vex/csaf/csaf_test.go new file mode 100644 index 00000000000..1d69c6c68a8 --- /dev/null +++ b/grype/vex/csaf/csaf_test.go @@ -0,0 +1,196 @@ +package csaf + +import ( + "reflect" + "testing" + + "github.com/csaf-poc/csaf_distribution/v3/csaf" +) + +func Test_advisoryMatch_statement(t *testing.T) { + type fields struct { + Vulnerability *csaf.Vulnerability + ProductID csaf.ProductID + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "no vulnerability", + fields: fields{ + Vulnerability: nil, + ProductID: "SPB-00260", + }, + want: "", + }, + { + name: "no flags or threats", + fields: fields{ + Vulnerability: &csaf.Vulnerability{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + }, + ProductID: "SPB-00260", + }, + want: "", + }, + { + name: "flag with label", + fields: fields{ + Vulnerability: &csaf.Vulnerability{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + Flags: []*csaf.Flag{{ + ProductIds: &csaf.Products{&[]csaf.ProductID{"SPB-00260"}[0]}, + Label: &[]csaf.FlagLabel{"vulnerable_code_not_present"}[0], + }}, + }, + ProductID: "SPB-00260", + }, + want: "vulnerable_code_not_present", + }, + { + name: "flag with label, different product ID", + fields: fields{ + Vulnerability: &csaf.Vulnerability{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + Flags: []*csaf.Flag{{ + ProductIds: &csaf.Products{&[]csaf.ProductID{"SPB-00260"}[0]}, + Label: &[]csaf.FlagLabel{"vulnerable_code_not_present"}[0], + }}, + }, + ProductID: "SPB-00261", + }, + want: "", + }, + { + name: "threat with details", + fields: fields{ + Vulnerability: &csaf.Vulnerability{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + Threats: []*csaf.Threat{{ + Category: &[]csaf.ThreatCategory{csaf.CSAFThreatCategoryImpact}[0], + Details: &[]string{"Class with vulnerable code was removed before shipping"}[0], + ProductIds: &csaf.Products{&[]csaf.ProductID{"SPB-00260"}[0]}, + }}, + }, + ProductID: "SPB-00260", + }, + want: "Class with vulnerable code was removed before shipping", + }, + { + name: "threat with details, different product ID", + fields: fields{ + Vulnerability: &csaf.Vulnerability{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + Threats: []*csaf.Threat{{ + Category: &[]csaf.ThreatCategory{csaf.CSAFThreatCategoryImpact}[0], + Details: &[]string{"Class with vulnerable code was removed before shipping"}[0], + ProductIds: &csaf.Products{&[]csaf.ProductID{"SPB-00260"}[0]}, + }}, + }, + ProductID: "SPB-00261", + }, + want: "", + }, + } + t.Parallel() + for _, testToRun := range tests { + test := testToRun + t.Run(test.name, func(tt *testing.T) { + tt.Parallel() + m := &advisoryMatch{ + Vulnerability: test.fields.Vulnerability, + ProductID: test.fields.ProductID, + } + if got := m.statement(); got != test.want { + tt.Errorf("advisoryMatch.statement() = %v, want %v", got, test.want) + } + }) + } +} + +func Test_advisories_matches(t *testing.T) { + sampleAdv := &csaf.Advisory{ + ProductTree: &csaf.ProductTree{ + Branches: csaf.Branches{ + &[]csaf.Branch{{ + Branches: csaf.Branches{ + &[]csaf.Branch{{ + Category: &[]csaf.BranchCategory{csaf.CSAFBranchCategoryProductVersion}[0], + Name: &[]string{"2.6.0"}[0], + Product: &csaf.FullProductName{ + Name: &[]string{"Spring Boot 2.6.0"}[0], + ProductID: &[]csaf.ProductID{"SPB-00260"}[0], + ProductIdentificationHelper: &[]csaf.ProductIdentificationHelper{{ + PURL: &[]csaf.PURL{"pkg:apk/alpine/libssl3@3.0.8-r3"}[0], + }}[0], + }, + }}[0], + }, + Category: &[]csaf.BranchCategory{csaf.CSAFBranchCategoryProductName}[0], + Name: &[]string{"Spring"}[0], + }}[0], + }, + }, + Vulnerabilities: []*csaf.Vulnerability{{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + ProductStatus: &[]csaf.ProductStatus{{ + KnownNotAffected: &csaf.Products{ + &[]csaf.ProductID{"SPB-00260"}[0], + }, + }}[0], + }}, + } + + type args struct { + vulnID string + purl string + } + tests := []struct { + name string + advisories advisories + args args + want *advisoryMatch + }{ + { + name: "no advisories", + advisories: advisories{}, + args: args{vulnID: "CVE-1234-5678", purl: "pkg:apk/alpine/libssl3@3.0.8-r3"}, + want: nil, + }, + { + name: "no matching advisory", + advisories: advisories{sampleAdv}, + args: args{vulnID: "CVE-1234-5678", purl: "pkg:apk/alpine/libcrypto3@3.0.8-r3"}, + want: nil, + }, + { + name: "advisory matches vulnerability for given pURL", + advisories: advisories{sampleAdv}, + args: args{vulnID: "CVE-1234-5678", purl: "pkg:apk/alpine/libssl3@3.0.8-r3"}, + want: &advisoryMatch{ + Vulnerability: &csaf.Vulnerability{ + CVE: &[]csaf.CVE{"CVE-1234-5678"}[0], + ProductStatus: &[]csaf.ProductStatus{{ + KnownNotAffected: &csaf.Products{ + &[]csaf.ProductID{"SPB-00260"}[0], + }, + }}[0], + }, + ProductID: "SPB-00260", + Status: knownNotAffected, + }, + }, + } + t.Parallel() + for _, testToRun := range tests { + test := testToRun + t.Run(test.name, func(tt *testing.T) { + tt.Parallel() + if got := test.advisories.matches(test.args.vulnID, test.args.purl); !reflect.DeepEqual(got, test.want) { + tt.Errorf("advisories.matches() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/grype/vex/csaf/implementation.go b/grype/vex/csaf/implementation.go new file mode 100644 index 00000000000..1336894290a --- /dev/null +++ b/grype/vex/csaf/implementation.go @@ -0,0 +1,203 @@ +package csaf + +import ( + "errors" + "fmt" + "slices" + "sort" + + "github.com/anchore/grype/grype/match" + "github.com/anchore/grype/grype/pkg" + vexStatus "github.com/anchore/grype/grype/vex/status" + + "github.com/aws/smithy-go/time" + "github.com/csaf-poc/csaf_distribution/v3/csaf" +) + +// searchedBy captures the parameters used to search through the VEX data +type searchedBy struct { + Vulnerability string + Purl string +} + +type Processor struct{} + +func New() *Processor { + return &Processor{} +} + +// IsCSAF checks if the provided document is a CSAF document +func IsCSAF(document string) bool { + if _, err := csaf.LoadAdvisory(document); err == nil { + return true + } + return false +} + +// ReadVexDocuments reads different files and creates a collection of advisories based on them. +func (*Processor) ReadVexDocuments(docs []string) (interface{}, error) { + var advs advisories + + for _, doc := range docs { + adv, err := csaf.LoadAdvisory(doc) + if err != nil { + return nil, fmt.Errorf("error loading VEX CSAF document: %w", err) + } + advs = append(advs, adv) + } + + // The collection is sorted by date, so newer advisories are guaranteed to be consumed before. + sort.SliceStable(advs, func(i, j int) bool { + i_t, _ := time.ParseDateTime(*advs[i].Document.Tracking.CurrentReleaseDate) + j_t, _ := time.ParseDateTime(*advs[j].Document.Tracking.CurrentReleaseDate) + return i_t.Before(j_t) + }) + + return advs, nil +} + +// FilterMatches takes a set of scanning results and moves any results marked in +// the VEX data as fixed or not_affected to the ignored list. +func (*Processor) FilterMatches( + docRaw interface{}, ignoreRules []match.IgnoreRule, _ *pkg.Context, matches *match.Matches, ignoredMatches []match.IgnoredMatch, +) (*match.Matches, []match.IgnoredMatch, error) { + + advisories, ok := docRaw.(advisories) + if !ok { + return nil, nil, errors.New("unable to cast vex document as CSAF Advisories") + } + + remainingMatches := match.NewMatches() + for _, m := range matches.Sorted() { + // Seek if our advisories have information about a vulnerability affecting + // the product for which we have a match. + advMatch := advisories.matches(m.Vulnerability.ID, m.Package.PURL) + if advMatch == nil { + remainingMatches.Add(m) + continue + } + + // Filtering only applies to not_affected and fixed statuses + if !matchesVexStatus(advMatch.Status, vexStatus.NotAffected) && !matchesVexStatus(advMatch.Status, vexStatus.Fixed) { + remainingMatches.Add(m) + continue + } + + // Check if there's any ignore rule that matches the current match statement + rule := matchingRule(ignoreRules, m, advMatch, vexStatus.IgnoreList()) + if rule == nil { + remainingMatches.Add(m) + continue + } + + ignoredMatches = append(ignoredMatches, match.IgnoredMatch{ + Match: m, + AppliedIgnoreRules: []match.IgnoreRule{*rule}, + }) + } + + return &remainingMatches, ignoredMatches, nil +} + +// AugmentMatches adds results to the match.Matches array when matching data +// about an affected VEX product is found on loaded VEX documents. Matches +// are moved from the ignore list or synthesized when no previous data is found. +func (*Processor) AugmentMatches( + docRaw interface{}, ignoreRules []match.IgnoreRule, _ *pkg.Context, matches *match.Matches, ignoredMatches []match.IgnoredMatch, +) (*match.Matches, []match.IgnoredMatch, error) { + + advisories, ok := docRaw.(advisories) + if !ok { + return nil, nil, errors.New("unable to cast vex document as CSAF Advisories") + } + + remainingIgnoredMatches := []match.IgnoredMatch{} + for _, m := range ignoredMatches { + if advMatch := advisories.matches(m.Vulnerability.ID, m.Package.PURL); advMatch != nil { + if rule := matchingRule(ignoreRules, m.Match, advMatch, vexStatus.AugmentList()); rule != nil { + newMatch := m.Match + newMatch.Details = append(newMatch.Details, match.Detail{ + Type: match.ExactDirectMatch, + SearchedBy: &searchedBy{ + Vulnerability: m.Vulnerability.ID, + Purl: m.Package.PURL, + }, + Found: advMatch, + Matcher: match.CsafVexMatcher, + }) + matches.Add(newMatch) + continue + } + } + + remainingIgnoredMatches = append(remainingIgnoredMatches, m) + } + + return matches, remainingIgnoredMatches, nil +} + +// matchingRule cycles through a set of ignore rules and returns the first +// one that matches the statement and the match. Returns nil if none match. +func matchingRule(ignoreRules []match.IgnoreRule, m match.Match, advMatch *advisoryMatch, allowedStatuses []vexStatus.Status) *match.IgnoreRule { + ms := match.NewMatches() + ms.Add(m) + + // By default, if there are no ignore rules (which means the user didn't provide + // any custom VEX rule), a matching rule should be returned if the advisory + // match status is one of the allowed statuses. + if len(ignoreRules) == 0 { + for _, status := range allowedStatuses { + if matchesVexStatus(advMatch.Status, status) { + return &match.IgnoreRule{ + Namespace: "vex", + Vulnerability: advMatch.cve(), + VexJustification: advMatch.statement(), + VexStatus: string(status), + } + } + } + } + + for _, rule := range ignoreRules { + // If the rule has more conditions than just the VEX statement, check if + // it applies to the current match. + if rule.HasConditions() { + r := rule + r.VexStatus = "" + if _, ignored := match.ApplyIgnoreRules(ms, []match.IgnoreRule{r}); len(ignored) == 0 { + continue + } + } + + // If the advisory match status is not the same as the rule status, + // it does not apply + if !matchesVexStatus(advMatch.Status, vexStatus.Status(rule.VexStatus)) { + continue + } + + // If the rule has a status other than the allowed ones, skip: + if rule.VexStatus != "" && !slices.Contains(allowedStatuses, vexStatus.Status(rule.VexStatus)) { + continue + } + + // If the vulnerability is blank in the rule it means we will honor + // any status with any vulnerability. Alternatively, if the vulnerability + // is set, the rule applies if it is the same in the advisory match and the rule. + if rule.Vulnerability == "" || advMatch.cve() == rule.Vulnerability { + return &rule + } + + // If the rule applies to a VEX justification it needs to match the + // advisory match statement, note that justifications only apply to not_affected: + if matchesVexStatus(advMatch.Status, vexStatus.NotAffected) && rule.VexJustification != "" && + rule.VexJustification != advMatch.statement() { + continue + } + + if advMatch.cve() == rule.Vulnerability { + return &rule + } + } + + return nil +} diff --git a/grype/vex/csaf/status.go b/grype/vex/csaf/status.go new file mode 100644 index 00000000000..d3e8908f100 --- /dev/null +++ b/grype/vex/csaf/status.go @@ -0,0 +1,33 @@ +package csaf + +import vexStatus "github.com/anchore/grype/grype/vex/status" + +type status string + +const ( + firstAffected status = "first_affected" + firstFixed status = "first_fixed" + fixed status = "fixed" + knownAffected status = "known_affected" + knownNotAffected status = "known_not_affected" + lastAffected status = "last_affected" + recommended status = "recommended" + underInvestigation status = "under_investigation" +) + +// matchesVexStatus returns true if the given CSAF status matches the given VEX status. +func matchesVexStatus(csafStatus status, status vexStatus.Status) bool { + // CSAF implementation has slightly different, richer statuses than the original VEX proposed by CISA + switch csafStatus { + case firstAffected, knownAffected, lastAffected, recommended: + return status == vexStatus.Affected + case firstFixed, fixed: + return status == vexStatus.Fixed + case knownNotAffected: + return status == vexStatus.NotAffected + case underInvestigation: + return status == vexStatus.UnderInvestigation + default: + return false + } +} diff --git a/grype/vex/openvex/implementation.go b/grype/vex/openvex/implementation.go index 608750bae7e..4112d77a819 100644 --- a/grype/vex/openvex/implementation.go +++ b/grype/vex/openvex/implementation.go @@ -4,15 +4,17 @@ import ( "errors" "fmt" "net/url" + "slices" "strings" - "github.com/google/go-containerregistry/pkg/name" - openvex "github.com/openvex/go-vex/pkg/vex" - "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" + vexStatus "github.com/anchore/grype/grype/vex/status" + "github.com/anchore/packageurl-go" "github.com/anchore/syft/syft/source" + "github.com/google/go-containerregistry/pkg/name" + openvex "github.com/openvex/go-vex/pkg/vex" ) type Processor struct{} @@ -26,23 +28,19 @@ type Match struct { Statement openvex.Statement } -// SearchedBy captures the prameters used to search through the VEX data +// SearchedBy captures the parameters used to search through the VEX data type SearchedBy struct { Vulnerability string Product string Subcomponents []string } -// augmentStatuses are the VEX statuses that augment results -var augmentStatuses = []openvex.Status{ - openvex.StatusAffected, - openvex.StatusUnderInvestigation, -} - -// filterStatuses are the VEX statuses that filter matched to the ignore list -var ignoreStatuses = []openvex.Status{ - openvex.StatusNotAffected, - openvex.StatusFixed, +// IsOpenVex checks if the provided document is a VEX document +func IsOpenVex(document string) bool { + if _, err := openvex.Load(document); err == nil { + return true + } + return false } // ReadVexDocuments reads and merges VEX documents @@ -175,7 +173,7 @@ func (ovm *Processor) FilterMatches( continue } - rule := matchingRule(ignoreRules, sorted[i], statement, ignoreStatuses) + rule := matchingRule(ignoreRules, sorted[i], statement, vexStatus.IgnoreList()) if rule == nil { remainingMatches.Add(sorted[i]) continue @@ -197,13 +195,20 @@ func (ovm *Processor) FilterMatches( // matchingRule cycles through a set of ignore rules and returns the first // one that matches the statement and the match. Returns nil if none match. -func matchingRule(ignoreRules []match.IgnoreRule, m match.Match, statement *openvex.Statement, allowedStatuses []openvex.Status) *match.IgnoreRule { +func matchingRule(ignoreRules []match.IgnoreRule, m match.Match, statement *openvex.Statement, allowedStatuses []vexStatus.Status) *match.IgnoreRule { ms := match.NewMatches() ms.Add(m) - revStatuses := map[string]struct{}{} - for _, s := range allowedStatuses { - revStatuses[string(s)] = struct{}{} + // By default, if there are no ignore rules (which means the user didn't provide + // any custom VEX rule), a matching rule should be returned if the statement + // status is one of the allowed statuses. + if len(ignoreRules) == 0 && slices.Contains(allowedStatuses, vexStatus.Status(statement.Status)) { + return &match.IgnoreRule{ + Namespace: "vex", + Vulnerability: statement.Vulnerability.ID, + VexJustification: string(statement.Justification), + VexStatus: string(statement.Status), + } } for _, rule := range ignoreRules { @@ -224,10 +229,8 @@ func matchingRule(ignoreRules []match.IgnoreRule, m match.Match, statement *open } // If the rule has a statement other than the allowed ones, skip: - if len(revStatuses) > 0 && rule.VexStatus != "" { - if _, ok := revStatuses[rule.VexStatus]; !ok { - continue - } + if rule.VexStatus != "" && !slices.Contains(allowedStatuses, vexStatus.Status(rule.VexStatus)) { + continue } // If the rule applies to a VEX justification it needs to match the @@ -301,7 +304,7 @@ func (ovm *Processor) AugmentMatches( } // Only match if rules to augment are configured - rule := matchingRule(ignoreRules, ignoredMatches[i].Match, statement, augmentStatuses) + rule := matchingRule(ignoreRules, ignoredMatches[i].Match, statement, vexStatus.AugmentList()) if rule == nil { additionalIgnoredMatches = append(additionalIgnoredMatches, ignoredMatches[i]) continue diff --git a/grype/vex/processor.go b/grype/vex/processor.go index 2c744d9f360..5703b40a949 100644 --- a/grype/vex/processor.go +++ b/grype/vex/processor.go @@ -3,22 +3,12 @@ package vex import ( "fmt" - gopenvex "github.com/openvex/go-vex/pkg/vex" - "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/vex/csaf" "github.com/anchore/grype/grype/vex/openvex" ) -type Status string - -const ( - StatusNotAffected Status = Status(gopenvex.StatusNotAffected) - StatusAffected Status = Status(gopenvex.StatusAffected) - StatusFixed Status = Status(gopenvex.StatusFixed) - StatusUnderInvestigation Status = Status(gopenvex.StatusUnderInvestigation) -) - type Processor struct { Options ProcessorOptions impl vexProcessorImplementation @@ -43,20 +33,38 @@ type vexProcessorImplementation interface { // getVexImplementation this function returns the vex processor implementation // at some point it can read the options and choose a user configured implementation. -func getVexImplementation() vexProcessorImplementation { - return openvex.New() +func getVexImplementation(documents []string) (vexProcessorImplementation, error) { + // No documents, no implementation + if len(documents) == 0 { + return nil, nil + } + + // We assume that, even N documents are provided, all of them use the same format + // so we can use the first one to determine the implementation to use. + if csaf.IsCSAF(documents[0]) { + return csaf.New(), nil + } + if openvex.IsOpenVex(documents[0]) { + return openvex.New(), nil + } + + return nil, fmt.Errorf("unsupported VEX document format") } -// NewProcessor returns a new VEX processor. For now, it defaults to the only vex -// implementation: OpenVEX -func NewProcessor(opts ProcessorOptions) *Processor { +// NewProcessor returns a new VEX processor +func NewProcessor(opts ProcessorOptions) (*Processor, error) { + implementation, err := getVexImplementation(opts.Documents) + if err != nil { + return nil, fmt.Errorf("unable to create VEX processor: %w", err) + } + return &Processor{ Options: opts, - impl: getVexImplementation(), - } + impl: implementation, + }, nil } -// ProcessorOptions captures the optiones of the VEX processor. +// ProcessorOptions captures the options of the VEX processor. type ProcessorOptions struct { Documents []string IgnoreRules []match.IgnoreRule @@ -68,7 +76,7 @@ type ProcessorOptions struct { func (vm *Processor) ApplyVEX(pkgContext *pkg.Context, remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch) (*match.Matches, []match.IgnoredMatch, error) { var err error - // If no VEX documents are loaded, just pass through the matches, effectivle NOOP + // If no VEX documents are loaded, just pass through the matches, effectively NOOP if len(vm.Options.Documents) == 0 { return remainingMatches, ignoredMatches, nil } @@ -108,5 +116,6 @@ func extractVexRules(rules []match.IgnoreRule) []match.IgnoreRule { newRules[len(newRules)-1].Namespace = "vex" } } + return newRules } diff --git a/grype/vex/processor_test.go b/grype/vex/processor_test.go index bdd84a3d7b6..870e3c7718c 100644 --- a/grype/vex/processor_test.go +++ b/grype/vex/processor_test.go @@ -9,6 +9,7 @@ import ( v5 "github.com/anchore/grype/grype/db/v5" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" + "github.com/anchore/grype/grype/vex/status" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/syft/syft/source" ) @@ -112,64 +113,125 @@ func TestProcessor_ApplyVEX(t *testing.T) { wantErr require.ErrorAssertionFunc }{ { - name: "openvex-demo1 - ignore by fixed status", + name: "csaf-demo1 - ignore by fixed status", options: ProcessorOptions{ Documents: []string{ - "testdata/vex-docs/openvex-demo1.json", + "testdata/vex-docs/csaf-demo1.json", }, - IgnoreRules: []match.IgnoreRule{ - { - VexStatus: "fixed", - }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: string(status.Fixed), + }}, + }, + args: args{ + pkgContext: pkgContext, + matches: getSubject(), + }, + wantMatches: matchesRef(libCryptoCVE_2023_3817, libCryptoCVE_2023_2975), + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_1255, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + VexStatus: string(status.Fixed), + }}, + }}, + }, + { + name: "csaf-demo1 - ignore by fixed status and CVE", + options: ProcessorOptions{ + Documents: []string{ + "testdata/vex-docs/csaf-demo1.json", }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: string(status.Fixed), + Vulnerability: "CVE-2023-1255", // note: and previous tests + }}, }, args: args{ pkgContext: pkgContext, matches: getSubject(), }, wantMatches: matchesRef(libCryptoCVE_2023_3817, libCryptoCVE_2023_2975), - wantIgnoredMatches: []match.IgnoredMatch{ - { - Match: libCryptoCVE_2023_1255, - AppliedIgnoreRules: []match.IgnoreRule{ - { - Namespace: "vex", // note: an additional namespace was added - VexStatus: "fixed", - }, - }, + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_1255, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + Vulnerability: "CVE-2023-1255", + VexStatus: string(status.Fixed), + }}, + }}, + }, + { + name: "csaf-demo2 - ignore by not_affected status and vulnerable_code_not_present justification", + options: ProcessorOptions{ + Documents: []string{ + "testdata/vex-docs/csaf-demo2.json", }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: string(status.NotAffected), + VexJustification: "vulnerable_code_not_present", // note: this is the difference between this test and previous tests + }}, + }, + args: args{ + pkgContext: pkgContext, + matches: getSubject(), }, + wantMatches: matchesRef(libCryptoCVE_2023_1255, libCryptoCVE_2023_2975), + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_3817, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + VexJustification: "vulnerable_code_not_present", + VexStatus: string(status.NotAffected), + }}, + }}, }, { - name: "openvex-demo1 - ignore by fixed status and CVE", // no real difference from the first test other than the AppliedIgnoreRules + name: "openvex-demo1 - ignore by fixed status", options: ProcessorOptions{ Documents: []string{ "testdata/vex-docs/openvex-demo1.json", }, - IgnoreRules: []match.IgnoreRule{ - { - Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test - VexStatus: "fixed", - }, - }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: string(status.Fixed), + }}, }, args: args{ pkgContext: pkgContext, matches: getSubject(), }, wantMatches: matchesRef(libCryptoCVE_2023_3817, libCryptoCVE_2023_2975), - wantIgnoredMatches: []match.IgnoredMatch{ - { - Match: libCryptoCVE_2023_1255, - AppliedIgnoreRules: []match.IgnoreRule{ - { - Namespace: "vex", - Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test - VexStatus: "fixed", - }, - }, + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_1255, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + VexStatus: string(status.Fixed), + }}, + }}, + }, + { + name: "openvex-demo1 - ignore by fixed status and CVE", // no real difference from the first test other than the AppliedIgnoreRules + options: ProcessorOptions{ + Documents: []string{ + "testdata/vex-docs/openvex-demo1.json", }, + IgnoreRules: []match.IgnoreRule{{ + Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test + VexStatus: string(status.Fixed), + }}, + }, + args: args{ + pkgContext: pkgContext, + matches: getSubject(), }, + wantMatches: matchesRef(libCryptoCVE_2023_3817, libCryptoCVE_2023_2975), + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_1255, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test + VexStatus: string(status.Fixed), + }}, + }}, }, { name: "openvex-demo2 - ignore by fixed status", @@ -177,37 +239,28 @@ func TestProcessor_ApplyVEX(t *testing.T) { Documents: []string{ "testdata/vex-docs/openvex-demo2.json", }, - IgnoreRules: []match.IgnoreRule{ - { - VexStatus: "fixed", - }, - }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: string(status.Fixed), + }}, }, args: args{ pkgContext: pkgContext, matches: getSubject(), }, wantMatches: matchesRef(libCryptoCVE_2023_3817), - wantIgnoredMatches: []match.IgnoredMatch{ - { - Match: libCryptoCVE_2023_1255, - AppliedIgnoreRules: []match.IgnoreRule{ - { - Namespace: "vex", - VexStatus: "fixed", - }, - }, - }, - { - Match: libCryptoCVE_2023_2975, - AppliedIgnoreRules: []match.IgnoreRule{ - { - Namespace: "vex", - VexStatus: "fixed", - }, - }, - }, - }, + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_1255, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + VexStatus: string(status.Fixed), + }}, + }, { + Match: libCryptoCVE_2023_2975, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + VexStatus: string(status.Fixed), + }}, + }}, }, { name: "openvex-demo2 - ignore by fixed status and CVE", @@ -215,30 +268,24 @@ func TestProcessor_ApplyVEX(t *testing.T) { Documents: []string{ "testdata/vex-docs/openvex-demo2.json", }, - IgnoreRules: []match.IgnoreRule{ - { - Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test - VexStatus: "fixed", - }, - }, + IgnoreRules: []match.IgnoreRule{{ + Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test + VexStatus: string(status.Fixed), + }}, }, args: args{ pkgContext: pkgContext, matches: getSubject(), }, wantMatches: matchesRef(libCryptoCVE_2023_3817, libCryptoCVE_2023_2975), - wantIgnoredMatches: []match.IgnoredMatch{ - { - Match: libCryptoCVE_2023_1255, - AppliedIgnoreRules: []match.IgnoreRule{ - { - Namespace: "vex", - Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test - VexStatus: "fixed", - }, - }, - }, - }, + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_1255, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + Vulnerability: "CVE-2023-1255", // note: this is the difference between this test and the last test + VexStatus: string(status.Fixed), + }}, + }}, }, { name: "openvex-demo1 - ignore by not_affected status and vulnerable_code_not_present justification", @@ -246,12 +293,10 @@ func TestProcessor_ApplyVEX(t *testing.T) { Documents: []string{ "testdata/vex-docs/openvex-demo1.json", }, - IgnoreRules: []match.IgnoreRule{ - { - VexStatus: "not_affected", - VexJustification: "vulnerable_code_not_present", - }, - }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: "not_affected", + VexJustification: "vulnerable_code_not_present", + }}, }, args: args{ pkgContext: pkgContext, @@ -267,30 +312,24 @@ func TestProcessor_ApplyVEX(t *testing.T) { Documents: []string{ "testdata/vex-docs/openvex-demo2.json", }, - IgnoreRules: []match.IgnoreRule{ - { - VexStatus: "not_affected", - VexJustification: "vulnerable_code_not_present", - }, - }, + IgnoreRules: []match.IgnoreRule{{ + VexStatus: "not_affected", + VexJustification: "vulnerable_code_not_present", + }}, }, args: args{ pkgContext: pkgContext, matches: getSubject(), }, wantMatches: matchesRef(libCryptoCVE_2023_2975, libCryptoCVE_2023_1255), - wantIgnoredMatches: []match.IgnoredMatch{ - { - Match: libCryptoCVE_2023_3817, - AppliedIgnoreRules: []match.IgnoreRule{ - { - Namespace: "vex", - VexStatus: "not_affected", - VexJustification: "vulnerable_code_not_present", - }, - }, - }, - }, + wantIgnoredMatches: []match.IgnoredMatch{{ + Match: libCryptoCVE_2023_3817, + AppliedIgnoreRules: []match.IgnoreRule{{ + Namespace: "vex", + VexStatus: "not_affected", + VexJustification: "vulnerable_code_not_present", + }}, + }}, }, } for _, tt := range tests { @@ -299,7 +338,11 @@ func TestProcessor_ApplyVEX(t *testing.T) { tt.wantErr = require.NoError } - p := NewProcessor(tt.options) + p, err := NewProcessor(tt.options) + tt.wantErr(t, err) + if err != nil { + return + } actualMatches, actualIgnoredMatches, err := p.ApplyVEX(tt.args.pkgContext, tt.args.matches, tt.args.ignoredMatches) tt.wantErr(t, err) if err != nil { diff --git a/grype/vex/status/status.go b/grype/vex/status/status.go new file mode 100644 index 00000000000..64c9c299584 --- /dev/null +++ b/grype/vex/status/status.go @@ -0,0 +1,24 @@ +package status + +type Status string + +// VEX statuses as defined by CISA +// https://www.cisa.gov/sites/default/files/2023-04/minimum-requirements-for-vex-508c.pdf +// +// Different VEX implementation can use different names to refer to them +const ( + NotAffected Status = "not_affected" + Affected Status = "affected" + Fixed Status = "fixed" + UnderInvestigation Status = "under_investigation" +) + +// AugmentList returns the VEX statuses that augment results +func AugmentList() []Status { + return []Status{Affected, UnderInvestigation} +} + +// IgnoreList returns the VEX statuses that should be ignored +func IgnoreList() []Status { + return []Status{Fixed, NotAffected} +} diff --git a/grype/vex/testdata/vex-docs/csaf-demo1.json b/grype/vex/testdata/vex-docs/csaf-demo1.json new file mode 100644 index 00000000000..89acc272538 --- /dev/null +++ b/grype/vex/testdata/vex-docs/csaf-demo1.json @@ -0,0 +1,86 @@ +{ + "document": { + "category": "csaf_vex", + "csaf_version": "2.0", + "notes": [ + { + "category": "summary", + "text": "Example Company VEX document. Unofficial content for demonstration purposes only.", + "title": "Author comment" + } + ], + "publisher": { + "category": "vendor", + "name": "Example Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "title": "AquaSecurity example VEX document", + "tracking": { + "current_release_date": "2022-03-03T11:00:00.000Z", + "generator": { + "date": "2022-03-03T11:00:00.000Z", + "engine": { + "name": "Secvisogram", + "version": "1.11.0" + } + }, + "id": "2022-EVD-UC-01-A-001", + "initial_release_date": "2022-03-03T11:00:00.000Z", + "revision_history": [ + { + "date": "2022-03-03T11:00:00.000Z", + "number": "1", + "summary": "Initial version." + } + ], + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "branches": [ + { + "category": "product_version", + "name": "2.6.0", + "product": { + "name": "Spring Boot 2.6.0", + "product_id": "SPB-00260", + "product_identification_helper": { + "purl": "pkg:apk/alpine/libcrypto3@3.0.8-r3?arch=x86_64&upstream=openssl&distro=alpine-3.17.3" + } + } + } + ], + "category": "product_name", + "name": "Spring Boot" + } + ], + "category": "vendor", + "name": "Spring" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2023-1255", + "product_status": { + "fixed": [ + "SPB-00260" + ] + }, + "threats": [ + { + "category": "impact", + "details": "Class with vulnerable code was removed before shipping.", + "product_ids": [ + "SPB-00260" + ] + } + ] + } + ] +} diff --git a/grype/vex/testdata/vex-docs/csaf-demo2.json b/grype/vex/testdata/vex-docs/csaf-demo2.json new file mode 100644 index 00000000000..20b05844d05 --- /dev/null +++ b/grype/vex/testdata/vex-docs/csaf-demo2.json @@ -0,0 +1,85 @@ +{ + "document": { + "category": "csaf_vex", + "csaf_version": "2.0", + "notes": [ + { + "category": "summary", + "text": "Example Company VEX document. Unofficial content for demonstration purposes only.", + "title": "Author comment" + } + ], + "publisher": { + "category": "vendor", + "name": "Example Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "title": "AquaSecurity example VEX document", + "tracking": { + "current_release_date": "2022-03-03T11:00:00.000Z", + "generator": { + "date": "2022-03-03T11:00:00.000Z", + "engine": { + "name": "Secvisogram", + "version": "1.11.0" + } + }, + "id": "2022-EVD-UC-01-A-001", + "initial_release_date": "2022-03-03T11:00:00.000Z", + "revision_history": [ + { + "date": "2022-03-03T11:00:00.000Z", + "number": "1", + "summary": "Initial version." + } + ], + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "branches": [ + { + "category": "product_version", + "name": "2.6.0", + "product": { + "name": "Spring Boot 2.6.0", + "product_id": "SPB-00260", + "product_identification_helper": { + "purl": "pkg:apk/alpine/libcrypto3@3.0.8-r3?arch=x86_64&upstream=openssl&distro=alpine-3.17.3" + } + } + } + ], + "category": "product_name", + "name": "Spring Boot" + } + ], + "category": "vendor", + "name": "Spring" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2023-3817", + "flags": [ + { + "label": "vulnerable_code_not_present", + "product_ids": [ + "SPB-00260" + ] + } + ], + "product_status": { + "known_not_affected": [ + "SPB-00260" + ] + } + } + ] +} diff --git a/grype/vulnerability_matcher_test.go b/grype/vulnerability_matcher_test.go index 8ddc98279b3..89ea0498c6c 100644 --- a/grype/vulnerability_matcher_test.go +++ b/grype/vulnerability_matcher_test.go @@ -323,6 +323,17 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { Language: syftPkg.Ruby, } + openvexProcessor, _ := vex.NewProcessor(vex.ProcessorOptions{ + Documents: []string{ + "vex/testdata/vex-docs/openvex-debian.json", + }, + IgnoreRules: []match.IgnoreRule{ + { + VexStatus: "fixed", + }, + }, + }) + type fields struct { Store store.Store Matchers []matcher.Matcher @@ -478,16 +489,7 @@ func TestVulnerabilityMatcher_FindMatches(t *testing.T) { x := vulnerability.LowSeverity return &x }(), - VexProcessor: vex.NewProcessor(vex.ProcessorOptions{ - Documents: []string{ - "vex/testdata/vex-docs/openvex-debian.json", - }, - IgnoreRules: []match.IgnoreRule{ - { - VexStatus: "fixed", - }, - }, - }), + VexProcessor: openvexProcessor, }, args: args{ pkgs: []pkg.Package{ diff --git a/test/integration/db_mock_test.go b/test/integration/db_mock_test.go index aa46c9154cb..222d266d0bd 100644 --- a/test/integration/db_mock_test.go +++ b/test/integration/db_mock_test.go @@ -45,7 +45,7 @@ func newMockDbStore() *mockStore { "nvd:cpe": { "libvncserver": []grypeDB.Vulnerability{ { - ID: "CVE-alpine-libvncserver", + ID: "CVE-2024-0000", VersionConstraint: "< 0.9.10", VersionFormat: "unknown", CPEs: []string{"cpe:2.3:a:lib_vnc_project-(server):libvncserver:*:*:*:*:*:*:*:*"}, @@ -69,7 +69,7 @@ func newMockDbStore() *mockStore { "alpine:distro:alpine:3.12": { "libvncserver": []grypeDB.Vulnerability{ { - ID: "CVE-alpine-libvncserver", + ID: "CVE-2024-0000", VersionConstraint: "< 0.9.10", VersionFormat: "unknown", }, diff --git a/test/integration/match_by_image_test.go b/test/integration/match_by_image_test.go index 8f1cdf35b62..c0edbf155f4 100644 --- a/test/integration/match_by_image_test.go +++ b/test/integration/match_by_image_test.go @@ -17,6 +17,7 @@ import ( "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/store" "github.com/anchore/grype/grype/vex" + vexStatus "github.com/anchore/grype/grype/vex/status" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/grype/internal/stringutil" "github.com/anchore/stereoscope/pkg/imagetest" @@ -47,7 +48,7 @@ func addAlpineMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co Details: []match.Detail{ { // note: the input pURL has an upstream reference (redundant) - Type: "exact-indirect-match", + Type: match.ExactIndirectMatch, SearchedBy: map[string]any{ "distro": map[string]string{ "type": "alpine", @@ -61,7 +62,7 @@ func addAlpineMatches(t *testing.T, theSource source.Source, catalog *syftPkg.Co }, Found: map[string]any{ "versionConstraint": "< 0.9.10 (unknown)", - "vulnerabilityID": "CVE-alpine-libvncserver", + "vulnerabilityID": "CVE-2024-0000", }, Matcher: "apk-matcher", Confidence: 1, @@ -698,13 +699,15 @@ func TestMatchByImage(t *testing.T) { } // Test that VEX matchers produce matches when fed documents with "affected" - // statuses. + // or "under investigation" statuses. for n, tc := range map[string]struct { - vexStatus vex.Status + vexStatus vexStatus.Status vexDocuments []string }{ - "openvex-affected": {vex.StatusAffected, []string{"test-fixtures/vex/openvex/affected.openvex.json"}}, - "openvex-under_investigation": {vex.StatusUnderInvestigation, []string{"test-fixtures/vex/openvex/under_investigation.openvex.json"}}, + "csaf-affected": {vexStatus.Affected, []string{"test-fixtures/vex/csaf/affected.csaf.json"}}, + "csaf-under_investigation": {vexStatus.UnderInvestigation, []string{"test-fixtures/vex/csaf/under_investigation.csaf.json"}}, + "openvex-affected": {vexStatus.Affected, []string{"test-fixtures/vex/openvex/affected.openvex.json"}}, + "openvex-under_investigation": {vexStatus.UnderInvestigation, []string{"test-fixtures/vex/openvex/under_investigation.openvex.json"}}, } { t.Run(n, func(t *testing.T) { ignoredMatches := testIgnoredMatches() @@ -763,7 +766,7 @@ func testIgnoredMatches() []match.IgnoredMatch { { Match: match.Match{ Vulnerability: vulnerability.Vulnerability{ - ID: "CVE-alpine-libvncserver", + ID: "CVE-2024-0000", Namespace: "alpine:distro:alpine:3.12", }, Package: pkg.Package{ @@ -787,7 +790,7 @@ func testIgnoredMatches() []match.IgnoredMatch { }, Details: []match.Detail{ { - Type: "exact-indirect-match", + Type: match.ExactIndirectMatch, SearchedBy: map[string]any{ "distro": map[string]string{ "type": "alpine", @@ -801,7 +804,7 @@ func testIgnoredMatches() []match.IgnoredMatch { }, Found: map[string]any{ "versionConstraint": "< 0.9.10 (unknown)", - "vulnerabilityID": "CVE-alpine-libvncserver", + "vulnerabilityID": "CVE-2024-0000", }, Matcher: "apk-matcher", Confidence: 1, @@ -815,14 +818,17 @@ func testIgnoredMatches() []match.IgnoredMatch { // vexMatches moves the first match of a matches list to an ignore list and // applies a VEX "affected" document to it to move it to the matches list. -func vexMatches(t *testing.T, ignoredMatches []match.IgnoredMatch, vexStatus vex.Status, vexDocuments []string) match.Matches { +func vexMatches(t *testing.T, ignoredMatches []match.IgnoredMatch, vexStatus vexStatus.Status, vexDocuments []string) match.Matches { matches := match.NewMatches() - vexMatcher := vex.NewProcessor(vex.ProcessorOptions{ + vexMatcher, err := vex.NewProcessor(vex.ProcessorOptions{ Documents: vexDocuments, IgnoreRules: []match.IgnoreRule{ {VexStatus: string(vexStatus)}, }, }) + if err != nil { + t.Errorf("creating VEX processor: %s", err) + } pctx := &pkg.Context{ Source: &source.Description{ diff --git a/test/integration/test-fixtures/vex/csaf/affected.csaf.json b/test/integration/test-fixtures/vex/csaf/affected.csaf.json new file mode 100644 index 00000000000..33032e5a3d1 --- /dev/null +++ b/test/integration/test-fixtures/vex/csaf/affected.csaf.json @@ -0,0 +1,93 @@ +{ + "document": { + "category": "csaf_vex", + "csaf_version": "2.0", + "notes": [ + { + "category": "summary", + "text": "Example Company VEX document. Unofficial content for demonstration purposes only.", + "title": "Author comment" + } + ], + "publisher": { + "category": "vendor", + "name": "Example Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "title": "Example VEX Document", + "tracking": { + "current_release_date": "2024-04-25T11:00:00.000Z", + "generator": { + "date": "2024-04-25T11:00:00.000Z", + "engine": { + "name": "Secvisogram", + "version": "1.11.0" + } + }, + "id": "2022-EVD-UC-01-A-001", + "initial_release_date": "2024-04-25T11:00:00.000Z", + "revision_history": [ + { + "date": "2024-04-25T11:00:00.000Z", + "number": "1", + "summary": "Initial version." + } + ], + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "branches": [ + { + "category": "product_version", + "name": "0.9.9", + "product": { + "name": "LibVNCServer 0.9.9", + "product_id": "CSAFPID-0001", + "product_identification_helper": { + "purl": "pkg:apk/alpine/libvncserver@0.9.9?arch=x86_64&distro=alpine-3.12.0" + } + } + } + ], + "category": "product_name", + "name": "LibVNCServer" + } + ], + "category": "vendor", + "name": "Example Company" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2024-0000", + "notes": [ + { + "category": "description", + "text": "A CVE affecting libvncserver.", + "title": "CVE description" + } + ], + "product_status": { + "known_affected": [ + "CSAFPID-0001" + ] + }, + "remediations": [ + { + "category": "vendor_fix", + "details": "Customers should update to version 1.1 of product DEF which fixes the issue.", + "product_ids": [ + "CSAFPID-0001" + ] + } + ] + } + ] +} diff --git a/test/integration/test-fixtures/vex/csaf/under_investigation.csaf.json b/test/integration/test-fixtures/vex/csaf/under_investigation.csaf.json new file mode 100644 index 00000000000..0eb6b8bee48 --- /dev/null +++ b/test/integration/test-fixtures/vex/csaf/under_investigation.csaf.json @@ -0,0 +1,84 @@ +{ + "document": { + "category": "csaf_vex", + "csaf_version": "2.0", + "notes": [ + { + "category": "summary", + "text": "Example Company VEX document. Unofficial content for demonstration purposes only.", + "title": "Author comment" + } + ], + "publisher": { + "category": "vendor", + "name": "Example Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "title": "Example VEX Document", + "tracking": { + "current_release_date": "2024-04-25T11:00:00.000Z", + "generator": { + "date": "2024-04-25T11:00:00.000Z", + "engine": { + "name": "Secvisogram", + "version": "1.11.0" + } + }, + "id": "2022-EVD-UC-01-A-001", + "initial_release_date": "2024-04-25T11:00:00.000Z", + "revision_history": [ + { + "date": "2024-04-25T11:00:00.000Z", + "number": "1", + "summary": "Initial version." + } + ], + "status": "final", + "version": "1" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "branches": [ + { + "category": "product_version", + "name": "0.9.9", + "product": { + "name": "LibVNCServer 0.9.9", + "product_id": "CSAFPID-0001", + "product_identification_helper": { + "purl": "pkg:apk/alpine/libvncserver@0.9.9?arch=x86_64&distro=alpine-3.12.0" + } + } + } + ], + "category": "product_name", + "name": "LibVNCServer" + } + ], + "category": "vendor", + "name": "Example Company" + } + ] + }, + "vulnerabilities": [ + { + "cve": "CVE-2024-0000", + "notes": [ + { + "category": "description", + "text": "A CVE affecting libvncserver.", + "title": "CVE description" + } + ], + "product_status": { + "under_investigation": [ + "CSAFPID-0001" + ] + } + } + ] + } diff --git a/test/integration/test-fixtures/vex/openvex/affected.openvex.json b/test/integration/test-fixtures/vex/openvex/affected.openvex.json index 78b24f5b80b..5c654f804e8 100644 --- a/test/integration/test-fixtures/vex/openvex/affected.openvex.json +++ b/test/integration/test-fixtures/vex/openvex/affected.openvex.json @@ -7,7 +7,7 @@ "statements": [ { "vulnerability": { - "name": "CVE-alpine-libvncserver" + "name": "CVE-2024-0000" }, "products": [ { diff --git a/test/integration/test-fixtures/vex/openvex/under_investigation.openvex.json b/test/integration/test-fixtures/vex/openvex/under_investigation.openvex.json index f9e4c60e38e..052fa7f9272 100644 --- a/test/integration/test-fixtures/vex/openvex/under_investigation.openvex.json +++ b/test/integration/test-fixtures/vex/openvex/under_investigation.openvex.json @@ -8,7 +8,7 @@ { "timestamp": "2023-07-16T18:28:47.696004345-06:00", "vulnerability": { - "name": "CVE-alpine-libvncserver" + "name": "CVE-2024-0000" }, "products": [ {