From 937627f4eab1cbc0591b3edcd40bdb41ddb04d72 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Mon, 30 Sep 2024 16:58:00 -0600 Subject: [PATCH] Windows Battery Status (#22455) --- changes/19619-win-battery | 1 + .../Contributing/Understanding-host-vitals.md | 16 +++- server/service/osquery_test.go | 1 + server/service/osquery_utils/queries.go | 75 ++++++++++++++++--- server/service/osquery_utils/queries_test.go | 39 +++++++++- 5 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 changes/19619-win-battery diff --git a/changes/19619-win-battery b/changes/19619-win-battery new file mode 100644 index 0000000000..124e581140 --- /dev/null +++ b/changes/19619-win-battery @@ -0,0 +1 @@ +- Windows host details now include battery status \ No newline at end of file diff --git a/docs/Contributing/Understanding-host-vitals.md b/docs/Contributing/Understanding-host-vitals.md index 3cdabf2fde..3aa2078574 100644 --- a/docs/Contributing/Understanding-host-vitals.md +++ b/docs/Contributing/Understanding-host-vitals.md @@ -3,7 +3,7 @@ Following is a summary of the detail queries hardcoded in Fleet used to populate the device details: -## battery +## battery_macos - Platforms: darwin @@ -12,6 +12,20 @@ Following is a summary of the detail queries hardcoded in Fleet used to populate SELECT serial_number, cycle_count, health FROM battery; ``` +## battery_windows + +- Platforms: windows + +- Discovery query: +```sql +SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND name = 'battery' +``` + +- Query: +```sql +SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery +``` + ## chromeos_profile_user_info - Platforms: chrome diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 0697c7de69..ff913ac46f 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -1062,6 +1062,7 @@ func verifyDiscovery(t *testing.T, queries, discovery map[string]string) { hostDetailQueryPrefix + "orbit_info": {}, hostDetailQueryPrefix + "software_vscode_extensions": {}, hostDetailQueryPrefix + "software_macos_firefox": {}, + hostDetailQueryPrefix + "battery_windows": {}, } for name := range queries { require.NotEmpty(t, discovery[name]) diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index f13454b2d1..5c93b5d2d6 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -555,7 +555,7 @@ var extraDetailQueries = map[string]DetailQuery{ DirectIngestFunc: directIngestChromeProfiles, Discovery: discoveryTable("google_chrome_profiles"), }, - "battery": { + "battery_macos": { Query: `SELECT serial_number, cycle_count, health FROM battery;`, Platforms: []string{"darwin"}, DirectIngestFunc: directIngestBattery, @@ -563,6 +563,12 @@ var extraDetailQueries = map[string]DetailQuery{ // osquery table on darwin (https://osquery.io/schema/5.3.0#battery), it is // always present. }, + "battery_windows": { + Query: `SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery`, + Platforms: []string{"windows"}, + DirectIngestFunc: directIngestBattery, + Discovery: discoveryTable("battery"), // added to Windows in v5.12.1 (https://github.com/osquery/osquery/releases/tag/5.12.1) + }, "os_windows": { // This query is used to populate the `operating_systems` and `host_operating_system` // tables. Separately, the `hosts` table is populated via the `os_version` and @@ -1297,23 +1303,70 @@ func directIngestChromeProfiles(ctx context.Context, logger log.Logger, host *fl func directIngestBattery(ctx context.Context, logger log.Logger, host *fleet.Host, ds fleet.Datastore, rows []map[string]string) error { mapping := make([]*fleet.HostBattery, 0, len(rows)) for _, row := range rows { - cycleCount, err := strconv.ParseInt(EmptyToZero(row["cycle_count"]), 10, 64) + cycleCount, err := strconv.Atoi(EmptyToZero(row["cycle_count"])) if err != nil { return err } - mapping = append(mapping, &fleet.HostBattery{ - HostID: host.ID, - SerialNumber: row["serial_number"], - CycleCount: int(cycleCount), - // database type is VARCHAR(40) and since there isn't a - // canonical list of strings we can get for health, we - // truncate the value just in case. - Health: fmt.Sprintf("%.40s", row["health"]), - }) + + switch host.Platform { + case "darwin": + mapping = append(mapping, &fleet.HostBattery{ + HostID: host.ID, + SerialNumber: row["serial_number"], + CycleCount: cycleCount, + // database type is VARCHAR(40) and since there isn't a + // canonical list of strings we can get for health, we + // truncate the value just in case. + Health: fmt.Sprintf("%.40s", row["health"]), + }) + case "windows": + health, err := generateWindowsBatteryHealth(row["designed_capacity"], row["max_capacity"]) + if err != nil { + level.Error(logger).Log("op", "directIngestBattery", "hostID", host.ID, "err", err) + } + + mapping = append(mapping, &fleet.HostBattery{ + HostID: host.ID, + SerialNumber: row["serial_number"], + CycleCount: cycleCount, + Health: health, + }) + } } return ds.ReplaceHostBatteries(ctx, host.ID, mapping) } +const ( + batteryStatusUnknown = "Unknown" + batteryStatusDegraded = "Check Battery" + batteryStatusGood = "Good" + batteryDegradedThreshold = 80 +) + +func generateWindowsBatteryHealth(designedCapacity, maxCapacity string) (string, error) { + if designedCapacity == "" || maxCapacity == "" { + return batteryStatusUnknown, fmt.Errorf("missing battery capacity values, designed: %s, max: %s", designedCapacity, maxCapacity) + } + + designed, err := strconv.ParseInt(designedCapacity, 10, 64) + if err != nil { + return batteryStatusUnknown, err + } + + max, err := strconv.ParseInt(maxCapacity, 10, 64) + if err != nil { + return batteryStatusUnknown, err + } + + health := float64(max) / float64(designed) * 100 + + if health < batteryDegradedThreshold { + return batteryStatusDegraded, nil + } + + return batteryStatusGood, nil +} + func directIngestWindowsUpdateHistory( ctx context.Context, logger log.Logger, diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 8a29314470..3593ff20f1 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -279,7 +279,8 @@ func TestGetDetailQueries(t *testing.T) { "mdm_windows", "munki_info", "google_chrome_profiles", - "battery", + "battery_macos", + "battery_windows", "os_windows", "os_unix_like", "os_chrome", @@ -296,7 +297,7 @@ func TestGetDetailQueries(t *testing.T) { sortedKeysCompare(t, queriesNoConfig, baseQueries) queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil) - require.Len(t, queriesWithoutWinOSVuln, 25) + require.Len(t, queriesWithoutWinOSVuln, 26) queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true}) qs := append(baseQueries, "users", "users_chrome", "scheduled_query_stats") @@ -984,7 +985,8 @@ func TestDirectIngestBattery(t *testing.T) { } host := fleet.Host{ - ID: 1, + ID: 1, + Platform: "darwin", } err := directIngestBattery(context.Background(), log.NewNopLogger(), &host, ds, []map[string]string{ @@ -994,6 +996,37 @@ func TestDirectIngestBattery(t *testing.T) { require.NoError(t, err) require.True(t, ds.ReplaceHostBatteriesFuncInvoked) + + ds.ReplaceHostBatteriesFunc = func(ctx context.Context, id uint, mappings []*fleet.HostBattery) error { + require.Equal(t, mappings, []*fleet.HostBattery{ + {HostID: uint(2), SerialNumber: "a", CycleCount: 2, Health: batteryStatusGood}, + {HostID: uint(2), SerialNumber: "b", CycleCount: 3, Health: batteryStatusDegraded}, + {HostID: uint(2), SerialNumber: "c", CycleCount: 4, Health: batteryStatusUnknown}, + {HostID: uint(2), SerialNumber: "d", CycleCount: 5, Health: batteryStatusUnknown}, + {HostID: uint(2), SerialNumber: "e", CycleCount: 6, Health: batteryStatusUnknown}, + {HostID: uint(2), SerialNumber: "f", CycleCount: 7, Health: batteryStatusUnknown}, + }) + return nil + } + + // reset the ds flag + ds.ReplaceHostBatteriesFuncInvoked = false + + host = fleet.Host{ + ID: 2, + Platform: "windows", + } + + err = directIngestBattery(context.Background(), log.NewNopLogger(), &host, ds, []map[string]string{ + {"serial_number": "a", "cycle_count": "2", "designed_capacity": "3000", "max_capacity": "2400"}, // max_capacity >= 80% + {"serial_number": "b", "cycle_count": "3", "designed_capacity": "3000", "max_capacity": "2399"}, // max_capacity < 50% + {"serial_number": "c", "cycle_count": "4", "designed_capacity": "3000", "max_capacity": ""}, // missing max_capacity + {"serial_number": "d", "cycle_count": "5", "designed_capacity": "", "max_capacity": ""}, // missing designed_capacity and max_capacity + {"serial_number": "e", "cycle_count": "6", "designed_capacity": "", "max_capacity": "2000"}, // missing designed_capacity + {"serial_number": "f", "cycle_count": "7", "designed_capacity": "foo", "max_capacity": "bar"}, // invalid designed_capacity and max_capacity + }) + require.NoError(t, err) + require.True(t, ds.ReplaceHostBatteriesFuncInvoked) } func TestDirectIngestOSWindows(t *testing.T) {