diff --git a/BUILD.bazel b/BUILD.bazel index c3b8b2e..8e47ef6 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -39,6 +39,7 @@ go_library( deps = [ "//tables/alt_system_info", "//tables/authdb", + "//tables/carafe", "//tables/chromeuserprofiles", "//tables/crowdstrike_falcon", "//tables/energyimpact", diff --git a/README.md b/README.md index ba6ee57..775a25e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ 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. | +| `carafe_homebrew_formulae` | Installed Homebrew formula inventory written by Carafe | macOS | Reads `/Library/Application Support/MacAdmins/Carafe/homebrew_formulae.json`, produced by `carafe inventory homebrew`. | | `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: 1000). | | `file_lines` | Read an arbitrary file | Linux / macOS / Windows | Use the constraint `path` and `last` to specify the file to read lines from | diff --git a/main.go b/main.go index 7e9f7b5..3aed09f 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,7 @@ import ( "github.com/macadmins/osquery-extension/tables/wifi_network" "github.com/macadmins/osquery-extension/tables/authdb" + "github.com/macadmins/osquery-extension/tables/carafe" osquery "github.com/osquery/osquery-go" "github.com/osquery/osquery-go/plugin/table" ) @@ -127,6 +128,7 @@ func main() { ), table.NewPlugin("macos_thermal_pressure", thermalthrottling.ThermalPressureColumns(), thermalthrottling.ThermalPressureGenerate), table.NewPlugin("macos_soc_power", socpower.SocPowerColumns(), socpower.SocPowerGenerate), + table.NewPlugin("carafe_homebrew_formulae", carafe.HomebrewFormulaeColumns(), carafe.HomebrewFormulaeGenerate), } plugins = append(plugins, darwinPlugins...) } diff --git a/tables/carafe/BUILD.bazel b/tables/carafe/BUILD.bazel new file mode 100644 index 0000000..48f7b10 --- /dev/null +++ b/tables/carafe/BUILD.bazel @@ -0,0 +1,23 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "carafe", + srcs = ["homebrew_formulae.go"], + importpath = "github.com/macadmins/osquery-extension/tables/carafe", + visibility = ["//visibility:public"], + deps = [ + "//pkg/utils", + "@com_github_osquery_osquery_go//plugin/table", + ], +) + +go_test( + name = "carafe_test", + srcs = ["homebrew_formulae_test.go"], + embed = [":carafe"], + deps = [ + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/tables/carafe/homebrew_formulae.go b/tables/carafe/homebrew_formulae.go new file mode 100644 index 0000000..5de15a7 --- /dev/null +++ b/tables/carafe/homebrew_formulae.go @@ -0,0 +1,104 @@ +package carafe + +import ( + "context" + "encoding/json" + "os" + "strconv" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/osquery/osquery-go/plugin/table" +) + +var homebrewFormulaePath = "/Library/Application Support/MacAdmins/Carafe/homebrew_formulae.json" + +type homebrewInventory struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt string `json:"generated_at"` + BrewPath string `json:"brew_path"` + Arch string `json:"arch"` + Formulae []homebrewFormulaeRow `json:"formulae"` +} + +type homebrewFormulaeRow struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Tap string `json:"tap"` + InstalledVersion string `json:"installed_version"` + InstalledOnRequest bool `json:"installed_on_request"` + SourceURL string `json:"source_url"` + Homepage string `json:"homepage"` + License string `json:"license"` + Checksum string `json:"checksum"` + Outdated bool `json:"outdated"` +} + +func HomebrewFormulaeColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn("name"), + table.TextColumn("full_name"), + table.TextColumn("tap"), + table.TextColumn("installed_version"), + table.TextColumn("installed_on_request"), + table.TextColumn("source_url"), + table.TextColumn("homepage"), + table.TextColumn("license"), + table.TextColumn("checksum"), + table.TextColumn("outdated"), + table.TextColumn("generated_at"), + table.TextColumn("brew_path"), + table.TextColumn("arch"), + table.TextColumn("schema_version"), + } +} + +func HomebrewFormulaeGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + fs := utils.OSFileSystem{} + inventory, err := loadHomebrewFormulaeInventory(fs, homebrewFormulaePath) + if err != nil { + return nil, err + } + if inventory == nil { + return nil, nil + } + + var results []map[string]string + for _, formula := range inventory.Formulae { + results = append(results, map[string]string{ + "name": formula.Name, + "full_name": formula.FullName, + "tap": formula.Tap, + "installed_version": formula.InstalledVersion, + "installed_on_request": strconv.FormatBool(formula.InstalledOnRequest), + "source_url": formula.SourceURL, + "homepage": formula.Homepage, + "license": formula.License, + "checksum": formula.Checksum, + "outdated": strconv.FormatBool(formula.Outdated), + "generated_at": inventory.GeneratedAt, + "brew_path": inventory.BrewPath, + "arch": inventory.Arch, + "schema_version": inventory.SchemaVersion, + }) + } + + return results, nil +} + +func loadHomebrewFormulaeInventory(fs utils.FileSystem, path string) (*homebrewInventory, error) { + if !utils.FileExists(fs, path) { + return nil, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var inventory homebrewInventory + if err := json.Unmarshal(data, &inventory); err != nil { + return nil, err + } + + return &inventory, nil +} diff --git a/tables/carafe/homebrew_formulae_test.go b/tables/carafe/homebrew_formulae_test.go new file mode 100644 index 0000000..e9ca4f5 --- /dev/null +++ b/tables/carafe/homebrew_formulae_test.go @@ -0,0 +1,92 @@ +package carafe + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/osquery/osquery-go/plugin/table" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testHomebrewFormulaeInventory = `{ + "schema_version": "1", + "generated_at": "2026-06-01T12:30:00Z", + "brew_path": "/opt/homebrew/bin/brew", + "arch": "arm64", + "formulae": [ + { + "name": "git", + "full_name": "git", + "tap": "homebrew/core", + "installed_version": "2.50.0", + "installed_on_request": true, + "source_url": "https://www.kernel.org/pub/software/scm/git/git-2.50.1.tar.xz", + "homepage": "https://git-scm.com", + "license": "GPL-2.0-only", + "checksum": "abc123", + "outdated": true + } + ] +}` + +func TestHomebrewFormulaeGenerate(t *testing.T) { + homebrewFormulaePath = filepath.Join(t.TempDir(), "homebrew_formulae.json") + require.NoError(t, os.WriteFile(homebrewFormulaePath, []byte(testHomebrewFormulaeInventory), 0600)) + + rows, err := HomebrewFormulaeGenerate(context.Background(), table.QueryContext{}) + + require.NoError(t, err) + expectedRows := []map[string]string{ + { + "name": "git", + "full_name": "git", + "tap": "homebrew/core", + "installed_version": "2.50.0", + "installed_on_request": "true", + "source_url": "https://www.kernel.org/pub/software/scm/git/git-2.50.1.tar.xz", + "homepage": "https://git-scm.com", + "license": "GPL-2.0-only", + "checksum": "abc123", + "outdated": "true", + "generated_at": "2026-06-01T12:30:00Z", + "brew_path": "/opt/homebrew/bin/brew", + "arch": "arm64", + "schema_version": "1", + }, + } + assert.Equal(t, expectedRows, rows) +} + +func TestHomebrewFormulaeGenerateMissingFile(t *testing.T) { + homebrewFormulaePath = filepath.Join(t.TempDir(), "missing.json") + + rows, err := HomebrewFormulaeGenerate(context.Background(), table.QueryContext{}) + + require.NoError(t, err) + assert.Nil(t, rows) +} + +func TestHomebrewFormulaeGenerateInvalidJSON(t *testing.T) { + homebrewFormulaePath = filepath.Join(t.TempDir(), "homebrew_formulae.json") + require.NoError(t, os.WriteFile(homebrewFormulaePath, []byte("{"), 0600)) + + rows, err := HomebrewFormulaeGenerate(context.Background(), table.QueryContext{}) + + assert.Nil(t, rows) + assert.Error(t, err) +} + +func TestHomebrewFormulaeColumns(t *testing.T) { + columns := HomebrewFormulaeColumns() + + names := make([]string, 0, len(columns)) + for _, column := range columns { + names = append(names, column.Name) + } + assert.Contains(t, names, "name") + assert.Contains(t, names, "installed_version") + assert.Contains(t, names, "generated_at") +}