feat: device health endpoint (#15432)

> #14920 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Documented any permissions changes (docs/Using
Fleet/manage-access.md)
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Jahziel Villasana-Espinoza 2023-12-06 14:42:29 -05:00 committed by GitHub
parent fb01e30f27
commit 5fd799ff2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 414 additions and 0 deletions

View file

@ -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.

View file

@ -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
##

View file

@ -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},
})
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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{})

View file

@ -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
}

View file

@ -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)
}