diff --git a/changes/16205-health-failing-counts b/changes/16205-health-failing-counts new file mode 100644 index 0000000000..df792a3fa6 --- /dev/null +++ b/changes/16205-health-failing-counts @@ -0,0 +1 @@ +- The Host Health API now includes failing policy counts \ No newline at end of file diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 6daf64222b..f3d66c7f4e 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -15,7 +15,9 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" @@ -4973,7 +4975,11 @@ func (ds *Datastore) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHea for _, s := range host.Software { if len(s.Vulnerabilities) > 0 { - hh.VulnerableSoftware = append(hh.VulnerableSoftware, s) + hh.VulnerableSoftware = append(hh.VulnerableSoftware, fleet.HostHealthVulnerableSoftware{ + ID: s.ID, + Name: s.Name, + Version: s.Version, + }) } } @@ -4982,12 +4988,34 @@ func (ds *Datastore) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHea return nil, err } + isPremium := license.IsPremium(ctx) for _, p := range policies { if p.Response == "fail" { - hh.FailingPolicies = append(hh.FailingPolicies, p) + var critical *bool + if isPremium { + critical = &p.Critical + } + hh.FailingPolicies = append(hh.FailingPolicies, &fleet.HostHealthFailingPolicy{ + ID: p.ID, + Name: p.Name, + Resolution: p.Resolution, + Critical: critical, + }) } } + hh.FailingPoliciesCount = len(hh.FailingPolicies) + + if license.IsPremium(ctx) { + var count int + for _, p := range hh.FailingPolicies { + if p.Critical != nil && *p.Critical { + count++ + } + } + hh.FailingCriticalPoliciesCount = ptr.Int(count) + } + return &hh, nil } diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 809ab12d54..ae0124c0d8 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -367,13 +367,28 @@ type HostOrbitInfo struct { // 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. + 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"` + FailingPoliciesCount int `json:"failing_policies_count"` + FailingCriticalPoliciesCount *int `json:"failing_critical_policies_count,omitempty"` // Fleet Premium Only + VulnerableSoftware []HostHealthVulnerableSoftware `json:"vulnerable_software,omitempty"` + FailingPolicies []*HostHealthFailingPolicy `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. +} + +type HostHealthVulnerableSoftware struct { + ID uint `json:"id"` + Name string `json:"name"` + Version string `json:"version"` +} + +type HostHealthFailingPolicy struct { + ID uint `json:"id"` + Name string `json:"name"` + Critical *bool `json:"critical,omitempty"` // Fleet Premium Only + Resolution *string `json:"resolution"` } func (hh HostHealth) AuthzType() string { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 273f4ec513..d3d1e99db8 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -10612,11 +10612,6 @@ func results(num int, hostID string) 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"), @@ -10631,7 +10626,7 @@ func (s *integrationTestSuite) TestHostHealth() { OSVersion: "Mac OS X 10.14.6", Platform: "darwin", CPUType: "cpuType", - TeamID: ptr.Uint(team.ID), + TeamID: nil, }) require.NoError(t, err) require.NotNil(t, host) @@ -10670,19 +10665,17 @@ func (s *integrationTestSuite) TestHostHealth() { 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, + passingPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "passing_policy", + Query: "select 1", + Resolution: "Run this command to fix it", }) 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, + failingPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "failing_policy", + Query: "select 0", + Resolution: "Run this command to fix it", }) require.NoError(t, err) @@ -10698,7 +10691,20 @@ func (s *integrationTestSuite) TestHostHealth() { assert.NotNil(t, hh.HostHealth) assert.Equal(t, host.OSVersion, hh.HostHealth.OsVersion) assert.Len(t, hh.HostHealth.VulnerableSoftware, 1) + assert.Equal(t, hh.HostHealth.VulnerableSoftware[0], fleet.HostHealthVulnerableSoftware{ + ID: soft1.ID, + Name: soft1.Name, + Version: soft1.Version, + }) + assert.Equal(t, 1, hh.HostHealth.FailingPoliciesCount) + assert.Nil(t, hh.HostHealth.FailingCriticalPoliciesCount) assert.Len(t, hh.HostHealth.FailingPolicies, 1) + assert.Equal(t, hh.HostHealth.FailingPolicies[0], &fleet.HostHealthFailingPolicy{ + ID: failingPolicy.ID, + Name: failingPolicy.Name, + Resolution: failingPolicy.Resolution, + Critical: nil, + }) assert.True(t, *hh.HostHealth.DiskEncryptionEnabled) // Check that the TeamID didn't make it into the response assert.Nil(t, hh.HostHealth.TeamID) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 7504218bb4..56272fa1b6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -32,8 +32,8 @@ import ( "github.com/fleetdm/fleet/v4/server/pubsub" "github.com/fleetdm/fleet/v4/server/service/schedule" "github.com/fleetdm/fleet/v4/server/test" - kitlog "github.com/go-kit/kit/log" "github.com/go-kit/log" + kitlog "github.com/go-kit/log" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" @@ -3279,6 +3279,89 @@ func (s *integrationEnterpriseTestSuite) TestListHosts() { } } +func (s *integrationEnterpriseTestSuite) 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) + + passingTeamPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{ + Name: "Passing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + }) + require.NoError(t, err) + + failingTeamPolicy, err := s.ds.NewTeamPolicy(context.Background(), team.ID, nil, fleet.PolicyPayload{ + Name: "Failing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + Critical: true, + }) + require.NoError(t, err) + + passingGlobalPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "Passing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + }) + require.NoError(t, err) + + failingGlobalPolicy, err := s.ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{ + Name: "Failing Global Policy", + Query: "select 1", + Resolution: "Run this command to fix it", + Critical: false, + }) + require.NoError(t, err) + + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingGlobalPolicy.ID: ptr.Bool(false)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingGlobalPolicy.ID: ptr.Bool(true)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{failingTeamPolicy.ID: ptr.Bool(false)}, time.Now(), false)) + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, map[uint]*bool{passingTeamPolicy.ID: ptr.Bool(true)}, time.Now(), false)) + + hh := getHostHealthResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/health", host.ID), nil, http.StatusOK, &hh) + require.Equal(t, host.ID, hh.HostID) + assert.NotNil(t, hh.HostHealth) + assert.Equal(t, host.OSVersion, hh.HostHealth.OsVersion) + assert.Equal(t, 2, hh.HostHealth.FailingPoliciesCount) + assert.Equal(t, ptr.Int(1), hh.HostHealth.FailingCriticalPoliciesCount) + assert.Contains(t, hh.HostHealth.FailingPolicies, &fleet.HostHealthFailingPolicy{ + ID: failingTeamPolicy.ID, + Name: failingTeamPolicy.Name, + Resolution: failingTeamPolicy.Resolution, + Critical: ptr.Bool(true), + }) + assert.Contains(t, hh.HostHealth.FailingPolicies, &fleet.HostHealthFailingPolicy{ + ID: failingGlobalPolicy.ID, + Name: failingGlobalPolicy.Name, + Resolution: failingGlobalPolicy.Resolution, + Critical: ptr.Bool(false), + }) +} + func (s *integrationEnterpriseTestSuite) TestListVulnerabilities() { t := s.T() var resp listVulnerabilitiesResponse