diff --git a/changes/19857-known_vulnerability b/changes/19857-known_vulnerability index c72a25d149..b9c95991b3 100644 --- a/changes/19857-known_vulnerability +++ b/changes/19857-known_vulnerability @@ -1,2 +1,2 @@ -For GET /api/v1/fleet/vulnerabilities endpoint, added `known_vulnerability` field to the response. This field is present when query is a valid CVE format and returns no results. It indicates whether the vulnerability is in Fleet's DB. +For GET /api/v1/fleet/vulnerabilities/{cve} endpoint, added validation of CVE format, and added a 204 response. The 204 response indicates that the vulnerability is known to Fleet but not present on any hosts. For the UI, add new empty states for searching vulnerabilities: invalid CVE format searched, a known CVE serached but not present on hosts, not a known CVE searched, exploited vulnerability empty state, operating systems empty state, new icons diff --git a/ee/server/service/vulnerabilities.go b/ee/server/service/vulnerabilities.go index 1e5583e134..c94e14c356 100644 --- a/ee/server/service/vulnerabilities.go +++ b/ee/server/service/vulnerabilities.go @@ -21,6 +21,7 @@ func (svc *Service) ListVulnerabilities(ctx context.Context, opt fleet.VulnListO return svc.Service.ListVulnerabilities(ctx, opt) } -func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*fleet.VulnerabilityWithMetadata, error) { +func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (vuln *fleet.VulnerabilityWithMetadata, + known bool, err error) { return svc.Service.Vulnerability(ctx, cve, teamID, true) } diff --git a/server/fleet/service.go b/server/fleet/service.go index 1eb817f503..268f5553ab 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -661,15 +661,13 @@ type Service interface { // ListVulnerabilities returns a list of vulnerabilities based on the provided options. ListVulnerabilities(ctx context.Context, opt VulnListOptions) ([]VulnerabilityWithMetadata, *PaginationMetadata, error) // ListVulnerability returns a vulnerability based on the provided CVE. - Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*VulnerabilityWithMetadata, error) + Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (vuln *VulnerabilityWithMetadata, known bool, err error) // CountVulnerabilities returns the number of vulnerabilities based on the provided options. CountVulnerabilities(ctx context.Context, opt VulnListOptions) (uint, error) // ListOSVersionsByCVE returns a list of OS versions affected by the provided CVE. ListOSVersionsByCVE(ctx context.Context, cve string, teamID *uint) (result []*VulnerableOS, updatedAt time.Time, err error) // ListSoftwareByCVE returns a list of software affected by the provided CVE. ListSoftwareByCVE(ctx context.Context, cve string, teamID *uint) (result []*VulnerableSoftware, updatedAt time.Time, err error) - // IsCVEKnownToFleet returns whether the provided CVE is known to Fleet. - IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) // ///////////////////////////////////////////////////////////////////////////// // Team Policies diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 851d0c3970..4f2c89ae93 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8718,8 +8718,7 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.NoError(t, err) // insert CVEMeta - knownCVEWoPrefix := "2021-12999" - knownCVE := "cve-" + knownCVEWoPrefix + knownCVE := "cve-2021-12999" mockTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) err = s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{ { @@ -8767,7 +8766,6 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.Equal(t, resp.Count, uint(3)) require.False(t, resp.Meta.HasPreviousResults) require.False(t, resp.Meta.HasNextResults) - assert.Nil(t, resp.KnownVulnerability) expected := map[string]struct { fleet.CVEMeta @@ -8805,7 +8803,6 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.Equal(t, resp.Count, uint(2)) require.False(t, resp.Meta.HasPreviousResults) require.False(t, resp.Meta.HasNextResults) - assert.Nil(t, resp.KnownVulnerability) expected = map[string]struct { fleet.CVEMeta @@ -8839,7 +8836,6 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.Equal(t, resp.Count, uint(0)) require.False(t, resp.Meta.HasPreviousResults) require.False(t, resp.Meta.HasNextResults) - assert.Nil(t, resp.KnownVulnerability) // test with a known CVE that does not match on software/OS s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", knownCVE) @@ -8848,43 +8844,15 @@ func (s *integrationTestSuite) TestListVulnerabilities() { assert.Equal(t, resp.Count, uint(0)) assert.False(t, resp.Meta.HasPreviousResults) assert.False(t, resp.Meta.HasNextResults) - assert.Equal(t, ptr.Bool(true), resp.KnownVulnerability) - // test with a known CVE that does not match on software/OS, but without CVE- prefix - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", knownCVEWoPrefix) - require.Empty(t, resp.Err) - assert.Len(s.T(), resp.Vulnerabilities, 0) - assert.Equal(t, resp.Count, uint(0)) - assert.False(t, resp.Meta.HasPreviousResults) - assert.False(t, resp.Meta.HasNextResults) - assert.Equal(t, ptr.Bool(true), resp.KnownVulnerability) - - // test with a substring of a known CVE -- results are returned but the exact match is not known to Fleet + // test with a substring of a known CVE -- results are returned s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", "CVE-2021-1234") require.Empty(t, resp.Err) assert.Len(s.T(), resp.Vulnerabilities, 1) assert.Equal(t, resp.Count, uint(1)) assert.False(t, resp.Meta.HasPreviousResults) assert.False(t, resp.Meta.HasNextResults) - assert.Equal(t, ptr.Bool(false), resp.KnownVulnerability) - - // test with exact match of a known CVE -- results are returned and CVE is known to Fleet - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", "2021-12345") - require.Empty(t, resp.Err) - assert.Len(s.T(), resp.Vulnerabilities, 1) - assert.Equal(t, resp.Count, uint(1)) - assert.False(t, resp.Meta.HasPreviousResults) - assert.False(t, resp.Meta.HasNextResults) - assert.Equal(t, ptr.Bool(true), resp.KnownVulnerability) - - // test with a unknown CVE that does not match on software/OS - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", knownCVE+"1") - require.Empty(t, resp.Err) - assert.Len(s.T(), resp.Vulnerabilities, 0) - assert.Equal(t, resp.Count, uint(0)) - assert.False(t, resp.Meta.HasPreviousResults) - assert.False(t, resp.Meta.HasNextResults) - assert.Equal(t, ptr.Bool(false), resp.KnownVulnerability) + _ = s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusNotFound) // Team 1 Filter s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "team_id", "1") @@ -8933,19 +8901,20 @@ func (s *integrationTestSuite) TestListVulnerabilities() { var gResp getVulnerabilityResponse // invalid cve - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/foobar", nil, http.StatusNotFound, &gResp) + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/foobar", nil, http.StatusBadRequest, &gResp) // Valid CVE but not in team scope - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1246", nil, http.StatusNotFound, &gResp, "team_id", fmt.Sprintf("%d", team.ID)) + s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1246", nil, http.StatusNoContent, "team_id", + fmt.Sprintf("%d", team.ID)) // Valid CVE in "no team" scope s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1246", nil, http.StatusOK, &gResp, "team_id", "0") - // Valid CVD not in "no team" scope - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusNotFound, &gResp, "team_id", "0") + // Valid CVE not in "no team" scope + s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusNoContent, "team_id", "0") // Invalid TeamID - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusForbidden, &gResp, "team_id", "100") + s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusForbidden, "team_id", "100") // Valid Global Request s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusOK, &gResp) diff --git a/server/service/vulnerabilities.go b/server/service/vulnerabilities.go index 74b55610d1..841cca1c01 100644 --- a/server/service/vulnerabilities.go +++ b/server/service/vulnerabilities.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "net/http" "regexp" "time" @@ -18,21 +19,32 @@ var freeValidVulnSortColumns = []string{ "created_at", } +type cveNotFoundError struct{} + +var _ fleet.NotFoundError = (*cveNotFoundError)(nil) + +func (p cveNotFoundError) Error() string { + return "This is not known CVE. None of Fleet’s vulnerability sources are aware of this CVE." +} + +func (p cveNotFoundError) IsNotFound() bool { + return true +} + type listVulnerabilitiesRequest struct { fleet.VulnListOptions } type listVulnerabilitiesResponse struct { - Vulnerabilities []fleet.VulnerabilityWithMetadata `json:"vulnerabilities"` - Count uint `json:"count"` - CountsUpdatedAt time.Time `json:"counts_updated_at"` - Meta *fleet.PaginationMetadata `json:"meta,omitempty"` - Err error `json:"error,omitempty"` - KnownVulnerability *bool `json:"known_vulnerability,omitempty"` + Vulnerabilities []fleet.VulnerabilityWithMetadata `json:"vulnerabilities"` + Count uint `json:"count"` + CountsUpdatedAt time.Time `json:"counts_updated_at"` + Meta *fleet.PaginationMetadata `json:"meta,omitempty"` + Err error `json:"error,omitempty"` } -// Allow formats like: CVE-2017-12345, cve-2017-12345 or 2017-12345 -var cveRegex = regexp.MustCompile(`(?i)^(CVE-)?\d{4}-\d{4}\d*$`) +// Allow formats like: CVE-2017-12345, cve-2017-12345 +var cveRegex = regexp.MustCompile(`(?i)^CVE-\d{4}-\d{4}\d*$`) func (r listVulnerabilitiesResponse) error() error { return r.Err } @@ -55,42 +67,11 @@ func listVulnerabilitiesEndpoint(ctx context.Context, req interface{}, svc fleet } } - // Check whether the query was for a vulnerability known to fleet - var knownVulnerability *bool - if len(request.ListOptions.MatchQuery) > 0 { - query := request.ListOptions.MatchQuery - matches := cveRegex.FindStringSubmatch(query) - if matches != nil { - const cvePrefix = "CVE-" - if len(matches) > 1 && matches[1] == "" { - // If CVE prefix was missing, we add it - query = cvePrefix + query - } - // As an optimization, we first check if the CVE was one of the ones returned - // by the query. If it was, we already know it's known to Fleet. - var known bool - for _, vuln := range vulns { - if vuln.CVE.CVE == query { - known = true - break - } - } - if !known { - known, err = svc.IsCVEKnownToFleet(ctx, query) - if err != nil { - return listVulnerabilitiesResponse{Err: err}, nil - } - } - knownVulnerability = &known - } - } - return listVulnerabilitiesResponse{ - Vulnerabilities: vulns, - Meta: meta, - Count: count, - CountsUpdatedAt: updatedAt, - KnownVulnerability: knownVulnerability, + Vulnerabilities: vulns, + Meta: meta, + Count: count, + CountsUpdatedAt: updatedAt, }, nil } @@ -149,17 +130,29 @@ type getVulnerabilityResponse struct { OSVersions []*fleet.VulnerableOS `json:"os_versions"` Software []*fleet.VulnerableSoftware `json:"software"` Err error `json:"error,omitempty"` + statusCode int } func (r getVulnerabilityResponse) error() error { return r.Err } +func (r getVulnerabilityResponse) Status() int { + if r.statusCode == 0 { + return http.StatusOK + } + return r.statusCode +} + func getVulnerabilityEndpoint(ctx context.Context, req interface{}, svc fleet.Service) (errorer, error) { request := req.(*getVulnerabilityRequest) - vuln, err := svc.Vulnerability(ctx, request.CVE, request.TeamID, false) + vuln, known, err := svc.Vulnerability(ctx, request.CVE, request.TeamID, false) if err != nil { return getVulnerabilityResponse{Err: err}, nil } + if vuln == nil && known { + // Return 204 status code if the vulnerability is known to Fleet but does not match any host software/OS + return getVulnerabilityResponse{statusCode: http.StatusNoContent}, nil + } vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE.CVE) @@ -180,30 +173,47 @@ func getVulnerabilityEndpoint(ctx context.Context, req interface{}, svc fleet.Se }, nil } -func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*fleet.VulnerabilityWithMetadata, error) { +func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (vuln *fleet.VulnerabilityWithMetadata, + known bool, err error) { if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil { - return nil, err + return nil, false, err } if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionRead); err != nil { - return nil, err + return nil, false, err + } + + if !cveRegex.Match([]byte(cve)) { + return nil, false, badRequest("That vulnerability (CVE) is not valid. Try updating your search to use CVE format: \"CVE-YYYY-<4 or more digits>\"") } if teamID != nil && *teamID != 0 { exists, err := svc.ds.TeamExists(ctx, *teamID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "checking if team exists") + return nil, false, ctxerr.Wrap(ctx, err, "checking if team exists") } else if !exists { - return nil, authz.ForbiddenWithInternal("team does not exist", nil, nil, nil) + return nil, false, authz.ForbiddenWithInternal("team does not exist", nil, nil, nil) } } - vuln, err := svc.ds.Vulnerability(ctx, cve, teamID, useCVSScores) - if err != nil { - return nil, err + vuln, err = svc.ds.Vulnerability(ctx, cve, teamID, useCVSScores) + switch { + case fleet.IsNotFound(err): + var errKnown error + known, errKnown = svc.ds.IsCVEKnownToFleet(ctx, cve) + if errKnown != nil { + return nil, false, errKnown + } + if !known { + return nil, false, cveNotFoundError{} + } + case err != nil: + return nil, false, err + default: + known = true } - return vuln, nil + return vuln, known, nil } func (svc *Service) ListOSVersionsByCVE(ctx context.Context, cve string, teamID *uint) (result []*fleet.VulnerableOS, updatedAt time.Time, err error) { diff --git a/server/service/vulnerabilities_test.go b/server/service/vulnerabilities_test.go index 48b6303d98..fcef3ede9a 100644 --- a/server/service/vulnerabilities_test.go +++ b/server/service/vulnerabilities_test.go @@ -173,10 +173,10 @@ func TestVulnerabilitesAuth(t *testing.T) { }) checkAuthErr(t, tc.shouldFailTeamRead, err) - _, err = svc.Vulnerability(ctx, "CVE-2019-1234", nil, false) + _, _, err = svc.Vulnerability(ctx, "CVE-2019-1234", nil, false) checkAuthErr(t, tc.shouldFailGlobalRead, err) - _, err = svc.Vulnerability(ctx, "CVE-2019-1234", ptr.Uint(1), false) + _, _, err = svc.Vulnerability(ctx, "CVE-2019-1234", ptr.Uint(1), false) checkAuthErr(t, tc.shouldFailTeamRead, err) }) }