diff --git a/ee/server/service/vulnerabilities.go b/ee/server/service/vulnerabilities.go index 41f435b06f..0d2535a4b4 100644 --- a/ee/server/service/vulnerabilities.go +++ b/ee/server/service/vulnerabilities.go @@ -20,3 +20,7 @@ func (svc *Service) ListVulnerabilities(ctx context.Context, opt fleet.VulnListO opt.IsEE = true return svc.Service.ListVulnerabilities(ctx, opt) } + +func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*fleet.VulnerabilityWithMetadata, error) { + return svc.Service.Vulnerability(ctx, cve, teamID, true) +} diff --git a/server/datastore/mysql/vulnerabilities.go b/server/datastore/mysql/vulnerabilities.go index 26abc36a83..2dbfa0e771 100644 --- a/server/datastore/mysql/vulnerabilities.go +++ b/server/datastore/mysql/vulnerabilities.go @@ -2,14 +2,167 @@ package mysql import ( "context" + "database/sql" "fmt" "strings" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/jmoiron/sqlx" ) +func (ds *Datastore) Vulnerability(ctx context.Context, cve string, teamID *uint, includeCVEScores bool) (*fleet.VulnerabilityWithMetadata, error) { + var vuln fleet.VulnerabilityWithMetadata + + eeSelectStmt := ` + SELECT + vhc.cve, + MIN(COALESCE(osv.created_at, sc.created_at, NOW())) AS created_at, + COALESCE(osv.source, sc.source, 0) AS source, + cm.cvss_score, + cm.epss_probability, + cm.cisa_known_exploit, + cm.published, + COALESCE(cm.description, '') AS description, + vhc.host_count, + vhc.updated_at as host_count_updated_at + FROM + vulnerability_host_counts vhc + LEFT JOIN cve_meta cm ON cm.cve = vhc.cve + LEFT JOIN operating_system_vulnerabilities osv ON osv.cve = vhc.cve + LEFT JOIN software_cve sc ON sc.cve = vhc.cve + WHERE vhc.cve = ? + ` + eeGroupBy := " GROUP BY vhc.cve, source, cm.cvss_score, cm.epss_probability, cm.cisa_known_exploit, cm.published, description, vhc.host_count, host_count_updated_at" + + freeSelectStmt := ` + SELECT + vhc.cve, + MIN(COALESCE(osv.created_at, sc.created_at, NOW())) AS created_at, + COALESCE(osv.source, sc.source, 0) AS source, + vhc.host_count, + vhc.updated_at as host_count_updated_at + FROM + vulnerability_host_counts vhc + LEFT JOIN operating_system_vulnerabilities osv ON osv.cve = vhc.cve + LEFT JOIN software_cve sc ON sc.cve = vhc.cve + WHERE vhc.cve = ? + ` + + freeGroupBy := " GROUP BY vhc.cve, source, vhc.host_count, host_count_updated_at" + + var args []interface{} + args = append(args, cve) + + if teamID != nil { + eeSelectStmt += " AND vhc.team_id = ?" + freeSelectStmt += " AND vhc.team_id = ?" + args = append(args, *teamID) + } else { + eeSelectStmt += " AND vhc.team_id = 0" + freeSelectStmt += " AND vhc.team_id = 0" + } + + eeSelectStmt += eeGroupBy + freeSelectStmt += freeGroupBy + + var selectStmt string + if includeCVEScores { + selectStmt = eeSelectStmt + } else { + selectStmt = freeSelectStmt + } + + err := sqlx.GetContext(ctx, ds.reader(ctx), &vuln, selectStmt, args...) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("Vulnerability").WithName(cve)) + } + return nil, ctxerr.Wrap(ctx, err, "fetching vulnerability") + } + return &vuln, nil +} + +func (ds *Datastore) OSVersionsByCVE(ctx context.Context, cve string, teamID *uint) (vos []*fleet.VulnerableOS, updatedAt time.Time, err error) { + osvs, err := ds.OSVersions(ctx, teamID, nil, nil, nil) + if err != nil { + return nil, updatedAt, ctxerr.Wrap(ctx, err, "fetching OS versions by CVE") + } + + updatedAt = osvs.CountsUpdatedAt + + var osVersionWithResolved []struct { + OSVersionID uint `db:"os_version_id"` + ResolvedVersion *string `db:"resolved_in_version"` + } + + selectStmt := ` + SELECT os.os_version_id, osv.resolved_in_version + FROM operating_system_vulnerabilities osv + JOIN operating_systems os ON os.id = osv.operating_system_id + WHERE osv.cve = ? + ` + err = sqlx.SelectContext(ctx, ds.reader(ctx), &osVersionWithResolved, selectStmt, cve) + if err != nil { + if err == sql.ErrNoRows { + return nil, updatedAt, ctxerr.Wrap(ctx, notFound("Vulnerability").WithName(cve)) + } + return vos, updatedAt, ctxerr.Wrap(ctx, err, "fetching OS versions by CVE") + } + + for _, osv := range osvs.OSVersions { + for _, id := range osVersionWithResolved { + if osv.OSVersionID == id.OSVersionID { + vos = append(vos, &fleet.VulnerableOS{ + OSVersion: osv, + ResolvedInVersion: id.ResolvedVersion, + }) + } + } + } + + return +} + +func (ds *Datastore) SoftwareByCVE(ctx context.Context, cve string, teamID *uint) (vs []*fleet.VulnerableSoftware, updatedAt time.Time, err error) { + var args []interface{} + selectStmt := ` + SELECT + s.id, + s.name, + s.version, + s.source, + s.browser, + COALESCE(scpe.cpe, '') as generated_cpe, + COALESCE(shc.hosts_count, 0) as hosts_count, + COALESCE(sc.resolved_in_version, '') as resolved_in_version + FROM software s + JOIN software_cve sc ON sc.software_id = s.id + LEFT JOIN software_cpe scpe ON scpe.software_id = s.id + LEFT JOIN software_host_counts shc ON shc.software_id = s.id + WHERE sc.cve = ? + ` + args = append(args, cve) + + if teamID != nil { + selectStmt += " AND shc.team_id = ?" + args = append(args, *teamID) + } else { + selectStmt += " AND shc.team_id = 0" + } + + err = sqlx.SelectContext(ctx, ds.reader(ctx), &vs, selectStmt, args...) + if err != nil { + if err == sql.ErrNoRows { + return nil, updatedAt, ctxerr.Wrap(ctx, notFound("Vulnerability").WithName(cve)) + } + return vs, updatedAt, ctxerr.Wrap(ctx, err, "fetching software by CVE") + } + + return +} + func (ds *Datastore) ListVulnerabilities(ctx context.Context, opt fleet.VulnListOptions) ([]fleet.VulnerabilityWithMetadata, *fleet.PaginationMetadata, error) { // Define base select statements for EE and Free versions eeSelectStmt := ` @@ -62,9 +215,10 @@ func (ds *Datastore) ListVulnerabilities(ctx context.Context, opt fleet.VulnList cm.cisa_known_exploit, cm.published, description, - vhc.host_count + vhc.host_count, + host_count_updated_at ` - freeGroupBy := " GROUP BY vhc.cve, source, vhc.host_count" + freeGroupBy := " GROUP BY vhc.cve, source, vhc.host_count, host_count_updated_at" // Choose the appropriate group by statement based on EE or Free var groupBy string @@ -91,14 +245,6 @@ func (ds *Datastore) ListVulnerabilities(ctx context.Context, opt fleet.VulnList selectStmt, args = searchLike(selectStmt, args, match, "vhc.cve") } - if opt.KnownExploit { - selectStmt = selectStmt + " AND cm.cisa_known_exploit = 1" - } - - if match := opt.MatchQuery; match != "" { - selectStmt, args = searchLike(selectStmt, args, match, "vhc.cve") - } - // Append group by statement selectStmt += groupBy diff --git a/server/datastore/mysql/vulnerabilities_test.go b/server/datastore/mysql/vulnerabilities_test.go index 34954273f7..1edb70a9ea 100644 --- a/server/datastore/mysql/vulnerabilities_test.go +++ b/server/datastore/mysql/vulnerabilities_test.go @@ -20,6 +20,10 @@ func TestVulnerabilities(t *testing.T) { fn func(t *testing.T, ds *Datastore) }{ {"TestListVulnerabilities", testListVulnerabilities}, + {"TestVulnerabilityWithOS", testVulnerabilityWithOS}, + {"TestVulnerabilityWithSoftware", testVulnerabilityWithSoftware}, + {"TestOSVersionsByCVE", testOSVersionsByCVE}, + {"TestSoftwareByCVE", testSoftwareByCVE}, {"TestVulnerabilitiesPagination", testVulnerabilitiesPagination}, {"TestVulnerabilitiesTeamFilter", testVulnerabilitiesTeamFilter}, {"TestListVulnerabilitiesSort", testListVulnerabilitiesSort}, @@ -157,6 +161,169 @@ func testListVulnerabilities(t *testing.T, ds *Datastore) { } } +func testVulnerabilityWithOS(t *testing.T, ds *Datastore) { + mockTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + ctx := context.Background() + + v, err := ds.Vulnerability(ctx, "CVE-2020-1234", nil, false) + require.Nil(t, v) + require.Error(t, err) + var nfe *notFoundError + require.ErrorAs(t, err, &nfe) + + // Insert Host Count + insertStmt := ` + INSERT INTO vulnerability_host_counts (cve, team_id, host_count) + VALUES (?, ?, ?), (?, ?, ?) + ` + _, err = ds.writer(context.Background()).Exec(insertStmt, + "CVE-2020-1234", 0, 10, + "CVE-2020-1234", 1, 4, + ) + require.NoError(t, err) + + // // insert OS Vuln + _, err = ds.InsertOSVulnerabilities(context.Background(), []fleet.OSVulnerability{ + { + OSID: 1, + CVE: "CVE-2020-1234", + ResolvedInVersion: ptr.String("1.0.0"), + }, + }, fleet.MSRCSource) + require.NoError(t, err) + + // // insert CVEMeta + err = ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{ + { + CVE: "CVE-2020-1234", + CVSSScore: ptr.Float64(7.5), + EPSSProbability: ptr.Float64(0.5), + CISAKnownExploit: ptr.Bool(true), + Published: ptr.Time(mockTime), + Description: "Test CVE 2020-1234", + }, + }) + require.NoError(t, err) + + expected := fleet.VulnerabilityWithMetadata{ + CVEMeta: fleet.CVEMeta{ + CVE: "CVE-2020-1234", + }, + HostCount: 10, + Source: fleet.MSRCSource, + } + + // No CVSSScores + v, err = ds.Vulnerability(ctx, "CVE-2020-1234", nil, false) + require.NoError(t, err) + require.Equal(t, expected.CVEMeta, v.CVEMeta) + require.Equal(t, expected.HostCount, v.HostCount) + require.Equal(t, expected.Source, v.Source) + + // Team 1 + expected.HostCount = 4 + v, err = ds.Vulnerability(ctx, "CVE-2020-1234", ptr.Uint(1), false) + require.NoError(t, err) + require.Equal(t, expected.CVEMeta, v.CVEMeta) + require.Equal(t, expected.HostCount, v.HostCount) + require.Equal(t, expected.Source, v.Source) + + expected = fleet.VulnerabilityWithMetadata{ + CVEMeta: fleet.CVEMeta{ + CVE: "CVE-2020-1234", + CVSSScore: ptr.Float64(7.5), + EPSSProbability: ptr.Float64(0.5), + CISAKnownExploit: ptr.Bool(true), + Published: ptr.Time(mockTime), + Description: "Test CVE 2020-1234", + }, + HostCount: 10, + Source: fleet.MSRCSource, + } + + // With CVSSScores + v, err = ds.Vulnerability(ctx, "CVE-2020-1234", nil, true) + require.NoError(t, err) + require.Equal(t, expected.CVEMeta, v.CVEMeta) + require.Equal(t, expected.HostCount, v.HostCount) + require.Equal(t, expected.Source, v.Source) +} + +func testVulnerabilityWithSoftware(t *testing.T, ds *Datastore) { + mockTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + ctx := context.Background() + + v, err := ds.Vulnerability(ctx, "CVE-2020-1234", nil, false) + require.Nil(t, v) + require.Error(t, err) + var nfe *notFoundError + require.ErrorAs(t, err, &nfe) + + // Insert Host Count + insertStmt := ` + INSERT INTO vulnerability_host_counts (cve, team_id, host_count) + VALUES (?, ?, ?) + ` + + _, err = ds.writer(context.Background()).Exec(insertStmt, "CVE-2020-1234", 0, 10) + require.NoError(t, err) + + // insert Software Vuln + _, err = ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{ + SoftwareID: 1, + CVE: "CVE-2020-1234", + }, fleet.NVDSource) + require.NoError(t, err) + + // insert CVEMeta + err = ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{ + { + CVE: "CVE-2020-1234", + CVSSScore: ptr.Float64(7.5), + EPSSProbability: ptr.Float64(0.5), + CISAKnownExploit: ptr.Bool(true), + Published: ptr.Time(mockTime), + Description: "Test CVE 2020-1234", + }, + }) + require.NoError(t, err) + + // No CVSSScores + expected := fleet.VulnerabilityWithMetadata{ + CVEMeta: fleet.CVEMeta{ + CVE: "CVE-2020-1234", + }, + HostCount: 10, + Source: fleet.NVDSource, + } + + v, err = ds.Vulnerability(ctx, "CVE-2020-1234", nil, false) + require.NoError(t, err) + require.Equal(t, expected.CVEMeta, v.CVEMeta) + require.Equal(t, expected.HostCount, v.HostCount) + require.Equal(t, expected.Source, v.Source) + + // With CVSSScores + expected = fleet.VulnerabilityWithMetadata{ + CVEMeta: fleet.CVEMeta{ + CVE: "CVE-2020-1234", + CVSSScore: ptr.Float64(7.5), + EPSSProbability: ptr.Float64(0.5), + CISAKnownExploit: ptr.Bool(true), + Published: ptr.Time(mockTime), + Description: "Test CVE 2020-1234", + }, + HostCount: 10, + Source: fleet.NVDSource, + } + + v, err = ds.Vulnerability(ctx, "CVE-2020-1234", nil, true) + require.NoError(t, err) + require.Equal(t, expected.CVEMeta, v.CVEMeta) + require.Equal(t, expected.HostCount, v.HostCount) + require.Equal(t, expected.Source, v.Source) +} + func testVulnerabilitiesPagination(t *testing.T, ds *Datastore) { seedVulnerabilities(t, ds) @@ -629,6 +796,80 @@ func testVulnerabilityHostCountBatchInserts(t *testing.T, ds *Datastore) { } } +func testOSVersionsByCVE(t *testing.T, ds *Datastore) { + seedVulnerabilities(t, ds) + + // global + osv, _, err := ds.OSVersionsByCVE(context.Background(), "CVE-2020-1238", nil) + require.NoError(t, err) + + expected := []fleet.VulnerableOS{ + { + OSVersion: fleet.OSVersion{ + Name: "Microsoft Windows 11 Enterprise 22H2 10.0.22621.2715", + NameOnly: "Microsoft Windows 11 Enterprise 22H2", + OSVersionID: 1, + Version: "10.0.22621.2715", + Platform: "windows", + HostsCount: 10, + }, + ResolvedInVersion: ptr.String("1.0.0"), + }, + } + + require.Len(t, osv, 1) + require.Equal(t, osv[0].OSVersion, expected[0].OSVersion) + + // team 1 + expected[0].OSVersion.HostsCount = 4 + osv, _, err = ds.OSVersionsByCVE(context.Background(), "CVE-2020-1238", ptr.Uint(1)) + require.NoError(t, err) + require.Len(t, osv, 1) + require.Equal(t, osv[0].OSVersion, expected[0].OSVersion) + + // team 2 + expected[0].OSVersion.HostsCount = 3 + osv, _, err = ds.OSVersionsByCVE(context.Background(), "CVE-2020-1238", ptr.Uint(2)) + require.NoError(t, err) + require.Len(t, osv, 1) + require.Equal(t, osv[0].OSVersion, expected[0].OSVersion) +} + +func testSoftwareByCVE(t *testing.T, ds *Datastore) { + seedVulnerabilities(t, ds) + + // global + software, _, err := ds.SoftwareByCVE(context.Background(), "CVE-2020-1234", nil) + require.NoError(t, err) + + expected := &fleet.VulnerableSoftware{ + ID: 1, + Name: "Chrome", + Version: "1.0.0", + Source: "programs", + HostsCount: 5, + GenerateCPE: "cpe:2.3:a:google:chrome:1.0.0:*:*:*:*:*:*:*:*", + ResolvedInVersion: ptr.String("1.0.0"), + } + + require.Len(t, software, 1) + require.Equal(t, expected, software[0]) + + // team 1 + expected.HostsCount = 4 + software, _, err = ds.SoftwareByCVE(context.Background(), "CVE-2020-1234", ptr.Uint(1)) + require.NoError(t, err) + require.Len(t, software, 1) + require.Equal(t, expected, software[0]) + + // team 2 + expected.HostsCount = 1 + software, _, err = ds.SoftwareByCVE(context.Background(), "CVE-2020-1234", ptr.Uint(2)) + require.NoError(t, err) + require.Len(t, software, 1) + require.Equal(t, expected, software[0]) +} + func assertHostCounts(t *testing.T, expected []hostCount, actual []fleet.VulnerabilityWithMetadata) { t.Helper() require.Len(t, actual, len(expected)) @@ -639,6 +880,88 @@ func assertHostCounts(t *testing.T, expected []hostCount, actual []fleet.Vulnera } func seedVulnerabilities(t *testing.T, ds *Datastore) { + // insert 20 hosts + var hostids []uint + for i := 0; i < 20; i++ { + host := test.NewHost(t, ds, fmt.Sprintf("host%d", i), fmt.Sprintf("192.168.0.%d", i), fmt.Sprintf("%d", i+1000), fmt.Sprintf("%d", i+1000), time.Now()) + hostids = append(hostids, host.ID) + } + + // update 15 hosts to windows + for i := 0; i < 10; i++ { + err := ds.UpdateHostOperatingSystem(context.Background(), hostids[i], fleet.OperatingSystem{ + Name: "Microsoft Windows 11 Enterprise 22H2", + Version: "10.0.22621.2715", + Arch: "x86_64", + Platform: "windows", + }) + require.NoError(t, err) + } + + // update 5 hosts to macOS + for i := 10; i < 15; i++ { + err := ds.UpdateHostOperatingSystem(context.Background(), hostids[i], fleet.OperatingSystem{ + Name: "macOS", + Version: "14.1.2", + Arch: "arm64", + Platform: "darwin", + }) + require.NoError(t, err) + } + + // move 4 windows hosts to team 1 + team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(context.Background(), &team1.ID, hostids[:4]) + require.NoError(t, err) + + // move 3 windows hosts to team 2 + team2, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team2"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(context.Background(), &team2.ID, hostids[4:7]) + require.NoError(t, err) + + // move 1 macOS host to team 2 + err = ds.AddHostsToTeam(context.Background(), &team2.ID, []uint{hostids[10]}) + require.NoError(t, err) + + err = ds.UpdateOSVersions(context.Background()) + require.NoError(t, err) + + // State: + // 10 global windows hosts + // 5 global macOS hosts + // 4 windows hosts in team 1 + // 3 windows hosts in team 2 + // 1 macOS host in team 2 + + // add software to 5 windows hosts + // affects: + // 5 global windows hosts + // 4 windows hosts in team 1 + // 1 windows host in team 2 + for i := 0; i < 5; i++ { + _, err = ds.UpdateHostSoftware(context.Background(), hostids[i], []fleet.Software{ + { + Name: "Chrome", + Version: "1.0.0", + Source: "programs", + }, + }) + require.NoError(t, err) + } + + _, err = ds.UpsertSoftwareCPEs(context.Background(), []fleet.SoftwareCPE{ + { + SoftwareID: 1, + CPE: "cpe:2.3:a:google:chrome:1.0.0:*:*:*:*:*:*:*:*", + }, + }) + require.NoError(t, err) + + err = ds.SyncHostsSoftware(context.Background(), time.Now()) + require.NoError(t, err) + softwareVulns := []fleet.SoftwareVulnerability{ { SoftwareID: 1, @@ -817,7 +1140,7 @@ func seedVulnerabilities(t *testing.T, ds *Datastore) { } // Insert OS Vuln - _, err := ds.InsertOSVulnerabilities(context.Background(), osVulns, fleet.NVDSource) + _, err = ds.InsertOSVulnerabilities(context.Background(), osVulns, fleet.NVDSource) require.NoError(t, err) // Insert Software Vuln diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 8086c9b42e..47c3a86e85 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -310,6 +310,8 @@ type Datastore interface { GetMDMSolution(ctx context.Context, mdmID uint) (*MDMSolution, error) OSVersions(ctx context.Context, teamID *uint, platform *string, name *string, version *string) (*OSVersions, error) + OSVersionsByCVE(ctx context.Context, cve string, teamID *uint) ([]*VulnerableOS, time.Time, error) + SoftwareByCVE(ctx context.Context, cve string, teamID *uint) ([]*VulnerableSoftware, time.Time, error) OSVersion(ctx context.Context, osVersionID uint, teamID *uint) (*OSVersion, *time.Time, error) UpdateOSVersions(ctx context.Context) error @@ -857,6 +859,8 @@ type Datastore interface { // ListVulnerabilities returns a list of unique vulnerabilities based on the provided options. ListVulnerabilities(ctx context.Context, opt VulnListOptions) ([]VulnerabilityWithMetadata, *PaginationMetadata, error) + // Vulnerability returns the vulnerability corresponding to the specified CVE ID + Vulnerability(ctx context.Context, cve string, teamID *uint, includeCVEScores bool) (*VulnerabilityWithMetadata, error) // CountVulnerabilities returns the number of unique vulnerabilities based on the provided // options. CountVulnerabilities(ctx context.Context, opt VulnListOptions) (uint, error) diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index bdb6dc2259..dd709ec7c8 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1125,6 +1125,11 @@ type OSVersions struct { OSVersions []OSVersion `json:"os_versions"` } +type VulnerableOS struct { + OSVersion + ResolvedInVersion *string `json:"resolved_in_version"` +} + type OSVersion struct { // ID is the unique id of the operating system. ID uint `json:"id,omitempty"` diff --git a/server/fleet/service.go b/server/fleet/service.go index 1cf71ddea0..6a1ff8420e 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -610,8 +610,14 @@ 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) // 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) // ///////////////////////////////////////////////////////////////////////////// // Team Policies diff --git a/server/fleet/software.go b/server/fleet/software.go index 81a7b0031f..9b15624920 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -103,6 +103,17 @@ func (s Software) ToUniqueStr() string { return strings.Join(ss, SoftwareFieldSeparator) } +type VulnerableSoftware struct { + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Version string `json:"version" db:"version"` + Source string `json:"source" db:"source"` + Browser string `json:"browser" db:"browser"` + GenerateCPE string `json:"generated_cpe" db:"generated_cpe"` + HostsCount int `json:"hosts_count,omitempty" db:"hosts_count"` + ResolvedInVersion *string `json:"resolved_in_version" db:"resolved_in_version"` +} + type SliceString []string func (c *SliceString) Scan(v interface{}) error { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 3b989bd3c1..832ee6ce26 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -244,6 +244,10 @@ type GetMDMSolutionFunc func(ctx context.Context, mdmID uint) (*fleet.MDMSolutio type OSVersionsFunc func(ctx context.Context, teamID *uint, platform *string, name *string, version *string) (*fleet.OSVersions, error) +type OSVersionsByCVEFunc func(ctx context.Context, cve string, teamID *uint) ([]*fleet.VulnerableOS, time.Time, error) + +type SoftwareByCVEFunc func(ctx context.Context, cve string, teamID *uint) ([]*fleet.VulnerableSoftware, time.Time, error) + type OSVersionFunc func(ctx context.Context, osVersionID uint, teamID *uint) (*fleet.OSVersion, *time.Time, error) type UpdateOSVersionsFunc func(ctx context.Context) error @@ -594,6 +598,8 @@ type DeleteOutOfDateOSVulnerabilitiesFunc func(ctx context.Context, source fleet type ListVulnerabilitiesFunc func(ctx context.Context, opt fleet.VulnListOptions) ([]fleet.VulnerabilityWithMetadata, *fleet.PaginationMetadata, error) +type VulnerabilityFunc func(ctx context.Context, cve string, teamID *uint, includeCVEScores bool) (*fleet.VulnerabilityWithMetadata, error) + type CountVulnerabilitiesFunc func(ctx context.Context, opt fleet.VulnListOptions) (uint, error) type UpdateVulnerabilityHostCountsFunc func(ctx context.Context) error @@ -1166,6 +1172,12 @@ type DataStore struct { OSVersionsFunc OSVersionsFunc OSVersionsFuncInvoked bool + OSVersionsByCVEFunc OSVersionsByCVEFunc + OSVersionsByCVEFuncInvoked bool + + SoftwareByCVEFunc SoftwareByCVEFunc + SoftwareByCVEFuncInvoked bool + OSVersionFunc OSVersionFunc OSVersionFuncInvoked bool @@ -1691,6 +1703,9 @@ type DataStore struct { ListVulnerabilitiesFunc ListVulnerabilitiesFunc ListVulnerabilitiesFuncInvoked bool + VulnerabilityFunc VulnerabilityFunc + VulnerabilityFuncInvoked bool + CountVulnerabilitiesFunc CountVulnerabilitiesFunc CountVulnerabilitiesFuncInvoked bool @@ -2833,6 +2848,20 @@ func (s *DataStore) OSVersions(ctx context.Context, teamID *uint, platform *stri return s.OSVersionsFunc(ctx, teamID, platform, name, version) } +func (s *DataStore) OSVersionsByCVE(ctx context.Context, cve string, teamID *uint) ([]*fleet.VulnerableOS, time.Time, error) { + s.mu.Lock() + s.OSVersionsByCVEFuncInvoked = true + s.mu.Unlock() + return s.OSVersionsByCVEFunc(ctx, cve, teamID) +} + +func (s *DataStore) SoftwareByCVE(ctx context.Context, cve string, teamID *uint) ([]*fleet.VulnerableSoftware, time.Time, error) { + s.mu.Lock() + s.SoftwareByCVEFuncInvoked = true + s.mu.Unlock() + return s.SoftwareByCVEFunc(ctx, cve, teamID) +} + func (s *DataStore) OSVersion(ctx context.Context, osVersionID uint, teamID *uint) (*fleet.OSVersion, *time.Time, error) { s.mu.Lock() s.OSVersionFuncInvoked = true @@ -4058,6 +4087,13 @@ func (s *DataStore) ListVulnerabilities(ctx context.Context, opt fleet.VulnListO return s.ListVulnerabilitiesFunc(ctx, opt) } +func (s *DataStore) Vulnerability(ctx context.Context, cve string, teamID *uint, includeCVEScores bool) (*fleet.VulnerabilityWithMetadata, error) { + s.mu.Lock() + s.VulnerabilityFuncInvoked = true + s.mu.Unlock() + return s.VulnerabilityFunc(ctx, cve, teamID, includeCVEScores) +} + func (s *DataStore) CountVulnerabilities(ctx context.Context, opt fleet.VulnListOptions) (uint, error) { s.mu.Lock() s.CountVulnerabilitiesFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 5947a61b89..6c917312dc 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -373,6 +373,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Vulnerabilities ue.GET("/api/_version_/fleet/vulnerabilities", listVulnerabilitiesEndpoint, listVulnerabilitiesRequest{}) + ue.GET("/api/_version_/fleet/vulnerabilities/{cve}", getVulnerabilityEndpoint, getVulnerabilityRequest{}) // Hosts ue.GET("/api/_version_/fleet/host_summary", getHostSummaryEndpoint, getHostSummaryRequest{}) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 40aaa2211e..e64d0a6fd0 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -7400,9 +7400,8 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.NoError(t, err) err = s.ds.UpdateHostOperatingSystem(context.Background(), host.ID, fleet.OperatingSystem{ - Name: "windows", + Name: "Windows 11 Enterprise 22H2", Version: "10.0.19042.1234", - Arch: "64bit", Platform: "windows", }) require.NoError(t, err) @@ -7415,18 +7414,33 @@ func (s *integrationTestSuite) TestListVulnerabilities() { } } + err = s.ds.UpdateOSVersions(context.Background()) + require.NoError(t, err) + _, err = s.ds.InsertOSVulnerability(context.Background(), fleet.OSVulnerability{ - OSID: os.ID, - CVE: "CVE-2021-1234", + OSID: os.ID, + CVE: "CVE-2021-1234", + ResolvedInVersion: *ptr.StringPtr("10.0.19043.2013"), }, fleet.MSRCSource) require.NoError(t, err) res, err := s.ds.UpdateHostSoftware(context.Background(), host.ID, []fleet.Software{ - {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "Google Chrome", Version: "0.0.1", Source: "programs"}, }) require.NoError(t, err) sw := res.Inserted[0] + _, err = s.ds.UpsertSoftwareCPEs(context.Background(), []fleet.SoftwareCPE{ + { + SoftwareID: sw.ID, + CPE: "cpe:2.3:a:google:chrome:1.0.0:*:*:*:*:*:*:*:*", + }, + }) + require.NoError(t, err) + + err = s.ds.SyncHostsSoftware(context.Background(), time.Now()) + require.NoError(t, err) + _, err = s.ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{ SoftwareID: sw.ID, CVE: "CVE-2021-1235", @@ -7515,6 +7529,42 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.Equal(t, expectedVuln.DetailsLink, vuln.DetailsLink) require.Empty(t, vuln.CVSSScore) } + + var gResp getVulnerabilityResponse + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusOK, &gResp) + require.Empty(t, gResp.Err) + require.Equal(t, "CVE-2021-1234", gResp.Vulnerability.CVE) + require.Equal(t, uint(1), gResp.Vulnerability.HostCount) + require.Equal(t, "https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2021-1234", gResp.Vulnerability.DetailsLink) + require.Empty(t, gResp.Vulnerability.Description) + require.Empty(t, gResp.Vulnerability.CVSSScore) + require.Empty(t, gResp.Vulnerability.CISAKnownExploit) + require.Empty(t, gResp.Vulnerability.EPSSProbability) + require.Empty(t, gResp.Vulnerability.Published) + require.Len(t, gResp.OSVersions, 1) + require.Equal(t, "Windows 11 Enterprise 22H2 10.0.19042.1234", gResp.OSVersions[0].Name) + require.Equal(t, "Windows 11 Enterprise 22H2", gResp.OSVersions[0].NameOnly) + require.Equal(t, "windows", gResp.OSVersions[0].Platform) + require.Equal(t, "10.0.19042.1234", gResp.OSVersions[0].Version) + require.Equal(t, 1, gResp.OSVersions[0].HostsCount) + require.Equal(t, "10.0.19043.2013", *gResp.OSVersions[0].ResolvedInVersion) + + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1235", nil, http.StatusOK, &gResp) + require.Empty(t, gResp.Err) + require.Equal(t, "CVE-2021-1235", gResp.Vulnerability.CVE) + require.Equal(t, uint(1), gResp.Vulnerability.HostCount) + require.Equal(t, "https://nvd.nist.gov/vuln/detail/CVE-2021-1235", gResp.Vulnerability.DetailsLink) + require.Empty(t, gResp.Vulnerability.Description) + require.Empty(t, gResp.Vulnerability.CVSSScore) + require.Empty(t, gResp.Vulnerability.CISAKnownExploit) + require.Empty(t, gResp.Vulnerability.EPSSProbability) + require.Empty(t, gResp.Vulnerability.Published) + require.Len(t, gResp.Software, 1) + require.Equal(t, "Google Chrome", gResp.Software[0].Name) + require.Equal(t, "0.0.1", gResp.Software[0].Version) + require.Equal(t, "programs", gResp.Software[0].Source) + require.Equal(t, "cpe:2.3:a:google:chrome:1.0.0:*:*:*:*:*:*:*:*", gResp.Software[0].GenerateCPE) + require.Equal(t, 1, gResp.Software[0].HostsCount) } func (s *integrationTestSuite) TestOSVersions() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index c9b8a7608f..000a63b43f 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -3163,9 +3163,8 @@ func (s *integrationEnterpriseTestSuite) TestListVulnerabilities() { require.NoError(t, err) err = s.ds.UpdateHostOperatingSystem(context.Background(), host.ID, fleet.OperatingSystem{ - Name: "windows", + Name: "Windows 11 Enterprise 22H2", Version: "10.0.19042.1234", - Arch: "64bit", Platform: "windows", }) require.NoError(t, err) @@ -3178,9 +3177,13 @@ func (s *integrationEnterpriseTestSuite) TestListVulnerabilities() { } } + err = s.ds.UpdateOSVersions(context.Background()) + require.NoError(t, err) + _, err = s.ds.InsertOSVulnerability(context.Background(), fleet.OSVulnerability{ - OSID: os.ID, - CVE: "CVE-2021-1234", + OSID: os.ID, + CVE: "CVE-2021-1234", + ResolvedInVersion: ptr.String("10.0.19043.2013"), }, fleet.MSRCSource) require.NoError(t, err) @@ -3299,6 +3302,25 @@ func (s *integrationEnterpriseTestSuite) TestListVulnerabilities() { require.Equal(t, expectedVuln.DetailsLink, vuln.DetailsLink) require.Equal(t, expectedVuln.CVEMeta, vuln.CVEMeta) } + + var gResp getVulnerabilityResponse + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusOK, &gResp) + require.Empty(t, gResp.Err) + require.Equal(t, "CVE-2021-1234", gResp.Vulnerability.CVE) + require.Equal(t, uint(1), gResp.Vulnerability.HostCount) + require.Equal(t, "https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2021-1234", gResp.Vulnerability.DetailsLink) + require.Equal(t, "Test CVE 2021-1234", gResp.Vulnerability.Description) + require.Equal(t, ptr.Float64(7.5), gResp.Vulnerability.CVSSScore) + require.Equal(t, ptr.Bool(true), gResp.Vulnerability.CISAKnownExploit) + require.Equal(t, ptr.Float64(0.5), gResp.Vulnerability.EPSSProbability) + require.Equal(t, ptr.Time(mockTime), gResp.Vulnerability.Published) + require.Len(t, gResp.OSVersions, 1) + require.Equal(t, "Windows 11 Enterprise 22H2 10.0.19042.1234", gResp.OSVersions[0].Name) + require.Equal(t, "Windows 11 Enterprise 22H2", gResp.OSVersions[0].NameOnly) + require.Equal(t, "windows", gResp.OSVersions[0].Platform) + require.Equal(t, "10.0.19042.1234", gResp.OSVersions[0].Version) + require.Equal(t, 1, gResp.OSVersions[0].HostsCount) + require.Equal(t, "10.0.19043.2013", *gResp.OSVersions[0].ResolvedInVersion) } func (s *integrationEnterpriseTestSuite) TestOSVersions() { diff --git a/server/service/vulnerabilities.go b/server/service/vulnerabilities.go index 7e620144cb..671c140256 100644 --- a/server/service/vulnerabilities.go +++ b/server/service/vulnerabilities.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "time" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -90,3 +91,79 @@ func (svc *Service) CountVulnerabilities(ctx context.Context, opts fleet.VulnLis return svc.ds.CountVulnerabilities(ctx, opts) } + +type getVulnerabilityRequest struct { + CVE string `url:"cve"` + TeamID *uint `query:"team_id,optional"` +} + +type getVulnerabilityResponse struct { + Vulnerability *fleet.VulnerabilityWithMetadata `json:"vulnerability"` + OSVersions []*fleet.VulnerableOS `json:"os_versions"` + Software []*fleet.VulnerableSoftware `json:"software"` + Err error `json:"error,omitempty"` +} + +func (r getVulnerabilityResponse) error() error { return r.Err } + +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) + if err != nil { + return getVulnerabilityResponse{Err: err}, nil + } + + if vuln.Source == fleet.MSRCSource { + vuln.DetailsLink = fmt.Sprintf("https://msrc.microsoft.com/update-guide/en-US/vulnerability/%s", vuln.CVE) + } else { + vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE) + } + + osVersions, _, err := svc.ListOSVersionsByCVE(ctx, vuln.CVE, request.TeamID) + if err != nil { + return getVulnerabilityResponse{Err: err}, nil + } + + software, _, err := svc.ListSoftwareByCVE(ctx, vuln.CVE, request.TeamID) + if err != nil { + return getVulnerabilityResponse{Err: err}, nil + } + + return getVulnerabilityResponse{ + Vulnerability: vuln, + OSVersions: osVersions, + Software: software, + }, nil +} + +func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*fleet.VulnerabilityWithMetadata, error) { + if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } + + if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } + + vuln, err := svc.ds.Vulnerability(ctx, cve, teamID, useCVSScores) + if err != nil { + return nil, err + } + + return vuln, nil +} + +func (svc *Service) ListOSVersionsByCVE(ctx context.Context, cve string, teamID *uint) (result []*fleet.VulnerableOS, updatedAt time.Time, err error) { + if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, updatedAt, err + } + return svc.ds.OSVersionsByCVE(ctx, cve, teamID) +} + +func (svc *Service) ListSoftwareByCVE(ctx context.Context, cve string, teamID *uint) (result []*fleet.VulnerableSoftware, updatedAt time.Time, err error) { + if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, updatedAt, err + } + return svc.ds.SoftwareByCVE(ctx, cve, teamID) +} diff --git a/server/service/vulnerabilities_test.go b/server/service/vulnerabilities_test.go index 95bc20bb94..967297531a 100644 --- a/server/service/vulnerabilities_test.go +++ b/server/service/vulnerabilities_test.go @@ -51,3 +51,129 @@ func TestListVulnerabilities(t *testing.T) { require.NoError(t, err) }) } + +func TestVulnerabilitesAuth(t *testing.T) { + ds := new(mock.Store) + + svc, ctx := newTestService(t, ds, nil, nil) + + ds.ListVulnerabilitiesFunc = func(cxt context.Context, opt fleet.VulnListOptions) ([]fleet.VulnerabilityWithMetadata, *fleet.PaginationMetadata, error) { + return []fleet.VulnerabilityWithMetadata{}, &fleet.PaginationMetadata{}, nil + } + + ds.VulnerabilityFunc = func(cxt context.Context, cve string, teamID *uint, includeCVEScores bool) (*fleet.VulnerabilityWithMetadata, error) { + return &fleet.VulnerabilityWithMetadata{}, nil + } + + ds.CountVulnerabilitiesFunc = func(cxt context.Context, opt fleet.VulnListOptions) (uint, error) { + return 0, nil + } + + for _, tc := range []struct { + name string + user *fleet.User + shouldFailGlobalRead bool + shouldFailTeamRead bool + }{ + { + name: "global-admin", + user: &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleAdmin), + }, + shouldFailGlobalRead: false, + shouldFailTeamRead: false, + }, + { + name: "global-maintainer", + user: &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleMaintainer), + }, + shouldFailGlobalRead: false, + shouldFailTeamRead: false, + }, + { + name: "global-observer", + user: &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleObserver), + }, + shouldFailGlobalRead: false, + shouldFailTeamRead: false, + }, + { + name: "team-admin-belongs-to-team", + user: &fleet.User{ + ID: 1, + Teams: []fleet.UserTeam{{ + Team: fleet.Team{ID: 1}, + Role: fleet.RoleAdmin, + }}, + }, + shouldFailGlobalRead: true, + shouldFailTeamRead: false, + }, + { + name: "team-maintainer-belongs-to-team", + user: &fleet.User{ + ID: 1, + Teams: []fleet.UserTeam{{ + Team: fleet.Team{ID: 1}, + Role: fleet.RoleMaintainer, + }}, + }, + shouldFailGlobalRead: true, + shouldFailTeamRead: false, + }, + { + name: "team-observer-belongs-to-team", + user: &fleet.User{ + ID: 1, + Teams: []fleet.UserTeam{{ + Team: fleet.Team{ID: 1}, + Role: fleet.RoleObserver, + }}, + }, + shouldFailGlobalRead: true, + shouldFailTeamRead: false, + }, + { + name: "team-admin-does-not-belong-to-team", + user: &fleet.User{ + ID: 1, + Teams: []fleet.UserTeam{{ + Team: fleet.Team{ID: 2}, + Role: fleet.RoleAdmin, + }}, + }, + shouldFailGlobalRead: true, + shouldFailTeamRead: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx = viewer.NewContext(ctx, viewer.Viewer{User: tc.user}) + _, _, err := svc.ListVulnerabilities(ctx, fleet.VulnListOptions{}) + checkAuthErr(t, tc.shouldFailGlobalRead, err) + + _, _, err = svc.ListVulnerabilities(ctx, fleet.VulnListOptions{ + TeamID: 1, + }) + checkAuthErr(t, tc.shouldFailTeamRead, err) + + _, err = svc.CountVulnerabilities(ctx, fleet.VulnListOptions{}) + checkAuthErr(t, tc.shouldFailGlobalRead, err) + + _, err = svc.CountVulnerabilities(ctx, fleet.VulnListOptions{ + TeamID: 1, + }) + checkAuthErr(t, tc.shouldFailTeamRead, err) + + _, 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) + checkAuthErr(t, tc.shouldFailTeamRead, err) + }) + } +}