Updated /api/v1/fleet/vulnerabilities/{cve} endpoint (#21463)

main task: #19857
subtask: #21392

- 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.
- Removed the previous known_vulnerability field implementation

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky
2024-08-21 12:52:28 -05:00
committed by GitHub
parent f00e985850
commit cd14831e44
6 changed files with 78 additions and 100 deletions
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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)
}
+1 -3
View File
@@ -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
+9 -40
View File
@@ -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)
+63 -53
View File
@@ -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 Fleets 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) {
+2 -2
View File
@@ -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)
})
}