diff --git a/changes/19857-known_vulnerability b/changes/19857-known_vulnerability new file mode 100644 index 000000000000..080e384893aa --- /dev/null +++ b/changes/19857-known_vulnerability @@ -0,0 +1 @@ +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. diff --git a/server/datastore/mysql/vulnerabilities.go b/server/datastore/mysql/vulnerabilities.go index db63bac01b4c..81a26ea58b0e 100644 --- a/server/datastore/mysql/vulnerabilities.go +++ b/server/datastore/mysql/vulnerabilities.go @@ -3,6 +3,7 @@ package mysql import ( "context" "database/sql" + "errors" "fmt" "strings" "time" @@ -496,3 +497,12 @@ func (ds *Datastore) batchInsertHostCounts(ctx context.Context, counts []hostCou return nil } + +func (ds *Datastore) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) { + var count uint + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, "SELECT 1 FROM cve_meta WHERE cve = ?", cve) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err + } + return count > 0, nil +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 6847f398af47..17bca4100eb7 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -989,6 +989,8 @@ type Datastore interface { CountVulnerabilities(ctx context.Context, opt VulnListOptions) (uint, error) // UpdateVulnerabilityHostCounts updates hosts counts for all vulnerabilities. UpdateVulnerabilityHostCounts(ctx context.Context) error + // IsCVEKnownToFleet checks if the provided CVE is known to Fleet. + IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) /////////////////////////////////////////////////////////////////////////////// // Apple MDM diff --git a/server/fleet/service.go b/server/fleet/service.go index 0148eb416242..2343c6d4a7c3 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -668,6 +668,8 @@ type Service interface { 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/mock/datastore_mock.go b/server/mock/datastore_mock.go index e062476d6049..60a18a0e69c9 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -684,6 +684,8 @@ type CountVulnerabilitiesFunc func(ctx context.Context, opt fleet.VulnListOption type UpdateVulnerabilityHostCountsFunc func(ctx context.Context) error +type IsCVEKnownToFleetFunc func(ctx context.Context, cve string) (bool, error) + type NewMDMAppleConfigProfileFunc func(ctx context.Context, p fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) type BulkUpsertMDMAppleConfigProfilesFunc func(ctx context.Context, payload []*fleet.MDMAppleConfigProfile) error @@ -2007,6 +2009,9 @@ type DataStore struct { UpdateVulnerabilityHostCountsFunc UpdateVulnerabilityHostCountsFunc UpdateVulnerabilityHostCountsFuncInvoked bool + IsCVEKnownToFleetFunc IsCVEKnownToFleetFunc + IsCVEKnownToFleetFuncInvoked bool + NewMDMAppleConfigProfileFunc NewMDMAppleConfigProfileFunc NewMDMAppleConfigProfileFuncInvoked bool @@ -4823,6 +4828,13 @@ func (s *DataStore) UpdateVulnerabilityHostCounts(ctx context.Context) error { return s.UpdateVulnerabilityHostCountsFunc(ctx) } +func (s *DataStore) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) { + s.mu.Lock() + s.IsCVEKnownToFleetFuncInvoked = true + s.mu.Unlock() + return s.IsCVEKnownToFleetFunc(ctx, cve) +} + func (s *DataStore) NewMDMAppleConfigProfile(ctx context.Context, p fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { s.mu.Lock() s.NewMDMAppleConfigProfileFuncInvoked = true diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index e1a9a5150b21..96643cc718c8 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8662,6 +8662,8 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.NoError(t, err) // insert CVEMeta + knownCVEWoPrefix := "2021-1299" + knownCVE := "cve-" + knownCVEWoPrefix mockTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) err = s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{ { @@ -8688,6 +8690,14 @@ func (s *integrationTestSuite) TestListVulnerabilities() { Published: ptr.Time(mockTime), Description: "Test CVE 2021-1246", }, + { + CVE: knownCVE, + CVSSScore: ptr.Float64(6.4), + EPSSProbability: ptr.Float64(0.61), + CISAKnownExploit: ptr.Bool(true), + Published: ptr.Time(mockTime), + Description: fmt.Sprintf("Test %s", knownCVE), + }, }) require.NoError(t, err) @@ -8701,6 +8711,7 @@ 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 @@ -8738,6 +8749,7 @@ 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 @@ -8771,6 +8783,34 @@ 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) + 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 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 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) // Team 1 Filter s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "team_id", "1") diff --git a/server/service/vulnerabilities.go b/server/service/vulnerabilities.go index 62cfd3dcfeb6..e4085eb43d87 100644 --- a/server/service/vulnerabilities.go +++ b/server/service/vulnerabilities.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "regexp" "time" "github.com/fleetdm/fleet/v4/server/authz" @@ -22,13 +23,17 @@ type listVulnerabilitiesRequest struct { } 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"` + 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"` } +// Allow formats like: CVE-2017-12345, cve-2017-12345 or 2017-12345 +var cveRegex = regexp.MustCompile(`(?i)^(CVE-)?\d{4}-\d{4}\d*$`) + func (r listVulnerabilitiesResponse) error() error { return r.Err } func listVulnerabilitiesEndpoint(ctx context.Context, req interface{}, svc fleet.Service) (errorer, error) { @@ -50,11 +55,31 @@ func listVulnerabilitiesEndpoint(ctx context.Context, req interface{}, svc fleet } } + var knownVulnerability *bool + if len(vulns) == 0 && len(request.ListOptions.MatchQuery) > 0 { + // If no vulnerabilities are returned, we need to check if the query was for a vulnerability known to fleet + 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 + } + 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, + Vulnerabilities: vulns, + Meta: meta, + Count: count, + CountsUpdatedAt: updatedAt, + KnownVulnerability: knownVulnerability, }, nil } @@ -99,6 +124,10 @@ func (svc *Service) CountVulnerabilities(ctx context.Context, opts fleet.VulnLis return svc.ds.CountVulnerabilities(ctx, opts) } +func (svc *Service) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) { + return svc.ds.IsCVEKnownToFleet(ctx, cve) +} + type getVulnerabilityRequest struct { CVE string `url:"cve"` TeamID *uint `query:"team_id,optional"`