diff --git a/.gitignore b/.gitignore index e2be26b..d6fa0e3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ config.mk bazel-* coverage/* version_config.go -cover.out \ No newline at end of file +cover.out +.claude/settings.local.json \ No newline at end of file diff --git a/BUILD.bazel b/BUILD.bazel index 559748d..bb87762 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -41,6 +41,7 @@ go_library( "//tables/authdb", "//tables/chromeuserprofiles", "//tables/crowdstrike_falcon", + "//tables/energyimpact", "//tables/fileline", "//tables/filevaultusers", "//tables/localnetworkpermissions", diff --git a/README.md b/README.md index fe0cfd4..d6d5a58 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ For production deployment, you should refer to the [osquery documentation](https |------------------------------| --------------------------------------------------------------------------------------------- |-------------------------| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `alt_system_info` | Alternative system_info table | macOS | This table is an alternative to the built-in system_info table in osquery, which triggers an `Allow "osquery" to find devices on local networks?` prompt on macOS 15.0. On versions other than 15.0, this table falls back to the built-in system_info table. Note: this table returns an empty `cpu_subtype` field. See [#58](https://github.com/macadmins/osquery-extension/pull/58) for more details. | | `authdb` | macOS Authorization database | macOS | Use the constraint `name` to specify a right name to query, otherwise all rights will be returned. | -| `crowdstrike_falcon` | Provides basic information about the currently installed Falcon sensor. | Linux / macOS | Requires Falcon to be installed. | +| `crowdstrike_falcon` | Provides basic information about the currently installed Falcon sensor. | Linux / macOS | Requires Falcon to be installed. | +| `energy_impact` | Process energy impact data from `powermetrics` | macOS | Use the `interval` constraint to specify sampling duration in milliseconds (default: 1000ms). | | `file_lines` | Read an arbitrary file | Linux / macOS / Windows | Use the constraint `path` and `last` to specify the file to read lines from | | `filevault_users` | Information on the users able to unlock the current boot volume when encrypted with Filevault | macOS | | | `google_chrome_profiles` | Profiles configured in Google Chrome. | Linux / macOS / Windows | | diff --git a/VERSION b/VERSION index 3a3cd8c..1892b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.1 +1.3.2 diff --git a/main.go b/main.go index 531f522..b223982 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/macadmins/osquery-extension/tables/alt_system_info" "github.com/macadmins/osquery-extension/tables/chromeuserprofiles" "github.com/macadmins/osquery-extension/tables/crowdstrike_falcon" + "github.com/macadmins/osquery-extension/tables/energyimpact" "github.com/macadmins/osquery-extension/tables/fileline" "github.com/macadmins/osquery-extension/tables/filevaultusers" "github.com/macadmins/osquery-extension/tables/localnetworkpermissions" @@ -92,6 +93,7 @@ func main() { if runtime.GOOS == "darwin" { darwinPlugins := []osquery.OsqueryPlugin{ + table.NewPlugin("energy_impact", energyimpact.EnergyImpactColumns(), energyimpact.EnergyImpactGenerate), table.NewPlugin("filevault_users", filevaultusers.FileVaultUsersColumns(), filevaultusers.FileVaultUsersGenerate), table.NewPlugin("local_network_permissions", localnetworkpermissions.LocalNetworkPermissionsColumns(), localnetworkpermissions.LocalNetworkPermissionsGenerate), table.NewPlugin("macos_profiles", macosprofiles.MacOSProfilesColumns(), macosprofiles.MacOSProfilesGenerate), diff --git a/tables/energyimpact/BUILD.bazel b/tables/energyimpact/BUILD.bazel new file mode 100644 index 0000000..a7a81f6 --- /dev/null +++ b/tables/energyimpact/BUILD.bazel @@ -0,0 +1,26 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "energyimpact", + srcs = ["energy_impact.go"], + importpath = "github.com/macadmins/osquery-extension/tables/energyimpact", + visibility = ["//visibility:public"], + deps = [ + "//pkg/utils", + "@com_github_micromdm_plist//:plist", + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_pkg_errors//:errors", + ], +) + +go_test( + name = "energyimpact_test", + srcs = ["energy_impact_test.go"], + embed = [":energyimpact"], + embedsrcs = ["test_powermetrics_output.plist"], + deps = [ + "//pkg/utils", + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_stretchr_testify//assert", + ], +) diff --git a/tables/energyimpact/energy_impact.go b/tables/energyimpact/energy_impact.go new file mode 100644 index 0000000..0d5f67a --- /dev/null +++ b/tables/energyimpact/energy_impact.go @@ -0,0 +1,155 @@ +package energyimpact + +import ( + "context" + "fmt" + "os" + "strconv" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/micromdm/plist" + "github.com/osquery/osquery-go/plugin/table" + "github.com/pkg/errors" +) + +const defaultInterval = 1000 + +// powermetricsOutput represents the top-level plist structure from powermetrics +type powermetricsOutput struct { + Tasks []task `plist:"tasks"` +} + +// task represents individual process data from powermetrics +type task struct { + PID int `plist:"pid"` + Name string `plist:"name"` + EnergyImpact float64 `plist:"energy_impact"` + EnergyImpactPerS float64 `plist:"energy_impact_per_s"` + CPUTimeNS int64 `plist:"cputime_ns"` + CPUTimeMSPerS float64 `plist:"cputime_ms_per_s"` + CPUTimeUserlandRatio float64 `plist:"cputime_userland_ratio"` + IntrWakeups int `plist:"intr_wakeups"` + IntrWakeupsPerS float64 `plist:"intr_wakeups_per_s"` + IdleWakeups int `plist:"idle_wakeups"` + IdleWakeupsPerS float64 `plist:"idle_wakeups_per_s"` + DiskIOBytesRead int64 `plist:"diskio_bytesread"` + DiskIOBytesReadPerS float64 `plist:"diskio_bytesread_per_s"` + DiskIOBytesWritten int64 `plist:"diskio_byteswritten"` + DiskIOBytesWrittenPerS float64 `plist:"diskio_byteswritten_per_s"` + PacketsReceived int `plist:"packets_received"` + PacketsSent int `plist:"packets_sent"` + BytesReceived int64 `plist:"bytes_received"` + BytesSent int64 `plist:"bytes_sent"` +} + +// EnergyImpactColumns returns the column definitions for the energy_impact table +func EnergyImpactColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.IntegerColumn("pid"), + table.TextColumn("name"), + table.TextColumn("energy_impact"), + table.TextColumn("energy_impact_per_s"), + table.IntegerColumn("cputime_ns"), + table.TextColumn("cputime_ms_per_s"), + table.TextColumn("cputime_userland_ratio"), + table.IntegerColumn("intr_wakeups"), + table.TextColumn("intr_wakeups_per_s"), + table.IntegerColumn("idle_wakeups"), + table.TextColumn("idle_wakeups_per_s"), + table.IntegerColumn("diskio_bytesread"), + table.TextColumn("diskio_bytesread_per_s"), + table.IntegerColumn("diskio_byteswritten"), + table.TextColumn("diskio_byteswritten_per_s"), + table.IntegerColumn("packets_received"), + table.IntegerColumn("packets_sent"), + table.IntegerColumn("bytes_received"), + table.IntegerColumn("bytes_sent"), + table.IntegerColumn("interval"), + } +} + +// EnergyImpactGenerate generates the table data when queried +func EnergyImpactGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + var results []map[string]string + + // Get interval from WHERE clause, default to 1000ms + interval := defaultInterval + if constraintList, present := queryContext.Constraints["interval"]; present { + for _, constraint := range constraintList.Constraints { + if constraint.Operator == table.OperatorEquals { + if parsed, err := strconv.Atoi(constraint.Expression); err == nil { + interval = parsed + } + } + } + } + + r := utils.NewRunner() + fs := utils.OSFileSystem{} + tasks, err := runPowermetrics(r, fs, interval) + if err != nil { + fmt.Println(err) + return results, err + } + + for _, t := range tasks { + results = append(results, map[string]string{ + "pid": strconv.Itoa(t.PID), + "name": t.Name, + "energy_impact": fmt.Sprintf("%.2f", t.EnergyImpact), + "energy_impact_per_s": fmt.Sprintf("%.2f", t.EnergyImpactPerS), + "cputime_ns": strconv.FormatInt(t.CPUTimeNS, 10), + "cputime_ms_per_s": fmt.Sprintf("%.2f", t.CPUTimeMSPerS), + "cputime_userland_ratio": fmt.Sprintf("%.2f", t.CPUTimeUserlandRatio), + "intr_wakeups": strconv.Itoa(t.IntrWakeups), + "intr_wakeups_per_s": fmt.Sprintf("%.2f", t.IntrWakeupsPerS), + "idle_wakeups": strconv.Itoa(t.IdleWakeups), + "idle_wakeups_per_s": fmt.Sprintf("%.2f", t.IdleWakeupsPerS), + "diskio_bytesread": strconv.FormatInt(t.DiskIOBytesRead, 10), + "diskio_bytesread_per_s": fmt.Sprintf("%.2f", t.DiskIOBytesReadPerS), + "diskio_byteswritten": strconv.FormatInt(t.DiskIOBytesWritten, 10), + "diskio_byteswritten_per_s": fmt.Sprintf("%.2f", t.DiskIOBytesWrittenPerS), + "packets_received": strconv.Itoa(t.PacketsReceived), + "packets_sent": strconv.Itoa(t.PacketsSent), + "bytes_received": strconv.FormatInt(t.BytesReceived, 10), + "bytes_sent": strconv.FormatInt(t.BytesSent, 10), + "interval": strconv.Itoa(interval), + }) + } + + return results, nil +} + +// runPowermetrics executes the powermetrics command and parses the output +func runPowermetrics(r utils.Runner, fs utils.FileSystem, interval int) ([]task, error) { + var output powermetricsOutput + + // Check if powermetrics binary exists + _, err := fs.Stat("/usr/bin/powermetrics") + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + // Run powermetrics command + out, err := r.Runner.RunCmd( + "/usr/bin/powermetrics", + "-f", "plist", + "-n", "1", + "-i", strconv.Itoa(interval), + "--samplers", "tasks", + "--show-process-energy", + ) + if err != nil { + return nil, errors.Wrap(err, "running powermetrics") + } + + // Parse plist output + if err := plist.Unmarshal(out, &output); err != nil { + return nil, errors.Wrap(err, "unmarshalling powermetrics output") + } + + return output.Tasks, nil +} diff --git a/tables/energyimpact/energy_impact_test.go b/tables/energyimpact/energy_impact_test.go new file mode 100644 index 0000000..22cf6f4 --- /dev/null +++ b/tables/energyimpact/energy_impact_test.go @@ -0,0 +1,177 @@ +package energyimpact + +import ( + "context" + _ "embed" + "errors" + "testing" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/osquery/osquery-go/plugin/table" + "github.com/stretchr/testify/assert" +) + +//go:embed test_powermetrics_output.plist +var testPlist string + +func TestEnergyImpactColumns(t *testing.T) { + columns := EnergyImpactColumns() + + // Should return 20 columns + assert.Len(t, columns, 20) + + // Verify column names exist + columnNames := make(map[string]bool) + for _, col := range columns { + columnNames[col.Name] = true + } + + expectedColumns := []string{ + "pid", "name", "energy_impact", "energy_impact_per_s", + "cputime_ns", "cputime_ms_per_s", "cputime_userland_ratio", + "intr_wakeups", "intr_wakeups_per_s", "idle_wakeups", "idle_wakeups_per_s", + "diskio_bytesread", "diskio_bytesread_per_s", + "diskio_byteswritten", "diskio_byteswritten_per_s", + "packets_received", "packets_sent", "bytes_received", "bytes_sent", + "interval", + } + + for _, name := range expectedColumns { + assert.True(t, columnNames[name], "Expected column %s not found", name) + } +} + +func TestEnergyImpactGenerate(t *testing.T) { + // This test verifies that the generate function works with the default context + // Since it requires actual powermetrics execution, we only test the function signature + ctx := context.Background() + queryContext := table.QueryContext{ + Constraints: make(map[string]table.ConstraintList), + } + + // Call the function - it may return empty results if powermetrics isn't available + // or require root, but it shouldn't panic + results, err := EnergyImpactGenerate(ctx, queryContext) + + // The function should return without panicking + // On Linux: powermetrics doesn't exist, returns nil results with no error + // On macOS without root: returns error (requires superuser) + // On macOS with root: returns results + if err != nil { + // Error case (e.g., not running as root on macOS) + assert.Nil(t, results) + } + // If no error, results could be nil (binary not found) or populated (successful run) +} + +func TestRunPowermetrics(t *testing.T) { + tests := []struct { + name string + mockCmd utils.MockCmdRunner + fileExist bool + interval int + wantErr bool + wantTasks int + checkFirst func(t *testing.T, tasks []task) + }{ + { + name: "Binary not present", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: nil, + }, + fileExist: false, + interval: 1000, + wantErr: false, + wantTasks: 0, + }, + { + name: "Successful execution", + mockCmd: utils.MockCmdRunner{ + Output: testPlist, + Err: nil, + }, + fileExist: true, + interval: 1000, + wantErr: false, + wantTasks: 3, + checkFirst: func(t *testing.T, tasks []task) { + // Verify first task (Safari) + assert.Equal(t, 1234, tasks[0].PID) + assert.Equal(t, "Safari", tasks[0].Name) + assert.InDelta(t, 125.5, tasks[0].EnergyImpact, 0.01) + assert.InDelta(t, 12.55, tasks[0].EnergyImpactPerS, 0.01) + assert.Equal(t, int64(500000000), tasks[0].CPUTimeNS) + assert.InDelta(t, 50.5, tasks[0].CPUTimeMSPerS, 0.01) + assert.InDelta(t, 0.75, tasks[0].CPUTimeUserlandRatio, 0.01) + assert.Equal(t, 100, tasks[0].IntrWakeups) + assert.InDelta(t, 10.5, tasks[0].IntrWakeupsPerS, 0.01) + assert.Equal(t, 50, tasks[0].IdleWakeups) + assert.InDelta(t, 5.2, tasks[0].IdleWakeupsPerS, 0.01) + assert.Equal(t, int64(1048576), tasks[0].DiskIOBytesRead) + assert.InDelta(t, 104857.6, tasks[0].DiskIOBytesReadPerS, 0.01) + assert.Equal(t, int64(524288), tasks[0].DiskIOBytesWritten) + assert.InDelta(t, 52428.8, tasks[0].DiskIOBytesWrittenPerS, 0.01) + assert.Equal(t, 200, tasks[0].PacketsReceived) + assert.Equal(t, 150, tasks[0].PacketsSent) + assert.Equal(t, int64(204800), tasks[0].BytesReceived) + assert.Equal(t, int64(102400), tasks[0].BytesSent) + + // Verify DEAD_TASKS entry + assert.Equal(t, -1, tasks[2].PID) + assert.Equal(t, "DEAD_TASKS", tasks[2].Name) + }, + }, + { + name: "Command execution error", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: errors.New("command error"), + }, + fileExist: true, + interval: 1000, + wantErr: true, + wantTasks: 0, + }, + { + name: "Invalid plist output", + mockCmd: utils.MockCmdRunner{ + Output: "invalid plist data", + Err: nil, + }, + fileExist: true, + interval: 1000, + wantErr: true, + wantTasks: 0, + }, + { + name: "Custom interval", + mockCmd: utils.MockCmdRunner{ + Output: testPlist, + Err: nil, + }, + fileExist: true, + interval: 5000, + wantErr: false, + wantTasks: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := utils.Runner{Runner: tt.mockCmd} + fs := utils.MockFileSystem{FileExists: tt.fileExist} + + tasks, err := runPowermetrics(runner, fs, tt.interval) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Len(t, tasks, tt.wantTasks) + if tt.checkFirst != nil && len(tasks) > 0 { + tt.checkFirst(t, tasks) + } + } + }) + } +} diff --git a/tables/energyimpact/test_powermetrics_output.plist b/tables/energyimpact/test_powermetrics_output.plist new file mode 100644 index 0000000..e81c8a3 --- /dev/null +++ b/tables/energyimpact/test_powermetrics_output.plist @@ -0,0 +1,186 @@ + + + + + tasks + + + pid + 1234 + name + Safari + started_abstime_ns + 123456789 + interval_ns + 1000000000 + cputime_ns + 500000000 + cputime_ms_per_s + 50.5 + cputime_userland_ratio + 0.75 + cputime_sample_ms_per_s + 45.2 + intr_wakeups + 100 + intr_wakeups_per_s + 10.5 + idle_wakeups + 50 + idle_wakeups_per_s + 5.2 + timer_wakeups + + interval_ns + 1000000 + wakeups + 25 + wakeups_per_s + 2.5 + + diskio_bytesread + 1048576 + diskio_bytesread_per_s + 104857.6 + diskio_byteswritten + 524288 + diskio_byteswritten_per_s + 52428.8 + pageins + 10 + pageins_per_s + 1.0 + packets_received + 200 + packets_sent + 150 + bytes_received + 204800 + bytes_sent + 102400 + energy_impact + 125.5 + energy_impact_per_s + 12.55 + + + pid + 5678 + name + Chrome + started_abstime_ns + 987654321 + interval_ns + 1000000000 + cputime_ns + 750000000 + cputime_ms_per_s + 75.0 + cputime_userland_ratio + 0.85 + cputime_sample_ms_per_s + 70.1 + intr_wakeups + 200 + intr_wakeups_per_s + 20.0 + idle_wakeups + 80 + idle_wakeups_per_s + 8.0 + timer_wakeups + + interval_ns + 1000000 + wakeups + 40 + wakeups_per_s + 4.0 + + diskio_bytesread + 2097152 + diskio_bytesread_per_s + 209715.2 + diskio_byteswritten + 1048576 + diskio_byteswritten_per_s + 104857.6 + pageins + 20 + pageins_per_s + 2.0 + packets_received + 500 + packets_sent + 300 + bytes_received + 512000 + bytes_sent + 256000 + energy_impact + 250.75 + energy_impact_per_s + 25.075 + + + pid + -1 + name + DEAD_TASKS + started_abstime_ns + 0 + interval_ns + 1000000000 + cputime_ns + 100000000 + cputime_ms_per_s + 10.0 + cputime_userland_ratio + 0.5 + cputime_sample_ms_per_s + 8.5 + intr_wakeups + 30 + intr_wakeups_per_s + 3.0 + idle_wakeups + 15 + idle_wakeups_per_s + 1.5 + timer_wakeups + + interval_ns + 1000000 + wakeups + 5 + wakeups_per_s + 0.5 + + diskio_bytesread + 0 + diskio_bytesread_per_s + 0.0 + diskio_byteswritten + 0 + diskio_byteswritten_per_s + 0.0 + pageins + 0 + pageins_per_s + 0.0 + packets_received + 0 + packets_sent + 0 + bytes_received + 0 + bytes_sent + 0 + energy_impact + 5.25 + energy_impact_per_s + 0.525 + + + +