diff --git a/BUILD.bazel b/BUILD.bazel index b13d2f9..0c9b15b 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -39,6 +39,7 @@ go_library( deps = [ "//tables/alt_system_info", "//tables/authdb", + "//tables/crowdstrike_falcon", "//tables/chromeuserprofiles", "//tables/fileline", "//tables/filevaultusers", diff --git a/README.md b/README.md index 46361f5..d219430 100644 --- a/README.md +++ b/README.md @@ -14,26 +14,27 @@ For production deployment, you should refer to the [osquery documentation](https ## Tables -| Table | Description | Platforms | Notes | -| ------------------------ | --------------------------------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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 | | -| `macos_profiles` | High level information on installed profiles enrollment | macOS | -| `mdm` | Information on the device's MDM enrollment | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher). Due to changes in macOS 12.3, the output of `profiles show -type enrollment` can only be generated once a day. If you are running this command with another tool, you should set the `PROFILES_SHOW_ENROLLMENT_CACHE_PATH` environment variable to the path you are caching this. The cache file should be `json` with the keys `dep_capable` and `rate_limited` present, both booleans representing whether the device is capable of DEP enrollment and whether the response from `profiles show -type enrollment` is being rate limited or not. | -| `munki_info` | Information from the last [Munki](https://github.com/munki/munki) run | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher) | -| `munki_installs` | Items [Munki](https://github.com/munki/munki) is managing | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher) | -| `network_quality` | Output from the `networkQuality` binary | macOS | This binary is only present on macOS 12 | -| `puppet_facts` | [Puppet](https://puppetlabs.com) facts | Linux / macOS / Windows | | -| `puppet_info` | Information on the last [Puppet](https://puppetlabs.com) run | Linux / macOS / Windows | | -| `puppet_logs` | Logs from the last [Puppet](https://puppetlabs.com) run | Linux / macOS / Windows | | -| `puppet_state` | State of every resource [Puppet](https://puppetlabs.com) is managing | Linux / macOS / Windows | | +| Table | Description | Platforms | Notes | +|------------------------------| --------------------------------------------------------------------------------------------- |-------------------------| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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 | | +| `macos_profiles` | High level information on installed profiles enrollment | macOS | +| `mdm` | Information on the device's MDM enrollment | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher). Due to changes in macOS 12.3, the output of `profiles show -type enrollment` can only be generated once a day. If you are running this command with another tool, you should set the `PROFILES_SHOW_ENROLLMENT_CACHE_PATH` environment variable to the path you are caching this. The cache file should be `json` with the keys `dep_capable` and `rate_limited` present, both booleans representing whether the device is capable of DEP enrollment and whether the response from `profiles show -type enrollment` is being rate limited or not. | +| `munki_info` | Information from the last [Munki](https://github.com/munki/munki) run | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher) | +| `munki_installs` | Items [Munki](https://github.com/munki/munki) is managing | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher) | +| `network_quality` | Output from the `networkQuality` binary | macOS | This binary is only present on macOS 12 | +| `puppet_facts` | [Puppet](https://puppetlabs.com) facts | Linux / macOS / Windows | | +| `puppet_info` | Information on the last [Puppet](https://puppetlabs.com) run | Linux / macOS / Windows | | +| `puppet_logs` | Logs from the last [Puppet](https://puppetlabs.com) run | Linux / macOS / Windows | | +| `puppet_state` | State of every resource [Puppet](https://puppetlabs.com) is managing | Linux / macOS / Windows | | | `sofa_security_release_info` | The information on the security release the device is running from [Sofa](https://sofa.macadmins.io) | macOS | Use the `url` constraint to specify a data source other than `https://sofafeed.macadmins.io/v1/macos_data_feed.json` . By default this table will return vulnerability data for the running operating system. For historical data, use the `os_version` predicate (e.g `select * from sofa_security_release_info where os_version="14.4.0";`) | -| `sofa_unpatched_cves` | The CVEs that are unpatched on the device from [Sofa](https://sofa.macadmins.io) | macOS | Use the `url` constraint to specify a data source other than `https://sofafeed.macadmins.io/v1/macos_data_feed.json`. By default this table will return all unpatched vulnerability data. For historical data, use the `os_version` predicate (e.g `select * from sofa_unpatched_cves where os_version="14.4.0";`) | -| `unified_log` | Results from macOS' Unified Log | macOS | Use the constraints `predicate` and `last` to limit the number of results you pull, or this will not be very performant at all. Use `level` with a value of `info` to include info level messages. Use `level` with a value of `debug` to include info and debug level messages. (`select * from unified_log where last="1h" and level="debug" and predicate='processImagePath contains "mdmclient"';`) | -| `wifi_network` | Table to get the current wifi network name since the Osquery `wifi_info` table no longer does this. Includes the rest of the working fields in `wifi_info`. | macOS | See [osquery issue #8220](https://github.com/osquery/osquery/issues/8220) | +| `sofa_unpatched_cves` | The CVEs that are unpatched on the device from [Sofa](https://sofa.macadmins.io) | macOS | Use the `url` constraint to specify a data source other than `https://sofafeed.macadmins.io/v1/macos_data_feed.json`. By default this table will return all unpatched vulnerability data. For historical data, use the `os_version` predicate (e.g `select * from sofa_unpatched_cves where os_version="14.4.0";`) | +| `unified_log` | Results from macOS' Unified Log | macOS | Use the constraints `predicate` and `last` to limit the number of results you pull, or this will not be very performant at all. Use `level` with a value of `info` to include info level messages. Use `level` with a value of `debug` to include info and debug level messages. (`select * from unified_log where last="1h" and level="debug" and predicate='processImagePath contains "mdmclient"';`) | +| `wifi_network` | Table to get the current wifi network name since the Osquery `wifi_info` table no longer does this. Includes the rest of the working fields in `wifi_info`. | macOS | See [osquery issue #8220](https://github.com/osquery/osquery/issues/8220) | ## Development diff --git a/VERSION b/VERSION index c813fe1..3c43790 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.5 +1.2.6 diff --git a/main.go b/main.go index 42d8098..dce765d 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,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/fileline" "github.com/macadmins/osquery-extension/tables/filevaultusers" macosprofiles "github.com/macadmins/osquery-extension/tables/macos_profiles" @@ -76,6 +77,18 @@ func main() { // If there were windows only tables, they would go here // } + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + linuxPlugins := []osquery.OsqueryPlugin{ + table.NewPlugin( + "crowdstrike_falcon", + crowdstrike_falcon.CrowdstrikeFalconColumns(), + func(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + return crowdstrike_falcon.CrowdstrikeFalconGenerate(ctx, queryContext, *flSocketPath) + }), + } + plugins = append(plugins, linuxPlugins...) + } + if runtime.GOOS == "darwin" { darwinPlugins := []osquery.OsqueryPlugin{ table.NewPlugin("filevault_users", filevaultusers.FileVaultUsersColumns(), filevaultusers.FileVaultUsersGenerate), diff --git a/tables/crowdstrike_falcon/BUILD.bazel b/tables/crowdstrike_falcon/BUILD.bazel new file mode 100644 index 0000000..b279a06 --- /dev/null +++ b/tables/crowdstrike_falcon/BUILD.bazel @@ -0,0 +1,25 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "crowdstrike_falcon", + srcs = ["crowdstrike_falcon.go"], + importpath = "github.com/macadmins/osquery-extension/tables/crowdstrike_falcon", + visibility = ["//visibility:public"], + deps = [ + "//pkg/utils", + "@com_github_micromdm_plist//:plist", + "@com_github_osquery_osquery_go//:osquery-go", + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_pkg_errors//:errors", + ], +) + +go_test( + name = "crowdstrike_falcon_test", + srcs = ["crowdstrike_falcon_test.go"], + embed = [":crowdstrike_falcon"], + deps = [ + "//pkg/utils", + "@com_github_stretchr_testify//assert", + ], +) diff --git a/tables/crowdstrike_falcon/crowdstrike_falcon.go b/tables/crowdstrike_falcon/crowdstrike_falcon.go new file mode 100644 index 0000000..c108bbd --- /dev/null +++ b/tables/crowdstrike_falcon/crowdstrike_falcon.go @@ -0,0 +1,147 @@ +package crowdstrike_falcon + +import ( + "context" + "fmt" + "os" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/micromdm/plist" + "github.com/osquery/osquery-go" + "github.com/osquery/osquery-go/plugin/table" + "github.com/pkg/errors" +) + +var falconCtlPath = map[string]string{ + "linux": "/opt/CrowdStrike/falconctl", + "darwin": "/Applications/Falcon.app/Contents/Resources/falconctl", +} + +type CrowdStrikeOutput struct { + AgentID string `plist:"aid"` + CID string `plist:"cid"` + FalconVersion string `plist:"falcon_version"` + ReducedFunctionalityMode bool `plist:"rfm"` + SensorLoaded bool `plist:"sensor_loaded"` +} + +func CrowdstrikeFalconColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn("agent_id"), + table.TextColumn("cid"), + table.TextColumn("falcon_version"), + table.TextColumn("reduced_functionality_mode"), + table.TextColumn("sensor_loaded"), + } +} + +func CrowdstrikeFalconGenerate(ctx context.Context, queryContext table.QueryContext, socketPath string) ([]map[string]string, error) { + var results []map[string]string + r := utils.NewRunner() + fs := utils.OSFileSystem{} + + var output = CrowdStrikeOutput{} + var err error + + switch runtime.GOOS { + case "darwin": + output, err = runCrowdstrikeFalconDarwin(r, fs) + if err != nil { + fmt.Println(err) + return nil, err + } + + case "linux": + osqueryClient, err := osquery.NewClient(socketPath, 10*time.Second) + if err != nil { + return nil, err + } + defer osqueryClient.Close() + output, err = runCrowdstrikeFalconLinux(r, fs, osqueryClient) + if err != nil { + fmt.Println(err) + return nil, err + } + } + + results = append(results, map[string]string{ + "agent_id": strings.ToUpper(output.AgentID), + "cid": strings.ToUpper(output.CID), + "falcon_version": output.FalconVersion, + "reduced_functionality_mode": strconv.FormatBool(output.ReducedFunctionalityMode), + "sensor_loaded": strconv.FormatBool(output.SensorLoaded), + }) + + return results, nil +} + +func runCrowdstrikeFalconLinux(r utils.Runner, fs utils.FileSystem, client utils.OsqueryClient) (CrowdStrikeOutput, error) { + var output CrowdStrikeOutput + + _, err := fs.Stat(falconCtlPath[runtime.GOOS]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return output, nil + } + return output, err + } + + falconProcessQuery := "SELECT 1 FROM processes WHERE name like 'falcon-sensor%';" + loadedState, err := client.QueryRows(falconProcessQuery) + if err != nil { + return output, err + } + + if len(loadedState) == 0 { + output.SensorLoaded = false + } else { + output.SensorLoaded = true + } + + out, err := r.Runner.RunCmd(falconCtlPath[runtime.GOOS], "-g", "--aid", "--cid", "--rfm-state", "--version") + if err != nil { + return output, errors.Wrap(err, falconCtlPath[runtime.GOOS]+" -g --aid --cid --rfm-state --version") + } + + agentIdRegex := regexp.MustCompile(`aid="([a-f0-9]{32})"`) + output.AgentID = agentIdRegex.FindStringSubmatch(strings.ToLower(string(out)))[1] + + cidRegex := regexp.MustCompile(`cid="([a-f0-9]{32})"`) + output.CID = cidRegex.FindStringSubmatch(strings.ToLower(string(out)))[1] + + versionRegex := regexp.MustCompile(`version\s?=\s?(\d\.\d{2}\.\d{5}\.\d)`) + output.FalconVersion = versionRegex.FindStringSubmatch(strings.ToLower(string(out)))[1] + + // as of 7.29, `rfm-state` is always returned on a newline, and always has a trailing comma. + rfmStateRegex := regexp.MustCompile(`rfm-state\s?=\s?(true|false),?`) + output.ReducedFunctionalityMode = rfmStateRegex.FindStringSubmatch(strings.ToLower(string(out)))[1] == "true" + + return output, nil +} + +func runCrowdstrikeFalconDarwin(r utils.Runner, fs utils.FileSystem) (CrowdStrikeOutput, error) { + var output CrowdStrikeOutput + + _, err := fs.Stat(falconCtlPath[runtime.GOOS]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return output, nil + } + return output, err + } + + out, err := r.Runner.RunCmd(falconCtlPath[runtime.GOOS], "info") + if err != nil { + return output, errors.Wrap(err, falconCtlPath[runtime.GOOS]+" info") + } + if err := plist.Unmarshal(out, &output); err != nil { + return output, errors.Wrap(err, "unmarshalling falconctl output") + } + + return output, nil +} diff --git a/tables/crowdstrike_falcon/crowdstrike_falcon_test.go b/tables/crowdstrike_falcon/crowdstrike_falcon_test.go new file mode 100644 index 0000000..3c500b2 --- /dev/null +++ b/tables/crowdstrike_falcon/crowdstrike_falcon_test.go @@ -0,0 +1,192 @@ +package crowdstrike_falcon + +import ( + "testing" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestRunCrowdstrikeFalconDarwin(t *testing.T) { + tests := []struct { + name string + mockCmd utils.MockCmdRunner + fileExist bool + wantErr bool + }{ + { + name: "Binary not present", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: nil, + }, + fileExist: false, + wantErr: false, + }, + { + name: "Command execution error", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: errors.New("command error"), + }, + fileExist: true, + wantErr: true, + }, + { + name: "Successful execution", + mockCmd: utils.MockCmdRunner{ + Output: ` + + + + aid + F3EDC954D286243B5BD94130C2F2647D + cid + 79391C24113773B01D8181C38C3E111A + falcon_version + 7.26.19707.0 + rfm + + sensor_loaded + + +`, + Err: nil, + }, + fileExist: true, + wantErr: false, + }, + { + name: "PLIST unmarshal error", + mockCmd: utils.MockCmdRunner{ + Output: `invalid plist`, + Err: nil, + }, + fileExist: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := utils.Runner{Runner: tt.mockCmd} + fs := utils.MockFileSystem{FileExists: tt.fileExist} + + output, err := runCrowdstrikeFalconDarwin(runner, fs) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.fileExist { + assert.NotEmpty(t, output) + if tt.name == "Successful execution" { + expectedOutput := CrowdStrikeOutput{ + AgentID: "F3EDC954D286243B5BD94130C2F2647D", + CID: "79391C24113773B01D8181C38C3E111A", + FalconVersion: "7.26.19707.0", + ReducedFunctionalityMode: true, + SensorLoaded: true} + assert.Equal(t, expectedOutput, output) + } + } else { + assert.Empty(t, output) + } + } + }) + } +} + +func TestRunCrowdstrikeFalconLinux(t *testing.T) { + tests := []struct { + name string + mockCmd utils.MockCmdRunner + fileExist bool + wantErr bool + mockOsqData map[string][]map[string]string + }{ + { + name: "Binary not present", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: nil, + }, + fileExist: false, + wantErr: false, + }, + { + name: "Command execution error", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: errors.New("command error"), + }, + fileExist: true, + wantErr: true, + }, + { + name: "Successful execution (with loaded sensor)", + mockCmd: utils.MockCmdRunner{ + Output: `cid="79391c24113773b01d8181c38c3e111a", aid="f3edc954d286243b5bd94130c2f2647d", version = 7.29.18202.0 +rfm-state=true,`, + Err: nil, + }, + fileExist: true, + wantErr: false, + mockOsqData: map[string][]map[string]string{ + "SELECT 1 FROM processes WHERE name like 'falcon-sensor%';": {{"1": "1"}}, + }, + }, + { + name: "Successful execution (with unloaded sensor)", + mockCmd: utils.MockCmdRunner{ + Output: `cid="79391c24113773b01d8181c38c3e111a", aid="f3edc954d286243b5bd94130c2f2647d", version = 7.29.18202.0 +rfm-state=true,`, + Err: nil, + }, + fileExist: true, + wantErr: false, + mockOsqData: map[string][]map[string]string{ + "SELECT 1 FROM processes WHERE name like 'falcon-sensor%';": {}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := utils.Runner{Runner: tt.mockCmd} + fs := utils.MockFileSystem{FileExists: tt.fileExist} + mockOsqueryClient := &utils.MockOsqueryClient{ + Data: tt.mockOsqData, + } + + output, err := runCrowdstrikeFalconLinux(runner, fs, mockOsqueryClient) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.fileExist { + assert.NotEmpty(t, output) + if tt.name == "Successful execution (with loaded sensor)" { + expectedOutput := CrowdStrikeOutput{ + AgentID: "f3edc954d286243b5bd94130c2f2647d", + CID: "79391c24113773b01d8181c38c3e111a", + FalconVersion: "7.29.18202.0", + ReducedFunctionalityMode: true, + SensorLoaded: true} + assert.Equal(t, expectedOutput, output) + } else if tt.name == "Successful execution (with unloaded sensor)" { + expectedOutput := CrowdStrikeOutput{ + AgentID: "f3edc954d286243b5bd94130c2f2647d", + CID: "79391c24113773b01d8181c38c3e111a", + FalconVersion: "7.29.18202.0", + ReducedFunctionalityMode: true, + SensorLoaded: false} + assert.Equal(t, expectedOutput, output) + } + } else { + assert.Empty(t, output) + } + } + }) + } +}