diff --git a/changes/12889-faster-software b/changes/12889-faster-software new file mode 100644 index 0000000000..e4c9b1b708 --- /dev/null +++ b/changes/12889-faster-software @@ -0,0 +1,2 @@ +- Adds a `populate_software` flag to the `GET /hosts` endpoint, which will include software for each + host returned from that endpoint. \ No newline at end of file diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 51b6e46f23..fe72573457 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -177,6 +177,9 @@ type HostListOptions struct { // Premium feature, Fleet Free ignores the setting (it forces it to nil to // disable it). LowDiskSpaceFilter *int + + // PopulateSoftware adds the `Software` field to all Hosts returned. + PopulateSoftware bool } // TODO(Sarah): Are we missing any filters here? Should all MDM filters be included? diff --git a/server/service/hosts.go b/server/service/hosts.go index 52e2e03dde..a5310d8d7a 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -179,7 +179,20 @@ func (svc *Service) ListHosts(ctx context.Context, opt fleet.HostListOptions) ([ opt.MDMBootstrapPackageFilter = nil } - return svc.ds.ListHosts(ctx, filter, opt) + hosts, err := svc.ds.ListHosts(ctx, filter, opt) + if err != nil { + return nil, err + } + + if opt.PopulateSoftware { + for _, host := range hosts { + if err = svc.ds.LoadHostSoftware(ctx, host, license.IsPremium(ctx)); err != nil { + return nil, err + } + } + } + + return hosts, nil } ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6492bd6d28..8e63bdcd85 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -1670,6 +1670,51 @@ func (s *integrationTestSuite) TestListHosts() { resp = listHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "os_id", fmt.Sprintf("%d", osID+1337)) require.Len(t, resp.Hosts, 0) + + // populate software for hosts + now := time.Now() + + inserted, err := s.ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{ + SoftwareID: host2.Software[0].ID, + CVE: "cve-123-123-123", + }, fleet.NVDSource) + require.NoError(t, err) + require.True(t, inserted) + + require.NoError(t, s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{{ + CVE: "cve-123-123-123", + CVSSScore: ptr.Float64(5.4), + EPSSProbability: ptr.Float64(0.5), + CISAKnownExploit: ptr.Bool(true), + Published: &now, + Description: "a long description of the cve", + }})) + + resp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "true") + require.Len(t, resp.Hosts, 4) + for _, h := range resp.Hosts { + if h.ID == hosts[2].ID { + require.NotEmpty(t, h.Software) + require.Len(t, h.Software, 1) + require.NotEmpty(t, h.Software[0].Vulnerabilities) + + // all these should be nil because this isn't Premium + require.Nil(t, h.Software[0].Vulnerabilities[0].CVSSScore) + require.Nil(t, h.Software[0].Vulnerabilities[0].EPSSProbability) + require.Nil(t, h.Software[0].Vulnerabilities[0].CISAKnownExploit) + require.Nil(t, h.Software[0].Vulnerabilities[0].CVEPublished) + require.Nil(t, h.Software[0].Vulnerabilities[0].Description) + require.Nil(t, h.Software[0].Vulnerabilities[0].ResolvedInVersion) + } + } + + resp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "false") + require.Len(t, resp.Hosts, 4) + for _, h := range resp.Hosts { + require.Empty(t, h.Software) + } } func (s *integrationTestSuite) TestInvites() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 62ccfb19cf..62414fb47e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -3030,6 +3030,59 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() { s.DoJSON("GET", "/api/latest/fleet/host_summary", nil, http.StatusOK, &summaryResp, "team_id", "1", "platform", "linux") require.Equal(t, uint(0), summaryResp.TotalsHostsCount) require.Nil(t, summaryResp.LowDiskSpaceCount) + + // populate software for hosts + now := time.Now() + + software := []fleet.Software{ + {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, + } + _, err = s.ds.UpdateHostSoftware(context.Background(), host1.ID, software) + require.NoError(t, err) + + require.NoError(t, s.ds.LoadHostSoftware(context.Background(), host1, false)) + + inserted, err := s.ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{ + SoftwareID: host1.Software[0].ID, + CVE: "cve-123-123-123", + }, fleet.NVDSource) + require.NoError(t, err) + require.True(t, inserted) + + vulnMeta := []fleet.CVEMeta{{ + CVE: "cve-123-123-123", + CVSSScore: ptr.Float64(5.4), + EPSSProbability: ptr.Float64(0.5), + CISAKnownExploit: ptr.Bool(true), + Published: &now, + Description: "a long description of the cve", + }} + + require.NoError(t, s.ds.InsertCVEMeta(context.Background(), vulnMeta)) + + resp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "true") + require.Len(t, resp.Hosts, 3) + for _, h := range resp.Hosts { + if h.ID == host1.ID { + require.NotEmpty(t, h.Software) + require.Len(t, h.Software, 1) + require.NotEmpty(t, h.Software[0].Vulnerabilities) + + s := &vulnMeta[0].Description + require.Equal(t, &vulnMeta[0].CVSSScore, h.Software[0].Vulnerabilities[0].CVSSScore) + require.Equal(t, &vulnMeta[0].EPSSProbability, h.Software[0].Vulnerabilities[0].EPSSProbability) + require.Equal(t, &vulnMeta[0].CISAKnownExploit, h.Software[0].Vulnerabilities[0].CISAKnownExploit) + require.Equal(t, &s, h.Software[0].Vulnerabilities[0].Description) + } + } + + resp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp, "populate_software", "false") + require.Len(t, resp.Hosts, 3) + for _, h := range resp.Hosts { + require.Empty(t, h.Software) + } } func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() { diff --git a/server/service/transport.go b/server/service/transport.go index 37b8989497..ca9feccb4a 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -483,6 +483,14 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error) } hopt.LowDiskSpaceFilter = &v } + populateSoftware := r.URL.Query().Get("populate_software") + if populateSoftware != "" { + ps, err := strconv.ParseBool(populateSoftware) + if err != nil { + return hopt, ctxerr.Wrap(r.Context(), badRequest(fmt.Sprintf("Invalid populate_software: %s", populateSoftware))) + } + hopt.PopulateSoftware = ps + } // cannot combine software_id, software_version_id, and software_title_id var softwareErrorLabel []string