diff --git a/changes/14920-device-health b/changes/14920-device-health new file mode 100644 index 0000000000..b930750f2e --- /dev/null +++ b/changes/14920-device-health @@ -0,0 +1,2 @@ +- Adds a `GET /hosts/{id}/health` endpoint which reports back some key data about host health such + as vulnerable software and failing policies. \ No newline at end of file diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 41ce8c4959..44ed0107c8 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -261,6 +261,21 @@ allow { action == write } +# Allow read for host health for global admin/maintainer, team admins, observer. +allow { + object.type == "host_health" + subject.global_role == [admin, maintainer, observer][_] + action == read +} + + +# Allow read for host health for team admin/maintainer, team admins, observer. +allow { + object.type == "host_health" + team_role(subject, object.team_id) == [admin, maintainer, observer][_] + action == read +} + ## # Labels ## diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go index 83fbe9587d..49fa56559e 100644 --- a/server/authz/policy_test.go +++ b/server/authz/policy_test.go @@ -2087,3 +2087,24 @@ func TestJSONToInterfaceUser(t *testing.T) { assert.Equal(t, json.Number("42"), subject["teams"].([]interface{})[1].(map[string]interface{})["id"]) } } + +func TestHostHealth(t *testing.T) { + t.Parallel() + + hostHealth := &fleet.HostHealth{TeamID: ptr.Uint(1)} + runTestCases(t, []authTestCase{ + {user: nil, object: hostHealth, action: read, allow: false}, + {user: test.UserGitOps, object: hostHealth, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: hostHealth, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: hostHealth, action: read, allow: false}, + {user: test.UserAdmin, object: hostHealth, action: read, allow: true}, + {user: test.UserTeamAdminTeam1, object: hostHealth, action: read, allow: true}, + {user: test.UserTeamAdminTeam2, object: hostHealth, action: read, allow: false}, + {user: test.UserObserver, object: hostHealth, action: read, allow: true}, + {user: test.UserTeamObserverTeam1, object: hostHealth, action: read, allow: true}, + {user: test.UserTeamObserverTeam2, object: hostHealth, action: read, allow: false}, + {user: test.UserMaintainer, object: hostHealth, action: read, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: hostHealth, action: read, allow: true}, + {user: test.UserTeamMaintainerTeam2, object: hostHealth, action: read, allow: false}, + }) +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 44f73728a2..461ddaa8da 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -4536,3 +4536,44 @@ func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []strin return result, nil } + +func (ds *Datastore) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHealth, error) { + sqlStmt := ` + SELECT h.os_version, h.updated_at, h.platform, h.team_id, hd.encrypted as disk_encryption_enabled FROM hosts h + LEFT JOIN host_disks hd ON hd.host_id = h.id + WHERE id = ? + ` + + var hh fleet.HostHealth + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hh, sqlStmt, id); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("Host Health").WithID(id)) + } + + return nil, ctxerr.Wrap(ctx, err, "loading host health") + } + + host := &fleet.Host{ID: id, Platform: hh.Platform} + if err := ds.LoadHostSoftware(ctx, host, true); err != nil { + return nil, err + } + + for _, s := range host.Software { + if len(s.Vulnerabilities) > 0 { + hh.VulnerableSoftware = append(hh.VulnerableSoftware, s) + } + } + + policies, err := ds.ListPoliciesForHost(ctx, host) + if err != nil { + return nil, err + } + + for _, p := range policies { + if p.Response == "fail" { + hh.FailingPolicies = append(hh.FailingPolicies, p) + } + } + + return &hh, nil +} diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 710ffbe802..3202e65ded 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -157,6 +157,7 @@ func TestHosts(t *testing.T) { {"ListHostsLiteByIDs", testHostsListHostsLiteByIDs}, {"ListHostsWithPagination", testListHostsWithPagination}, {"LastRestarted", testLastRestarted}, + {"HostHealth", testHostHealth}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -7690,3 +7691,137 @@ func testLastRestarted(t *testing.T, ds *Datastore) { require.Equal(t, time.Duration(uptimeVal), host.Uptime) require.Equal(t, hostsToVals[host.ID], host.LastRestartedAt) } + +func testHostHealth(t *testing.T, ds *Datastore) { + _, err := ds.GetHostHealth(context.Background(), 1) + require.Error(t, err) + var nfe fleet.NotFoundError + require.True(t, errors.As(err, &nfe)) + + // We'll check TeamIDs because at this level they should still be populated + team, err := ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + now := time.Now() + _, err = ds.NewHost(context.Background(), &fleet.Host{ + ID: 1, + OsqueryHostID: ptr.String("foobar"), + NodeKey: ptr.String("nodekey"), + Hostname: "foobar.local", + UUID: "uuid", + Platform: "darwin", + DistributedInterval: 60, + LoggerTLSPeriod: 50, + ConfigTLSRefresh: 40, + DetailUpdatedAt: now, + LabelUpdatedAt: now, + LastEnrolledAt: now, + PolicyUpdatedAt: now, + RefetchRequested: true, + TeamID: ptr.Uint(team.ID), + + SeenTime: now, + + CPUType: "cpuType", + }) + require.NoError(t, err) + h, err := ds.Host(context.Background(), 1) + require.NoError(t, err) + + // set up policies + u := test.NewUser(t, ds, "Jack", "jack@example.com", true) + + q := test.NewQuery(t, ds, nil, "passing_query", "select 1", 0, true) + passingPolicy, err := ds.NewGlobalPolicy(context.Background(), &u.ID, fleet.PolicyPayload{QueryID: &q.ID}) + require.NoError(t, err) + + q = test.NewQuery(t, ds, nil, "failing_query", "select 1", 0, true) + failingPolicy, err := ds.NewGlobalPolicy(context.Background(), &u.ID, fleet.PolicyPayload{QueryID: &q.ID}) + require.NoError(t, err) + + require.NoError(t, ds.RecordPolicyQueryExecutions(context.Background(), h, map[uint]*bool{passingPolicy.ID: ptr.Bool(true)}, time.Now(), false)) + require.NoError(t, ds.RecordPolicyQueryExecutions(context.Background(), h, map[uint]*bool{failingPolicy.ID: ptr.Bool(false)}, time.Now(), false)) + + // set up vulnerable software + software := []fleet.Software{ + {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "bar", Version: "0.0.3", Source: "apps"}, + {Name: "baz", Version: "0.0.4", Source: "apps"}, + } + _, err = ds.UpdateHostSoftware(context.Background(), h.ID, software) + require.NoError(t, err) + require.NoError(t, ds.LoadHostSoftware(context.Background(), h, false)) + + soft1 := h.Software[0] + if soft1.Name != "bar" { + soft1 = h.Software[1] + } + + cpes := []fleet.SoftwareCPE{{SoftwareID: soft1.ID, CPE: "somecpe"}} + _, err = ds.UpsertSoftwareCPEs(context.Background(), cpes) + require.NoError(t, err) + + // Reload software so that 'GeneratedCPEID is set. + require.NoError(t, ds.LoadHostSoftware(context.Background(), h, false)) + soft1 = h.Software[0] + if soft1.Name != "bar" { + soft1 = h.Software[1] + } + + inserted, err := ds.InsertSoftwareVulnerability( + context.Background(), fleet.SoftwareVulnerability{ + SoftwareID: soft1.ID, + CVE: "cve-123-123-132", + }, fleet.NVDSource, + ) + require.NoError(t, err) + require.True(t, inserted) + + hh, err := ds.GetHostHealth(context.Background(), h.ID) + require.NoError(t, err) + require.Equal(t, h.Platform, hh.Platform) + require.Equal(t, h.DiskEncryptionEnabled, hh.DiskEncryptionEnabled) + require.Equal(t, h.OSVersion, hh.OsVersion) + require.Equal(t, ptr.Uint(team.ID), hh.TeamID) + require.Equal(t, h.UpdatedAt, hh.UpdatedAt) + require.Len(t, hh.FailingPolicies, 1) + require.Equal(t, failingPolicy.ID, hh.FailingPolicies[0].ID) + require.Len(t, hh.VulnerableSoftware, 1) + require.Equal(t, soft1.ID, hh.VulnerableSoftware[0].ID) + + // Validate a host with no software or policies or team + _, err = ds.NewHost(context.Background(), &fleet.Host{ + ID: 2, + OsqueryHostID: ptr.String("empty"), + NodeKey: ptr.String("empty_nodekey"), + Hostname: "empty.local", + UUID: "uuid123", + Platform: "darwin", + DistributedInterval: 60, + LoggerTLSPeriod: 50, + ConfigTLSRefresh: 40, + DetailUpdatedAt: now, + LabelUpdatedAt: now, + LastEnrolledAt: now, + PolicyUpdatedAt: now, + RefetchRequested: true, + + SeenTime: now, + + CPUType: "cpuType", + }) + require.NoError(t, err) + h, err = ds.Host(context.Background(), 2) + require.NoError(t, err) + + hh, err = ds.GetHostHealth(context.Background(), h.ID) + require.NoError(t, err) + require.Equal(t, h.Platform, hh.Platform) + require.Equal(t, h.DiskEncryptionEnabled, hh.DiskEncryptionEnabled) + require.Equal(t, h.OSVersion, hh.OsVersion) + require.Empty(t, hh.FailingPolicies) + require.Empty(t, hh.VulnerableSoftware) + require.Equal(t, h.TeamID, hh.TeamID) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 06b5884719..12866084ba 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -196,6 +196,7 @@ type Datastore interface { NewHost(ctx context.Context, host *Host) (*Host, error) DeleteHost(ctx context.Context, hid uint) error Host(ctx context.Context, id uint) (*Host, error) + GetHostHealth(ctx context.Context, id uint) (*HostHealth, error) ListHosts(ctx context.Context, filter TeamFilter, opt HostListOptions) ([]*Host, error) // ListHostsLiteByUUIDs returns the "lite" version of hosts corresponding to diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 9610f720cd..b82dcb6fc0 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -321,6 +321,22 @@ type Host struct { LastRestartedAt time.Time `json:"last_restarted_at" db:"last_restarted_at" csv:"last_restarted_at"` } +// HostHealth contains a subset of Host data that indicates how healthy a Host is. For fields with +// the same name, see the comments/docs for the Host field above. +type HostHealth struct { + UpdatedAt time.Time `json:"updated_at,omitempty" db:"updated_at"` + OsVersion string `json:"os_version,omitempty" db:"os_version"` + DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty" db:"disk_encryption_enabled"` + VulnerableSoftware []HostSoftwareEntry `json:"vulnerable_software,omitempty"` + FailingPolicies []*HostPolicy `json:"failing_policies,omitempty"` + Platform string `json:"-" db:"platform"` // Needed to fetch failing policies. Not returned in HTTP responses. + TeamID *uint `json:"team_id,omitempty" db:"team_id"` // Needed to verify that user can access this host's health data. Not returned in HTTP responses. +} + +func (hh HostHealth) AuthzType() string { + return "host_health" +} + type MDMHostData struct { // For CSV columns, since the CSV is flattened, we keep the "mdm." prefix // along with the column name. diff --git a/server/fleet/service.go b/server/fleet/service.go index 4f670c0753..e7069932a8 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -325,6 +325,7 @@ type Service interface { // The return value can also include policy information and CVE scores based // on the values provided to `opts` GetHost(ctx context.Context, id uint, opts HostDetailOptions) (host *HostDetail, err error) + GetHostHealth(ctx context.Context, id uint) (hostHealth *HostHealth, err error) GetHostSummary(ctx context.Context, teamID *uint, platform *string, lowDiskSpace *int) (summary *HostSummary, err error) DeleteHost(ctx context.Context, id uint) (err error) // HostByIdentifier returns one host matching the provided identifier. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 9c6cb8131d..24b5f9410f 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -154,6 +154,8 @@ type DeleteHostFunc func(ctx context.Context, hid uint) error type HostFunc func(ctx context.Context, id uint) (*fleet.Host, error) +type GetHostHealthFunc func(ctx context.Context, id uint) (*fleet.HostHealth, error) + type ListHostsFunc func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) type ListHostsLiteByUUIDsFunc func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) @@ -973,6 +975,9 @@ type DataStore struct { HostFunc HostFunc HostFuncInvoked bool + GetHostHealthFunc GetHostHealthFunc + GetHostHealthFuncInvoked bool + ListHostsFunc ListHostsFunc ListHostsFuncInvoked bool @@ -2373,6 +2378,13 @@ func (s *DataStore) Host(ctx context.Context, id uint) (*fleet.Host, error) { return s.HostFunc(ctx, id) } +func (s *DataStore) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHealth, error) { + s.mu.Lock() + s.GetHostHealthFuncInvoked = true + s.mu.Unlock() + return s.GetHostHealthFunc(ctx, id) +} + func (s *DataStore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { s.mu.Lock() s.ListHostsFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 282cb11c8a..3150bf1903 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -385,6 +385,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{}) ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 9579d1ae4e..6c5f00be92 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1704,3 +1704,46 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host return key, nil } + +//////////////////////////////////////////////////////////////////////////////// +// Host Health +//////////////////////////////////////////////////////////////////////////////// + +type getHostHealthRequest struct { + ID uint `url:"id"` +} + +type getHostHealthResponse struct { + Err error `json:"error,omitempty"` + HostID uint `json:"host_id,omitempty"` + HostHealth *fleet.HostHealth `json:"health,omitempty"` +} + +func (r getHostHealthResponse) error() error { return r.Err } + +func getHostHealthEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getHostHealthRequest) + hh, err := svc.GetHostHealth(ctx, req.ID) + if err != nil { + return getHostHealthResponse{Err: err}, nil + } + + // remove TeamID as it's needed for authorization internally but is not part of the external API + hh.TeamID = nil + + return getHostHealthResponse{HostID: req.ID, HostHealth: hh}, nil +} + +func (svc *Service) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHealth, error) { + svc.authz.SkipAuthorization(ctx) + hh, err := svc.ds.GetHostHealth(ctx, id) + if err != nil { + return nil, err + } + + if err := svc.authz.Authorize(ctx, hh, fleet.ActionRead); err != nil { + return nil, err + } + + return hh, nil +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index e2af124c01..a7cc205839 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -692,6 +692,7 @@ func (s *integrationTestSuite) TestVulnerableSoftware() { Hostname: t.Name() + "foo.local", PrimaryIP: "192.168.1.1", PrimaryMac: "30-65-EC-6F-C4-58", + OSVersion: "Mac OS X 10.14.6", }) require.NoError(t, err) require.NotNil(t, host) @@ -8795,3 +8796,128 @@ func results(num int, hostID string) string { return b.String() } + +func (s *integrationTestSuite) TestHostHealth() { + t := s.T() + + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + host, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + OsqueryHostID: ptr.String(t.Name() + "hostid1"), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(t.Name() + "nodekey1"), + UUID: t.Name() + "uuid1", + Hostname: t.Name() + "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + OSVersion: "Mac OS X 10.14.6", + Platform: "darwin", + CPUType: "cpuType", + TeamID: ptr.Uint(team.ID), + }) + require.NoError(t, err) + require.NotNil(t, host) + + software := []fleet.Software{ + {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "bar", Version: "0.0.3", Source: "apps"}, + {Name: "baz", Version: "0.0.4", Source: "apps"}, + } + _, err = s.ds.UpdateHostSoftware(context.Background(), host.ID, software) + require.NoError(t, err) + require.NoError(t, s.ds.LoadHostSoftware(context.Background(), host, false)) + + soft1 := host.Software[0] + if soft1.Name != "bar" { + soft1 = host.Software[1] + } + + cpes := []fleet.SoftwareCPE{{SoftwareID: soft1.ID, CPE: "somecpe"}} + _, err = s.ds.UpsertSoftwareCPEs(context.Background(), cpes) + require.NoError(t, err) + + // Reload software so that 'GeneratedCPEID is set. + require.NoError(t, s.ds.LoadHostSoftware(context.Background(), host, false)) + soft1 = host.Software[0] + if soft1.Name != "bar" { + soft1 = host.Software[1] + } + + inserted, err := s.ds.InsertSoftwareVulnerability( + context.Background(), fleet.SoftwareVulnerability{ + SoftwareID: soft1.ID, + CVE: "cve-123-123-132", + }, fleet.NVDSource, + ) + require.NoError(t, err) + require.True(t, inserted) + + user1 := test.NewUser(t, s.ds, "Joe", "joe@example.com", true) + + q1 := test.NewQuery(t, s.ds, nil, "passing_query", "select 1", 0, true) + defer cleanupQuery(s, q1.ID) + passingPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, &user1.ID, fleet.PolicyPayload{ + QueryID: &q1.ID, + }) + require.NoError(t, err) + + q2 := test.NewQuery(t, s.ds, nil, "failing_query", "select 0", 0, true) + defer cleanupQuery(s, q2.ID) + failingPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, &user1.ID, fleet.PolicyPayload{ + QueryID: &q2.ID, + }) + require.NoError(t, err) + + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingPolicy.ID: ptr.Bool(false)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingPolicy.ID: ptr.Bool(true)}, time.Now(), false)) + + require.NoError(t, s.ds.SetOrUpdateHostDisksEncryption(context.Background(), host.ID, true)) + + // Get host health + hh := getHostHealthResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/health", host.ID), nil, http.StatusOK, &hh) + assert.Equal(t, host.ID, hh.HostID) + assert.NotNil(t, hh.HostHealth) + assert.Equal(t, host.OSVersion, hh.HostHealth.OsVersion) + assert.Len(t, hh.HostHealth.VulnerableSoftware, 1) + assert.Len(t, hh.HostHealth.FailingPolicies, 1) + assert.True(t, *hh.HostHealth.DiskEncryptionEnabled) + // Check that the TeamID didn't make it into the response + assert.Nil(t, hh.HostHealth.TeamID) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/health", 0), nil, http.StatusNotFound, &hh) + + resp := getHostHealthResponse{} + host1, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + OsqueryHostID: ptr.String(t.Name() + "hostid2"), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(t.Name() + "nodekey2"), + UUID: t.Name() + "uuid2", + Hostname: t.Name() + "foo2.local", + PrimaryIP: "192.168.2.2", + PrimaryMac: "32-62-E2-62-C2-52", + OSVersion: "Mac OS X 10.14.2", + Platform: "darwin", + CPUType: "cpuType", + }) + require.NoError(t, err) + require.NotNil(t, host1) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/health", host1.ID), nil, http.StatusOK, &resp) + assert.Equal(t, host1.ID, resp.HostID) + assert.NotNil(t, resp.HostHealth) + assert.Equal(t, host1.OSVersion, resp.HostHealth.OsVersion) + assert.Nil(t, resp.HostHealth.DiskEncryptionEnabled) + assert.Empty(t, resp.HostHealth.VulnerableSoftware) + assert.Empty(t, resp.HostHealth.FailingPolicies) + assert.Nil(t, resp.HostHealth.TeamID) +}