From 2d94be26596f72546c69bbaa22eed63ce10eea19 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 1 Jun 2026 21:33:47 -0400 Subject: [PATCH 1/7] Add touchid_system_config and touchid_user_config tables Two macOS Touch ID tables populated from bioutil(1) and ioreg(8): - touchid_system_config: machine-wide Touch ID / Secure Enclave posture (touchid_compatible, secure_enclave, touchid_enabled, touchid_unlock) plus two sensor-presence columns: * touchid_builtin -- a built-in Touch ID sensor exists (laptops), detected via an AppleBiometricSensor IORegistry node. * touchid_sensor_present -- a usable sensor exists: built-in OR an attached external Touch ID accessory (e.g. a Magic Keyboard with Touch ID), the latter detected via the AppleMesaAccessory class. Why the new columns: touchid_compatible / touchid_enabled come from bioutil's "Biometrics functionality" flag, which reports the on-die Secure Enclave -- true on every Apple Silicon Mac, including a keyboard-less Mac mini/Studio that has no fingerprint sensor at all. touchid_sensor_present is the signal callers actually want for "can this user enroll a fingerprint." AppleMesaAccessory is a capability class, not a product-string match, so it is name-, transport- (USB/Bluetooth), and localization-independent, and a non-Touch-ID keyboard correctly reads as no sensor. Verified on hardware across MacBook (built-in), Mac Studio + Touch ID keyboard, keyboard-less Mac mini/Studio, and an iMac with a disconnected Touch ID keyboard. - touchid_user_config: per-user Touch ID configuration and enrolled fingerprint count (uid, fingerprints_registered, touchid_unlock, touchid_applepay, effective_unlock, effective_applepay). Without a WHERE uid = constraint it returns a row per real local account. fingerprints_registered comes from `bioutil -c -s` (needs root, reads all users at once); the config flags come from `bioutil -r` run in the user's login session via launchctl asuser (needs the user logged in) and are left NULL when unavailable rather than 0, so an enabled-but- logged-out user is not misreported as disabled. Both tables shell out via the injected utils.CmdRunner and are unit-tested with MultiMockCmdRunner (no real binaries invoked); coverage 74.3%. ioreg is parsed as plist (ioreg -a) per the alt_system_info convention. Apple Silicon only. Co-Authored-By: Claude Opus 4.8 (1M context) --- BUILD.bazel | 1 + README.md | 2 + VERSION | 2 +- main.go | 3 + tables/touchid/BUILD.bazel | 24 ++ tables/touchid/touchid.go | 419 +++++++++++++++++++++++++++++++++ tables/touchid/touchid_test.go | 275 ++++++++++++++++++++++ 7 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 tables/touchid/BUILD.bazel create mode 100644 tables/touchid/touchid.go create mode 100644 tables/touchid/touchid_test.go diff --git a/BUILD.bazel b/BUILD.bazel index c3b8b2e..e1e33c8 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -55,6 +55,7 @@ go_library( "//tables/socpower", "//tables/sofa", "//tables/thermalthrottling", + "//tables/touchid", "//tables/unifiedlog", "//tables/wifi_network", "@com_github_osquery_osquery_go//:osquery-go", diff --git a/README.md b/README.md index ba6ee57..acbfd92 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ For production deployment, you should refer to the [osquery documentation](https | `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";`) | +| `touchid_system_config` | Machine-wide Touch ID / Secure Enclave posture (`touchid_compatible`, `secure_enclave`, `touchid_enabled`, `touchid_unlock`) plus `touchid_builtin` (a built-in Touch ID sensor exists — laptops) and `touchid_sensor_present` (a usable sensor exists: built-in OR an attached Magic Keyboard with Touch ID). From `bioutil` and `ioreg`. | macOS | Apple Silicon only. `touchid_compatible`/`touchid_enabled` are `1` on every Apple Silicon Mac (the Secure Enclave is on-die), so use `touchid_sensor_present` to tell a Mac that can actually enroll a fingerprint from a keyboard-less Mac mini/Studio. The accessory half of `touchid_sensor_present` is a live signal — a disconnected Touch ID keyboard reads `0`. | +| `touchid_user_config` | Per-user Touch ID configuration and enrolled fingerprint count (`uid`, `fingerprints_registered`, `touchid_unlock`, `touchid_applepay`, `effective_unlock`, `effective_applepay`). From `bioutil`. | macOS | Apple Silicon only. Without a `WHERE uid =` constraint, returns a row per real local account. `fingerprints_registered` needs root (reads all users via `bioutil -c -s`); the config flags need the user logged in (`bioutil -r` via `launchctl asuser`) and are left empty (NULL) when unavailable rather than `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) | diff --git a/VERSION b/VERSION index 347f583..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.5.0 diff --git a/main.go b/main.go index 7e9f7b5..834a8b9 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "github.com/macadmins/osquery-extension/tables/socpower" "github.com/macadmins/osquery-extension/tables/sofa" "github.com/macadmins/osquery-extension/tables/thermalthrottling" + "github.com/macadmins/osquery-extension/tables/touchid" "github.com/macadmins/osquery-extension/tables/unifiedlog" "github.com/macadmins/osquery-extension/tables/wifi_network" @@ -127,6 +128,8 @@ func main() { ), table.NewPlugin("macos_thermal_pressure", thermalthrottling.ThermalPressureColumns(), thermalthrottling.ThermalPressureGenerate), table.NewPlugin("macos_soc_power", socpower.SocPowerColumns(), socpower.SocPowerGenerate), + table.NewPlugin("touchid_system_config", touchid.TouchIDSystemConfigColumns(), touchid.TouchIDSystemConfigGenerate), + table.NewPlugin("touchid_user_config", touchid.TouchIDUserConfigColumns(), touchid.TouchIDUserConfigGenerate), } plugins = append(plugins, darwinPlugins...) } diff --git a/tables/touchid/BUILD.bazel b/tables/touchid/BUILD.bazel new file mode 100644 index 0000000..7007a4a --- /dev/null +++ b/tables/touchid/BUILD.bazel @@ -0,0 +1,24 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "touchid", + srcs = ["touchid.go"], + importpath = "github.com/macadmins/osquery-extension/tables/touchid", + visibility = ["//visibility:public"], + deps = [ + "//pkg/utils", + "@com_github_micromdm_plist//:plist", + "@com_github_osquery_osquery_go//plugin/table", + ], +) + +go_test( + name = "touchid_test", + srcs = ["touchid_test.go"], + embed = [":touchid"], + deps = [ + "//pkg/utils", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/tables/touchid/touchid.go b/tables/touchid/touchid.go new file mode 100644 index 0000000..e053318 --- /dev/null +++ b/tables/touchid/touchid.go @@ -0,0 +1,419 @@ +// Package touchid implements two osquery tables that report macOS Touch ID +// state, populated from bioutil(1) and ioreg(8): +// +// - touchid_system_config: machine-wide Touch ID / Secure Enclave posture, +// plus whether a usable fingerprint sensor (built-in or an attached Touch +// ID accessory) is actually present. +// - touchid_user_config: per-user Touch ID configuration and the number of +// enrolled fingerprints. +// +// Apple Silicon only. bioutil reports Touch ID state; the sensor-presence +// columns come from the IORegistry. Both tables shell out via an injected +// utils.CmdRunner so they are unit-testable without touching real binaries. +package touchid + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/user" + "strconv" + "strings" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/micromdm/plist" + "github.com/osquery/osquery-go/plugin/table" +) + +const ( + bioutilPath = "/usr/bin/bioutil" + ioregPath = "/usr/sbin/ioreg" + dsclPath = "/usr/bin/dscl" +) + +// minHumanUID / maxHumanUID bound real (non-system) local accounts. macOS +// reserves uids below 500 for system/service accounts; the first human account +// is 501. 60000+ are transient/Setup Assistant accounts. +const ( + minHumanUID = 501 + maxHumanUID = 60000 +) + +// parseBioutil parses the "Label: value" lines emitted by `bioutil -r` / +// `bioutil -r -s` into a map keyed by label. Keying on the label text (rather +// than line position) keeps the tables correct on macOS releases that add +// configuration lines. Section headers ("System Touch ID configuration:") and +// the trailing "Operation performed successfully." line have no value after the +// colon and are skipped. +func parseBioutil(out []byte) map[string]string { + fields := make(map[string]string) + s := bufio.NewScanner(bytes.NewReader(out)) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + if key == "" || val == "" { + continue + } + fields[key] = val + } + return fields +} + +// boolField returns "1" if the named bioutil field equals "1", else "0". +// bioutil reports these flags as the literal characters 0/1. +func boolField(fields map[string]string, key string) string { + if fields[key] == "1" { + return "1" + } + return "0" +} + +// boolValue renders a Go bool as osquery's "1"/"0" integer-column convention. +func boolValue(b bool) string { + if b { + return "1" + } + return "0" +} + +// ioregClassPresent reports whether the IORegistry contains at least one +// instance of the given class. `ioreg -a -r -c ` emits a plist array of +// matched nodes, or empty output when the class has no instances. We unmarshal +// to a slice and check its length; empty/unparseable output is treated as "not +// present". +func ioregClassPresent(cmder utils.CmdRunner, class string) (bool, error) { + buf, err := cmder.RunCmd(ioregPath, "-a", "-r", "-c", class) + if err != nil { + return false, fmt.Errorf("could not run ioreg for %s: %w", class, err) + } + if len(bytes.TrimSpace(buf)) == 0 { + // No instances: ioreg prints nothing. + return false, nil + } + var nodes []map[string]interface{} + if err := plist.Unmarshal(buf, &nodes); err != nil { + // Unexpected shape — be conservative and report not present rather than + // erroring the whole table. + return false, nil + } + return len(nodes) > 0, nil +} + +// SystemConfig holds the machine-wide Touch ID posture for one host. +type SystemConfig struct { + Compatible string // touchid_compatible: bioutil reports Secure Enclave biometric support + SecureEnclave string // secure_enclave: SoC model identifier + Enabled string // touchid_enabled + Unlock string // touchid_unlock + Builtin string // touchid_builtin: a built-in AppleBiometricSensor node exists (laptops) + SensorPresent string // touchid_sensor_present: built-in OR an attached Touch ID accessory +} + +// chipModelFromSPiBridge extracts the SoC model identifier (e.g. "Mac16,5") +// from `ioreg -a ... AppleARMPE`... but bioutil already covers compatibility, +// so secure_enclave is sourced from system_profiler SPiBridgeDataType, parsed +// here from its plain-text "Model Identifier:" line. +func chipModelFromSPiBridge(out []byte) string { + s := bufio.NewScanner(bytes.NewReader(out)) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + const k = "Model Identifier:" + if strings.HasPrefix(line, k) { + return strings.TrimSpace(strings.TrimPrefix(line, k)) + } + } + return "" +} + +// GetSystemConfig builds the single touchid_system_config row. bioutil supplies +// the compatibility/enabled/unlock flags; ioreg supplies the hardware-presence +// flags. The two ioreg-derived columns are independent of bioutil, so they are +// always populated even when bioutil fails. +func GetSystemConfig(cmder utils.CmdRunner) (*SystemConfig, error) { + cfg := &SystemConfig{ + Compatible: "0", + Enabled: "0", + Unlock: "0", + Builtin: "0", + SensorPresent: "0", + } + + if out, err := cmder.RunCmd("/usr/sbin/system_profiler", "SPiBridgeDataType"); err == nil { + cfg.SecureEnclave = chipModelFromSPiBridge(out) + } + + if out, err := cmder.RunCmd(bioutilPath, "-r", "-s"); err == nil { + fields := parseBioutil(out) + if _, ok := fields["Biometrics functionality"]; ok { + cfg.Compatible = "1" + } + cfg.Enabled = boolField(fields, "Biometrics functionality") + cfg.Unlock = boolField(fields, "Biometrics for unlock") + } + + // Built-in sensor: laptops expose one or more AppleBiometricSensor nodes; + // keyboard-less desktops expose none. NOTE: touchid_compatible above is "1" + // on every Apple Silicon Mac (the Secure Enclave is on-die), so it cannot + // distinguish a Mac that has a fingerprint sensor from a keyboard-less + // desktop — that is what touchid_builtin / touchid_sensor_present are for. + builtin, err := ioregClassPresent(cmder, "AppleBiometricSensor") + if err != nil { + return nil, err + } + cfg.Builtin = boolValue(builtin) + + // Any usable sensor: built-in OR an attached external Touch ID sensor (e.g. + // a Magic Keyboard with Touch ID), which registers no AppleBiometricSensor + // node but does register an AppleMesaAccessory node ("Mesa" is Apple's + // codename for the Touch ID sensor subsystem; the "Accessory" suffix denotes + // an external sensor). This is a capability class, not a product-string + // match, so a non-Touch-ID keyboard correctly reads as no sensor. The + // sibling classes AppleMesaSEPDriver / AppleMesaResources are NOT usable for + // this — they are SEP scaffolding present on every Apple Silicon Mac. + sensorPresent := builtin + if !sensorPresent { + mesa, err := ioregClassPresent(cmder, "AppleMesaAccessory") + if err != nil { + return nil, err + } + sensorPresent = mesa + } + cfg.SensorPresent = boolValue(sensorPresent) + + return cfg, nil +} + +// TouchIDSystemConfigColumns is the schema for touchid_system_config. +func TouchIDSystemConfigColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.IntegerColumn("touchid_compatible"), + table.TextColumn("secure_enclave"), + table.IntegerColumn("touchid_enabled"), + table.IntegerColumn("touchid_unlock"), + table.IntegerColumn("touchid_builtin"), + table.IntegerColumn("touchid_sensor_present"), + } +} + +// TouchIDSystemConfigGenerate is the osquery generate function for +// touchid_system_config. +func TouchIDSystemConfigGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + cfg, err := GetSystemConfig(utils.NewRunner().Runner) + if err != nil { + return nil, err + } + return []map[string]string{{ + "touchid_compatible": cfg.Compatible, + "secure_enclave": cfg.SecureEnclave, + "touchid_enabled": cfg.Enabled, + "touchid_unlock": cfg.Unlock, + "touchid_builtin": cfg.Builtin, + "touchid_sensor_present": cfg.SensorPresent, + }}, nil +} + +// parseFingerprintCounts extracts enrolled-template counts from `bioutil -c` +// (single user) or `bioutil -c -s` (all enrolled users, root) output, keyed by +// uid. Lines look like "User 501:\t1 biometric template(s)". Users with zero +// enrolled templates do not appear in `-c -s` output, so a uid absent from the +// returned map should be treated as count 0 when the counts are known. +func parseFingerprintCounts(out []byte) map[string]int { + counts := make(map[string]int) + s := bufio.NewScanner(bytes.NewReader(out)) + for s.Scan() { + fields := strings.Fields(s.Text()) + if len(fields) < 4 || fields[0] != "User" { + continue + } + uid := strings.TrimSuffix(fields[1], ":") + if _, err := strconv.Atoi(uid); err != nil { + continue + } + for i, f := range fields { + if strings.HasPrefix(f, "biometric") && i > 0 { + if n, err := strconv.Atoi(fields[i-1]); err == nil { + counts[uid] = n + } + break + } + } + } + return counts +} + +// parseLocalUIDs parses `dscl . -list /Users UniqueID` (two columns: account +// name, uid) and returns the uids of real local accounts within the human-uid +// range. +func parseLocalUIDs(out []byte) []string { + var uids []string + s := bufio.NewScanner(bytes.NewReader(out)) + for s.Scan() { + fields := strings.Fields(s.Text()) + if len(fields) != 2 { + continue + } + n, err := strconv.Atoi(fields[1]) + if err != nil || n < minHumanUID || n > maxHumanUID { + continue + } + uids = append(uids, fields[1]) + } + return uids +} + +// uidExists is injected so tests don't depend on real local accounts. +type uidExists func(uid string) bool + +func defaultUIDExists(uid string) bool { + _, err := user.LookupId(uid) + return err == nil +} + +// UserConfig is one touchid_user_config row. +type UserConfig struct { + UID string + FingerprintsRegistered string // empty when unknown (e.g. -c -s could not run) + Unlock string // empty when unknown (user not logged in) + ApplePay string + EffectiveUnlock string + EffectiveApplePay string +} + +// GetUserConfigs builds touchid_user_config rows. Two bioutil data sources with +// different access models are combined: +// +// - Enrolled fingerprint count: `bioutil -c -s` (run as root, the context +// osquery's extension runner provides) reports counts for all enrolled +// users at once and does not require the user to be logged in. +// - Config flags: `bioutil -r` must run inside the target user's login +// session, which only exists while that user is logged in. When it cannot +// be read the flag columns are left empty (unknown) rather than "0", so an +// enabled-but-logged-out user is not misreported as disabled. +// +// targetUIDs, when non-empty, restricts the rows (from a `WHERE uid =` +// constraint); otherwise every real local account is reported. perUserRunner +// runs `bioutil -r` as a given uid; it is injected for testability. +func GetUserConfigs( + cmder utils.CmdRunner, + exists uidExists, + targetUIDs []string, + perUserRunner func(uid string) ([]byte, error), +) ([]*UserConfig, error) { + counts := map[string]int{} + countsKnown := false + if out, err := cmder.RunCmd(bioutilPath, "-c", "-s"); err == nil { + counts = parseFingerprintCounts(out) + countsKnown = true + } + + uids := targetUIDs + if len(uids) == 0 { + if out, err := cmder.RunCmd(dsclPath, ".", "-list", "/Users", "UniqueID"); err == nil { + uids = parseLocalUIDs(out) + } + } + + var results []*UserConfig + for _, uid := range uids { + if _, err := strconv.Atoi(uid); err != nil || !exists(uid) { + continue + } + + row := &UserConfig{UID: uid} + + count, hasCount := counts[uid] + if countsKnown { + hasCount = true // absent from -c -s output means 0 enrolled + } + if hasCount { + row.FingerprintsRegistered = strconv.Itoa(count) + } + + if out, err := perUserRunner(uid); err == nil { + fields := parseBioutil(out) + row.Unlock = boolField(fields, "Biometrics for unlock") + row.ApplePay = boolField(fields, "Biometrics for ApplePay") + row.EffectiveUnlock = boolField(fields, "Effective biometrics for unlock") + row.EffectiveApplePay = boolField(fields, "Effective biometrics for ApplePay") + + // bioutil's "Effective" flags can report 1 with no fingerprints + // enrolled. Only correct this when the count is known to be 0. + if hasCount && count == 0 { + row.EffectiveUnlock = "0" + row.EffectiveApplePay = "0" + } + } + + results = append(results, row) + } + + return results, nil +} + +// TouchIDUserConfigColumns is the schema for touchid_user_config. +func TouchIDUserConfigColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.IntegerColumn("uid"), + table.IntegerColumn("fingerprints_registered"), + table.IntegerColumn("touchid_unlock"), + table.IntegerColumn("touchid_applepay"), + table.IntegerColumn("effective_unlock"), + table.IntegerColumn("effective_applepay"), + } +} + +// uidConstraints extracts the values of all `uid =` constraints from the query. +func uidConstraints(queryContext table.QueryContext) []string { + var uids []string + if c, ok := queryContext.Constraints["uid"]; ok { + for _, con := range c.Constraints { + if con.Operator == table.OperatorEquals { + uids = append(uids, con.Expression) + } + } + } + return uids +} + +// defaultPerUserRunner runs `bioutil -r` inside the target uid's login session +// via `launchctl asuser`, which is required because per-user Touch ID config +// lives in that user's Secure Enclave keybag context. +func defaultPerUserRunner(cmder utils.CmdRunner) func(uid string) ([]byte, error) { + return func(uid string) ([]byte, error) { + return cmder.RunCmd("/bin/launchctl", "asuser", uid, bioutilPath, "-r") + } +} + +// TouchIDUserConfigGenerate is the osquery generate function for +// touchid_user_config. +func TouchIDUserConfigGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + runner := utils.NewRunner().Runner + configs, err := GetUserConfigs(runner, defaultUIDExists, uidConstraints(queryContext), defaultPerUserRunner(runner)) + if err != nil { + return nil, err + } + + var results []map[string]string + for _, c := range configs { + row := map[string]string{"uid": c.UID} + // Only set keys we actually know; an unknown IntegerColumn must be + // omitted (NULL) rather than set to an empty/zero value. + if c.FingerprintsRegistered != "" { + row["fingerprints_registered"] = c.FingerprintsRegistered + } + if c.Unlock != "" { + row["touchid_unlock"] = c.Unlock + row["touchid_applepay"] = c.ApplePay + row["effective_unlock"] = c.EffectiveUnlock + row["effective_applepay"] = c.EffectiveApplePay + } + results = append(results, row) + } + return results, nil +} diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go new file mode 100644 index 0000000..69b21a6 --- /dev/null +++ b/tables/touchid/touchid_test.go @@ -0,0 +1,275 @@ +package touchid + +import ( + "errors" + "testing" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Real `bioutil -r -s` output on Apple Silicon (macOS 15+). The extra timeout +// lines would trip position-based parsing. +const systemBioutil = `System Touch ID configuration: + Biometrics functionality: 1 + Biometrics for unlock: 1 + Biometric timeout (in seconds): 172800 + Match timeout (in seconds): 14400 + Passcode input timeout (in seconds): 561600 +Operation performed successfully.` + +const userBioutil = `User Touch ID configuration: + Biometrics for unlock: 1 + Biometrics for ApplePay: 1 + Effective biometrics for unlock: 1 + Effective biometrics for ApplePay: 1 +Operation performed successfully.` + +// `bioutil -c -s` (root): one line per enrolled user. +const allCounts = "User 501:\t1 biometric template(s)\nUser 503:\t2 biometric template(s)\nOperation performed successfully." + +const spiBridge = `Controller: + + Model Identifier: Mac16,5 + Firmware Version: mBoot-18000.120.36` + +const dsclUsers = "_mbsetupuser 248\nroot 0\nalice 501\nbob 503\n" + +// A minimal `ioreg -a -r -c ` plist: an array with one matched node. +// ioreg emits a plist array of matched IORegistry entries; for presence we only +// need len(array) > 0. +const ioregOneNode = ` + + + + + IOClass + AppleMesaAccessory + + +` + +// `ioreg -a -r -c ` for a class with no instances: empty output. +const ioregNoNode = `` + +func TestParseBioutil(t *testing.T) { + t.Parallel() + f := parseBioutil([]byte(systemBioutil)) + assert.Equal(t, "1", f["Biometrics functionality"]) + assert.Equal(t, "1", f["Biometrics for unlock"]) + _, hasHeader := f["System Touch ID configuration"] + assert.False(t, hasHeader, "header line should not parse as a field") + _, hasFooter := f["Operation performed successfully"] + assert.False(t, hasFooter, "footer line should not parse as a field") +} + +func TestParseFingerprintCounts(t *testing.T) { + t.Parallel() + got := parseFingerprintCounts([]byte(allCounts)) + assert.Equal(t, map[string]int{"501": 1, "503": 2}, got) + + single := parseFingerprintCounts([]byte("User 501:\t3 biometric template(s)\nOperation performed successfully.")) + assert.Equal(t, 3, single["501"]) + + assert.Empty(t, parseFingerprintCounts([]byte("nonsense\nOperation performed successfully."))) +} + +func TestParseLocalUIDs(t *testing.T) { + t.Parallel() + // Only human-range uids (501, 503) survive; system accounts are dropped. + assert.Equal(t, []string{"501", "503"}, parseLocalUIDs([]byte(dsclUsers))) +} + +func TestChipModelFromSPiBridge(t *testing.T) { + t.Parallel() + assert.Equal(t, "Mac16,5", chipModelFromSPiBridge([]byte(spiBridge))) + assert.Equal(t, "", chipModelFromSPiBridge([]byte("no model here"))) +} + +func TestIORegClassPresent(t *testing.T) { + t.Parallel() + present := utils.MockCmdRunner{Output: ioregOneNode} + ok, err := ioregClassPresent(present, "AppleMesaAccessory") + require.NoError(t, err) + assert.True(t, ok) + + absent := utils.MockCmdRunner{Output: ioregNoNode} + ok, err = ioregClassPresent(absent, "AppleBiometricSensor") + require.NoError(t, err) + assert.False(t, ok) +} + +// systemRunner mocks every command GetSystemConfig issues. builtinIOReg and +// mesaIOReg are the canned `ioreg -a -r -c ` outputs for the two classes. +func systemRunner(builtinIOReg, mesaIOReg string) utils.MultiMockCmdRunner { + return utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/sbin/system_profiler SPiBridgeDataType": {Output: spiBridge}, + "/usr/bin/bioutil -r -s": {Output: systemBioutil}, + "/usr/sbin/ioreg -a -r -c AppleBiometricSensor": {Output: builtinIOReg}, + "/usr/sbin/ioreg -a -r -c AppleMesaAccessory": {Output: mesaIOReg}, + }, + } +} + +func TestGetSystemConfig_BuiltInSensor(t *testing.T) { + t.Parallel() + // Laptop: built-in AppleBiometricSensor present. + cfg, err := GetSystemConfig(systemRunner(ioregOneNode, ioregNoNode)) + require.NoError(t, err) + assert.Equal(t, "1", cfg.Compatible) + assert.Equal(t, "Mac16,5", cfg.SecureEnclave) + assert.Equal(t, "1", cfg.Enabled) + assert.Equal(t, "1", cfg.Unlock) + assert.Equal(t, "1", cfg.Builtin) + assert.Equal(t, "1", cfg.SensorPresent) +} + +func TestGetSystemConfig_AccessorySensor(t *testing.T) { + t.Parallel() + // Desktop with a Magic Keyboard with Touch ID: no built-in sensor, but an + // AppleMesaAccessory node is present. + cfg, err := GetSystemConfig(systemRunner(ioregNoNode, ioregOneNode)) + require.NoError(t, err) + assert.Equal(t, "0", cfg.Builtin) + assert.Equal(t, "1", cfg.SensorPresent) +} + +func TestGetSystemConfig_NoSensor(t *testing.T) { + t.Parallel() + // Keyboard-less Mac mini / Studio: no sensor of any kind. + cfg, err := GetSystemConfig(systemRunner(ioregNoNode, ioregNoNode)) + require.NoError(t, err) + assert.Equal(t, "0", cfg.Builtin) + assert.Equal(t, "0", cfg.SensorPresent) + // bioutil still reports compatible=1 (on-die Secure Enclave) — which is + // exactly why touchid_compatible is not a usable sensor-presence signal. + assert.Equal(t, "1", cfg.Compatible) +} + +func TestGetSystemConfig_BioutilError(t *testing.T) { + t.Parallel() + // bioutil fails, but the ioreg-derived columns must still be populated. + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/sbin/system_profiler SPiBridgeDataType": {Output: spiBridge}, + "/usr/bin/bioutil -r -s": {Err: errors.New("boom")}, + "/usr/sbin/ioreg -a -r -c AppleBiometricSensor": {Output: ioregOneNode}, + "/usr/sbin/ioreg -a -r -c AppleMesaAccessory": {Output: ioregNoNode}, + }, + } + cfg, err := GetSystemConfig(runner) + require.NoError(t, err) + assert.Equal(t, "Mac16,5", cfg.SecureEnclave) + assert.Equal(t, "0", cfg.Compatible) // unknown -> default 0 + assert.Equal(t, "1", cfg.Builtin) + assert.Equal(t, "1", cfg.SensorPresent) +} + +func TestGetUserConfigs_AllAccounts(t *testing.T) { + t.Parallel() + // -c -s reports 501=1, 503=2; 502 absent => 0. Only 501 is "logged in" + // (its -r succeeds); the others' -r errors but their counts still report. + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/bin/bioutil -c -s": {Output: allCounts}, + "/usr/bin/dscl . -list /Users UniqueID": {Output: "alice 501\ncarol 502\nbob 503\n"}, + }, + } + perUser := func(uid string) ([]byte, error) { + if uid == "501" { + return []byte(userBioutil), nil + } + return nil, errors.New("not logged in") + } + exists := func(string) bool { return true } + + configs, err := GetUserConfigs(runner, exists, nil, perUser) + require.NoError(t, err) + require.Len(t, configs, 3) + + byUID := map[string]*UserConfig{} + for _, c := range configs { + byUID[c.UID] = c + } + // 501: logged in, 1 fingerprint, flags populated. + assert.Equal(t, "1", byUID["501"].FingerprintsRegistered) + assert.Equal(t, "1", byUID["501"].Unlock) + // 502: absent from -c -s => known 0; logged out => flags empty (NULL). + assert.Equal(t, "0", byUID["502"].FingerprintsRegistered) + assert.Equal(t, "", byUID["502"].Unlock) + // 503: 2 fingerprints; logged out => flags empty. + assert.Equal(t, "2", byUID["503"].FingerprintsRegistered) + assert.Equal(t, "", byUID["503"].Unlock) +} + +func TestGetUserConfigs_ConstraintAndZeroForcesEffective(t *testing.T) { + t.Parallel() + // uid 501 constrained; -c -s reports nobody enrolled (known 0). bioutil -r + // claims effective=1, which the zero-fingerprint workaround must force to 0. + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/bin/bioutil -c -s": {Output: "Operation performed successfully."}, + }, + } + perUser := func(string) ([]byte, error) { return []byte(userBioutil), nil } + exists := func(string) bool { return true } + + configs, err := GetUserConfigs(runner, exists, []string{"501"}, perUser) + require.NoError(t, err) + require.Len(t, configs, 1) + c := configs[0] + assert.Equal(t, "0", c.FingerprintsRegistered) + assert.Equal(t, "0", c.EffectiveUnlock) + assert.Equal(t, "0", c.EffectiveApplePay) + // Configured (non-effective) flags stay as bioutil reported. + assert.Equal(t, "1", c.Unlock) +} + +func TestGetUserConfigs_CountUnknownPreservesEffective(t *testing.T) { + t.Parallel() + // -c -s fails (not root): count unknown. effective flags must be preserved + // (the zero-fingerprint workaround must NOT fire on unknown counts). + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/bin/bioutil -c -s": {Err: errors.New("not root")}, + }, + } + perUser := func(string) ([]byte, error) { return []byte(userBioutil), nil } + exists := func(string) bool { return true } + + configs, err := GetUserConfigs(runner, exists, []string{"501"}, perUser) + require.NoError(t, err) + require.Len(t, configs, 1) + c := configs[0] + assert.Equal(t, "", c.FingerprintsRegistered) // unknown -> empty + assert.Equal(t, "1", c.EffectiveUnlock) +} + +func TestGetUserConfigs_SkipsNonexistentUID(t *testing.T) { + t.Parallel() + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/bin/bioutil -c -s": {Output: allCounts}, + }, + } + perUser := func(string) ([]byte, error) { return nil, errors.New("nope") } + exists := func(string) bool { return false } + + configs, err := GetUserConfigs(runner, exists, []string{"99999"}, perUser) + require.NoError(t, err) + assert.Empty(t, configs) +} + +func TestColumns(t *testing.T) { + t.Parallel() + sys := []string{"touchid_compatible", "secure_enclave", "touchid_enabled", "touchid_unlock", "touchid_builtin", "touchid_sensor_present"} + for i, c := range TouchIDSystemConfigColumns() { + assert.Equal(t, sys[i], c.Name) + } + usr := []string{"uid", "fingerprints_registered", "touchid_unlock", "touchid_applepay", "effective_unlock", "effective_applepay"} + for i, c := range TouchIDUserConfigColumns() { + assert.Equal(t, usr[i], c.Name) + } +} From 363aafa711a0cb44ffa6e2a9f654027741e83b49 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 1 Jun 2026 23:01:03 -0400 Subject: [PATCH 2/7] touchid: address Copilot review feedback - Source secure_enclave from `sysctl -n hw.model` instead of `system_profiler SPiBridgeDataType`. Both return the SoC/model identifier (e.g. "Mac16,5"), but system_profiler can take seconds and added noticeable latency to every query; sysctl is near-instant. Drops the chipModelFromSPiBridge parser and its test. - Check bufio.Scanner.Err() in parseBioutil / parseFingerprintCounts / parseLocalUIDs, and enlarge the scan buffer (newLineScanner, 1 MiB cap) so a truncated read or an over-long line is treated as "unknown" (empty result) rather than silently producing a partial parse. - Rewrite TestColumns to compare full column-name slices via a columnNames helper, so a count/order mismatch is a clean assertion failure instead of an index-out-of-range panic. All three were raised by the Copilot reviewer on #110. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/touchid/BUILD.bazel | 1 + tables/touchid/touchid.go | 59 ++++++++++++++++++++++------------ tables/touchid/touchid_test.go | 41 +++++++++++------------ 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/tables/touchid/BUILD.bazel b/tables/touchid/BUILD.bazel index 7007a4a..fd4d556 100644 --- a/tables/touchid/BUILD.bazel +++ b/tables/touchid/BUILD.bazel @@ -18,6 +18,7 @@ go_test( embed = [":touchid"], deps = [ "//pkg/utils", + "@com_github_osquery_osquery_go//plugin/table", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", ], diff --git a/tables/touchid/touchid.go b/tables/touchid/touchid.go index e053318..b3d10ae 100644 --- a/tables/touchid/touchid.go +++ b/tables/touchid/touchid.go @@ -40,6 +40,21 @@ const ( maxHumanUID = 60000 ) +// maxScanLine is the per-line buffer cap for the parsers below. bioutil/dscl +// lines are short, but a generous cap avoids bufio.Scanner's default 64KiB +// bufio.ErrTooLong on unexpectedly long output, which would otherwise truncate +// a parse silently. +const maxScanLine = 1 << 20 // 1 MiB + +// newLineScanner returns a line scanner over out with an enlarged buffer so a +// long line cannot silently truncate the parse. Callers should check Err() +// after the loop. +func newLineScanner(out []byte) *bufio.Scanner { + s := bufio.NewScanner(bytes.NewReader(out)) + s.Buffer(make([]byte, 0, 64*1024), maxScanLine) + return s +} + // parseBioutil parses the "Label: value" lines emitted by `bioutil -r` / // `bioutil -r -s` into a map keyed by label. Keying on the label text (rather // than line position) keeps the tables correct on macOS releases that add @@ -48,7 +63,7 @@ const ( // colon and are skipped. func parseBioutil(out []byte) map[string]string { fields := make(map[string]string) - s := bufio.NewScanner(bytes.NewReader(out)) + s := newLineScanner(out) for s.Scan() { line := strings.TrimSpace(s.Text()) idx := strings.Index(line, ":") @@ -62,6 +77,13 @@ func parseBioutil(out []byte) map[string]string { } fields[key] = val } + if s.Err() != nil { + // A read/tokenization error means the output was only partially parsed. + // Discard it rather than returning fields built from a truncated read; + // callers treat absent keys as "unknown" (NULL), which is the correct, + // safe degradation. + return map[string]string{} + } return fields } @@ -115,22 +137,6 @@ type SystemConfig struct { SensorPresent string // touchid_sensor_present: built-in OR an attached Touch ID accessory } -// chipModelFromSPiBridge extracts the SoC model identifier (e.g. "Mac16,5") -// from `ioreg -a ... AppleARMPE`... but bioutil already covers compatibility, -// so secure_enclave is sourced from system_profiler SPiBridgeDataType, parsed -// here from its plain-text "Model Identifier:" line. -func chipModelFromSPiBridge(out []byte) string { - s := bufio.NewScanner(bytes.NewReader(out)) - for s.Scan() { - line := strings.TrimSpace(s.Text()) - const k = "Model Identifier:" - if strings.HasPrefix(line, k) { - return strings.TrimSpace(strings.TrimPrefix(line, k)) - } - } - return "" -} - // GetSystemConfig builds the single touchid_system_config row. bioutil supplies // the compatibility/enabled/unlock flags; ioreg supplies the hardware-presence // flags. The two ioreg-derived columns are independent of bioutil, so they are @@ -144,8 +150,11 @@ func GetSystemConfig(cmder utils.CmdRunner) (*SystemConfig, error) { SensorPresent: "0", } - if out, err := cmder.RunCmd("/usr/sbin/system_profiler", "SPiBridgeDataType"); err == nil { - cfg.SecureEnclave = chipModelFromSPiBridge(out) + // secure_enclave is the SoC / model identifier (e.g. "Mac16,5"). sysctl + // returns it directly and cheaply; we avoid system_profiler here because it + // can take seconds and would add noticeable latency to every query. + if out, err := cmder.RunCmd("/usr/sbin/sysctl", "-n", "hw.model"); err == nil { + cfg.SecureEnclave = strings.TrimSpace(string(out)) } if out, err := cmder.RunCmd(bioutilPath, "-r", "-s"); err == nil { @@ -225,7 +234,7 @@ func TouchIDSystemConfigGenerate(ctx context.Context, queryContext table.QueryCo // returned map should be treated as count 0 when the counts are known. func parseFingerprintCounts(out []byte) map[string]int { counts := make(map[string]int) - s := bufio.NewScanner(bytes.NewReader(out)) + s := newLineScanner(out) for s.Scan() { fields := strings.Fields(s.Text()) if len(fields) < 4 || fields[0] != "User" { @@ -244,6 +253,11 @@ func parseFingerprintCounts(out []byte) map[string]int { } } } + if s.Err() != nil { + // Truncated read: report counts as unknown rather than from a partial + // parse (the caller distinguishes "no counts" from "user has 0"). + return map[string]int{} + } return counts } @@ -252,7 +266,7 @@ func parseFingerprintCounts(out []byte) map[string]int { // range. func parseLocalUIDs(out []byte) []string { var uids []string - s := bufio.NewScanner(bytes.NewReader(out)) + s := newLineScanner(out) for s.Scan() { fields := strings.Fields(s.Text()) if len(fields) != 2 { @@ -264,6 +278,9 @@ func parseLocalUIDs(out []byte) []string { } uids = append(uids, fields[1]) } + if s.Err() != nil { + return nil + } return uids } diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go index 69b21a6..b262e2a 100644 --- a/tables/touchid/touchid_test.go +++ b/tables/touchid/touchid_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/osquery/osquery-go/plugin/table" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,10 +30,9 @@ Operation performed successfully.` // `bioutil -c -s` (root): one line per enrolled user. const allCounts = "User 501:\t1 biometric template(s)\nUser 503:\t2 biometric template(s)\nOperation performed successfully." -const spiBridge = `Controller: - - Model Identifier: Mac16,5 - Firmware Version: mBoot-18000.120.36` +// `sysctl -n hw.model` output: the SoC / model identifier, with a trailing +// newline (reported as secure_enclave). +const sysctlModel = "Mac16,5\n" const dsclUsers = "_mbsetupuser 248\nroot 0\nalice 501\nbob 503\n" @@ -81,12 +81,6 @@ func TestParseLocalUIDs(t *testing.T) { assert.Equal(t, []string{"501", "503"}, parseLocalUIDs([]byte(dsclUsers))) } -func TestChipModelFromSPiBridge(t *testing.T) { - t.Parallel() - assert.Equal(t, "Mac16,5", chipModelFromSPiBridge([]byte(spiBridge))) - assert.Equal(t, "", chipModelFromSPiBridge([]byte("no model here"))) -} - func TestIORegClassPresent(t *testing.T) { t.Parallel() present := utils.MockCmdRunner{Output: ioregOneNode} @@ -105,7 +99,7 @@ func TestIORegClassPresent(t *testing.T) { func systemRunner(builtinIOReg, mesaIOReg string) utils.MultiMockCmdRunner { return utils.MultiMockCmdRunner{ Commands: map[string]utils.MockCmdRunner{ - "/usr/sbin/system_profiler SPiBridgeDataType": {Output: spiBridge}, + "/usr/sbin/sysctl -n hw.model": {Output: sysctlModel}, "/usr/bin/bioutil -r -s": {Output: systemBioutil}, "/usr/sbin/ioreg -a -r -c AppleBiometricSensor": {Output: builtinIOReg}, "/usr/sbin/ioreg -a -r -c AppleMesaAccessory": {Output: mesaIOReg}, @@ -153,7 +147,7 @@ func TestGetSystemConfig_BioutilError(t *testing.T) { // bioutil fails, but the ioreg-derived columns must still be populated. runner := utils.MultiMockCmdRunner{ Commands: map[string]utils.MockCmdRunner{ - "/usr/sbin/system_profiler SPiBridgeDataType": {Output: spiBridge}, + "/usr/sbin/sysctl -n hw.model": {Output: sysctlModel}, "/usr/bin/bioutil -r -s": {Err: errors.New("boom")}, "/usr/sbin/ioreg -a -r -c AppleBiometricSensor": {Output: ioregOneNode}, "/usr/sbin/ioreg -a -r -c AppleMesaAccessory": {Output: ioregNoNode}, @@ -262,14 +256,21 @@ func TestGetUserConfigs_SkipsNonexistentUID(t *testing.T) { assert.Empty(t, configs) } +func columnNames(cols []table.ColumnDefinition) []string { + names := make([]string, len(cols)) + for i, c := range cols { + names[i] = c.Name + } + return names +} + func TestColumns(t *testing.T) { t.Parallel() - sys := []string{"touchid_compatible", "secure_enclave", "touchid_enabled", "touchid_unlock", "touchid_builtin", "touchid_sensor_present"} - for i, c := range TouchIDSystemConfigColumns() { - assert.Equal(t, sys[i], c.Name) - } - usr := []string{"uid", "fingerprints_registered", "touchid_unlock", "touchid_applepay", "effective_unlock", "effective_applepay"} - for i, c := range TouchIDUserConfigColumns() { - assert.Equal(t, usr[i], c.Name) - } + // Compare full name slices so a count/order mismatch is a clean assertion + // failure rather than an index-out-of-range panic. + wantSys := []string{"touchid_compatible", "secure_enclave", "touchid_enabled", "touchid_unlock", "touchid_builtin", "touchid_sensor_present"} + assert.Equal(t, wantSys, columnNames(TouchIDSystemConfigColumns())) + + wantUsr := []string{"uid", "fingerprints_registered", "touchid_unlock", "touchid_applepay", "effective_unlock", "effective_applepay"} + assert.Equal(t, wantUsr, columnNames(TouchIDUserConfigColumns())) } From 9e5516b8fbb7d807fa93b2c0ddf6522f9a9695d1 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 1 Jun 2026 23:20:55 -0400 Subject: [PATCH 3/7] touchid: fix unknown-vs-disabled semantics from scanner-error hardening Follow-up to the previous Copilot round. The "return empty on scanner error" change conflated parse failure with valid-empty results: - parseFingerprintCounts now returns (counts, ok). GetUserConfigs sets countsKnown from ok, so a truncated/failed parse is treated as "unknown" rather than "every user has 0 enrolled fingerprints". - parseBioutil now returns (fields, ok). On a scanner error the caller skips populating the flag columns entirely (leaving them NULL) instead of running boolField over an empty map and reporting every flag as "0"/disabled. - Add nullableBoolField and use it for the per-user touchid_user_config flags (unlock / applepay / effective_*), so a flag bioutil did not emit (e.g. on a macOS version that omits it) is reported as unknown (NULL) rather than silently "0". The zero-fingerprint effective-flag workaround now only fires on flags that were actually present. - Add a sysctlPath constant for the hw.model lookup, matching bioutilPath / ioregPath / dsclPath. Raised by Copilot on #110. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/touchid/touchid.go | 94 ++++++++++++++++++++++------------ tables/touchid/touchid_test.go | 31 +++++++++-- 2 files changed, 88 insertions(+), 37 deletions(-) diff --git a/tables/touchid/touchid.go b/tables/touchid/touchid.go index b3d10ae..6729148 100644 --- a/tables/touchid/touchid.go +++ b/tables/touchid/touchid.go @@ -30,6 +30,7 @@ const ( bioutilPath = "/usr/bin/bioutil" ioregPath = "/usr/sbin/ioreg" dsclPath = "/usr/bin/dscl" + sysctlPath = "/usr/sbin/sysctl" ) // minHumanUID / maxHumanUID bound real (non-system) local accounts. macOS @@ -61,7 +62,10 @@ func newLineScanner(out []byte) *bufio.Scanner { // configuration lines. Section headers ("System Touch ID configuration:") and // the trailing "Operation performed successfully." line have no value after the // colon and are skipped. -func parseBioutil(out []byte) map[string]string { +// parseBioutil returns the parsed fields and ok=false if a scanner read error +// left the output only partially parsed (so callers can treat the result as +// unknown rather than trusting a truncated parse). +func parseBioutil(out []byte) (map[string]string, bool) { fields := make(map[string]string) s := newLineScanner(out) for s.Scan() { @@ -78,17 +82,15 @@ func parseBioutil(out []byte) map[string]string { fields[key] = val } if s.Err() != nil { - // A read/tokenization error means the output was only partially parsed. - // Discard it rather than returning fields built from a truncated read; - // callers treat absent keys as "unknown" (NULL), which is the correct, - // safe degradation. - return map[string]string{} + return nil, false } - return fields + return fields, true } // boolField returns "1" if the named bioutil field equals "1", else "0". -// bioutil reports these flags as the literal characters 0/1. +// bioutil reports these flags as the literal characters 0/1. Use this only for +// fields bioutil is guaranteed to emit; for fields that may be absent (and +// where absent must mean "unknown", not "disabled"), use nullableBoolField. func boolField(fields map[string]string, key string) string { if fields[key] == "1" { return "1" @@ -96,6 +98,20 @@ func boolField(fields map[string]string, key string) string { return "0" } +// nullableBoolField returns "1"/"0" when the key is present, or "" (NULL) when +// it is absent — so a field bioutil did not emit (e.g. on a macOS version that +// omits it) is reported as unknown rather than silently "disabled". +func nullableBoolField(fields map[string]string, key string) (string, bool) { + v, ok := fields[key] + if !ok { + return "", false + } + if v == "1" { + return "1", true + } + return "0", true +} + // boolValue renders a Go bool as osquery's "1"/"0" integer-column convention. func boolValue(b bool) string { if b { @@ -153,17 +169,18 @@ func GetSystemConfig(cmder utils.CmdRunner) (*SystemConfig, error) { // secure_enclave is the SoC / model identifier (e.g. "Mac16,5"). sysctl // returns it directly and cheaply; we avoid system_profiler here because it // can take seconds and would add noticeable latency to every query. - if out, err := cmder.RunCmd("/usr/sbin/sysctl", "-n", "hw.model"); err == nil { + if out, err := cmder.RunCmd(sysctlPath, "-n", "hw.model"); err == nil { cfg.SecureEnclave = strings.TrimSpace(string(out)) } if out, err := cmder.RunCmd(bioutilPath, "-r", "-s"); err == nil { - fields := parseBioutil(out) - if _, ok := fields["Biometrics functionality"]; ok { - cfg.Compatible = "1" + if fields, ok := parseBioutil(out); ok { + if _, present := fields["Biometrics functionality"]; present { + cfg.Compatible = "1" + } + cfg.Enabled = boolField(fields, "Biometrics functionality") + cfg.Unlock = boolField(fields, "Biometrics for unlock") } - cfg.Enabled = boolField(fields, "Biometrics functionality") - cfg.Unlock = boolField(fields, "Biometrics for unlock") } // Built-in sensor: laptops expose one or more AppleBiometricSensor nodes; @@ -231,8 +248,11 @@ func TouchIDSystemConfigGenerate(ctx context.Context, queryContext table.QueryCo // (single user) or `bioutil -c -s` (all enrolled users, root) output, keyed by // uid. Lines look like "User 501:\t1 biometric template(s)". Users with zero // enrolled templates do not appear in `-c -s` output, so a uid absent from the -// returned map should be treated as count 0 when the counts are known. -func parseFingerprintCounts(out []byte) map[string]int { +// returned map should be treated as count 0 when ok is true. ok is false if a +// scanner read error left the output only partially parsed, so the caller can +// treat the counts as unknown (NOT "everyone has 0") rather than trusting a +// truncated parse. +func parseFingerprintCounts(out []byte) (map[string]int, bool) { counts := make(map[string]int) s := newLineScanner(out) for s.Scan() { @@ -254,11 +274,9 @@ func parseFingerprintCounts(out []byte) map[string]int { } } if s.Err() != nil { - // Truncated read: report counts as unknown rather than from a partial - // parse (the caller distinguishes "no counts" from "user has 0"). - return map[string]int{} + return nil, false } - return counts + return counts, true } // parseLocalUIDs parses `dscl . -list /Users UniqueID` (two columns: account @@ -325,8 +343,9 @@ func GetUserConfigs( counts := map[string]int{} countsKnown := false if out, err := cmder.RunCmd(bioutilPath, "-c", "-s"); err == nil { - counts = parseFingerprintCounts(out) - countsKnown = true + // Only treat counts as known if parsing actually succeeded — a truncated + // parse must not be reported as "everyone has 0 enrolled". + counts, countsKnown = parseFingerprintCounts(out) } uids := targetUIDs @@ -353,17 +372,26 @@ func GetUserConfigs( } if out, err := perUserRunner(uid); err == nil { - fields := parseBioutil(out) - row.Unlock = boolField(fields, "Biometrics for unlock") - row.ApplePay = boolField(fields, "Biometrics for ApplePay") - row.EffectiveUnlock = boolField(fields, "Effective biometrics for unlock") - row.EffectiveApplePay = boolField(fields, "Effective biometrics for ApplePay") - - // bioutil's "Effective" flags can report 1 with no fingerprints - // enrolled. Only correct this when the count is known to be 0. - if hasCount && count == 0 { - row.EffectiveUnlock = "0" - row.EffectiveApplePay = "0" + if fields, ok := parseBioutil(out); ok { + // Use nullableBoolField so a flag bioutil did not emit (e.g. on a + // macOS version that omits it) is reported as unknown (NULL), + // not silently "0"/disabled. + row.Unlock, _ = nullableBoolField(fields, "Biometrics for unlock") + row.ApplePay, _ = nullableBoolField(fields, "Biometrics for ApplePay") + row.EffectiveUnlock, _ = nullableBoolField(fields, "Effective biometrics for unlock") + row.EffectiveApplePay, _ = nullableBoolField(fields, "Effective biometrics for ApplePay") + + // bioutil's "Effective" flags can report 1 with no fingerprints + // enrolled. Only correct this when the count is known to be 0 and + // the effective flags were actually present. + if hasCount && count == 0 { + if row.EffectiveUnlock != "" { + row.EffectiveUnlock = "0" + } + if row.EffectiveApplePay != "" { + row.EffectiveApplePay = "0" + } + } } } diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go index b262e2a..169f8bb 100644 --- a/tables/touchid/touchid_test.go +++ b/tables/touchid/touchid_test.go @@ -55,7 +55,8 @@ const ioregNoNode = `` func TestParseBioutil(t *testing.T) { t.Parallel() - f := parseBioutil([]byte(systemBioutil)) + f, ok := parseBioutil([]byte(systemBioutil)) + require.True(t, ok) assert.Equal(t, "1", f["Biometrics functionality"]) assert.Equal(t, "1", f["Biometrics for unlock"]) _, hasHeader := f["System Touch ID configuration"] @@ -64,15 +65,37 @@ func TestParseBioutil(t *testing.T) { assert.False(t, hasFooter, "footer line should not parse as a field") } +func TestNullableBoolField(t *testing.T) { + t.Parallel() + fields := map[string]string{"on": "1", "off": "0"} + + v, ok := nullableBoolField(fields, "on") + assert.True(t, ok) + assert.Equal(t, "1", v) + + v, ok = nullableBoolField(fields, "off") + assert.True(t, ok) + assert.Equal(t, "0", v) + + // Absent key => unknown (NULL), not "0". + v, ok = nullableBoolField(fields, "missing") + assert.False(t, ok) + assert.Equal(t, "", v) +} + func TestParseFingerprintCounts(t *testing.T) { t.Parallel() - got := parseFingerprintCounts([]byte(allCounts)) + got, ok := parseFingerprintCounts([]byte(allCounts)) + require.True(t, ok) assert.Equal(t, map[string]int{"501": 1, "503": 2}, got) - single := parseFingerprintCounts([]byte("User 501:\t3 biometric template(s)\nOperation performed successfully.")) + single, ok := parseFingerprintCounts([]byte("User 501:\t3 biometric template(s)\nOperation performed successfully.")) + require.True(t, ok) assert.Equal(t, 3, single["501"]) - assert.Empty(t, parseFingerprintCounts([]byte("nonsense\nOperation performed successfully."))) + empty, ok := parseFingerprintCounts([]byte("nonsense\nOperation performed successfully.")) + require.True(t, ok) // a well-formed read with no matching lines is still "known" + assert.Empty(t, empty) } func TestParseLocalUIDs(t *testing.T) { From c385997db6285d0d47e56c6fd0942b69fa084476 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 2 Jun 2026 10:10:52 -0400 Subject: [PATCH 4/7] touchid: set each per-user flag column independently (NULL omission) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TouchIDUserConfigGenerate gated all four flag columns on `Unlock != ""`, so a config where touchid_unlock is present but ApplePay/effective_* are absent (now possible since nullableBoolField returns "" for missing keys) would set those columns to "" — an invalid value for an IntegerColumn that should be NULL/omitted. Set each column independently, only when its value is known. Extracted the row-shaping into userConfigsToRows so the NULL-omission behavior is unit-testable; added tests for the partial-flags and all-known cases. Raised by Copilot on #110. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/touchid/touchid.go | 32 ++++++++++++++-------- tables/touchid/touchid_test.go | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/tables/touchid/touchid.go b/tables/touchid/touchid.go index 6729148..7490668 100644 --- a/tables/touchid/touchid.go +++ b/tables/touchid/touchid.go @@ -444,21 +444,31 @@ func TouchIDUserConfigGenerate(ctx context.Context, queryContext table.QueryCont return nil, err } + return userConfigsToRows(configs), nil +} + +// userConfigsToRows shapes UserConfig values into osquery row maps. Each column +// is set only when its value is known; an unknown IntegerColumn must be omitted +// (NULL) rather than set to "" (an invalid integer). Flags are checked +// independently — any one can be individually absent (e.g. a macOS version that +// omits the ApplePay line), so one flag's presence must not be used as a proxy +// for the others. Extracted from the generate function so this NULL-omission +// behavior is unit-testable. +func userConfigsToRows(configs []*UserConfig) []map[string]string { var results []map[string]string for _, c := range configs { row := map[string]string{"uid": c.UID} - // Only set keys we actually know; an unknown IntegerColumn must be - // omitted (NULL) rather than set to an empty/zero value. - if c.FingerprintsRegistered != "" { - row["fingerprints_registered"] = c.FingerprintsRegistered - } - if c.Unlock != "" { - row["touchid_unlock"] = c.Unlock - row["touchid_applepay"] = c.ApplePay - row["effective_unlock"] = c.EffectiveUnlock - row["effective_applepay"] = c.EffectiveApplePay + setIfKnown := func(key, val string) { + if val != "" { + row[key] = val + } } + setIfKnown("fingerprints_registered", c.FingerprintsRegistered) + setIfKnown("touchid_unlock", c.Unlock) + setIfKnown("touchid_applepay", c.ApplePay) + setIfKnown("effective_unlock", c.EffectiveUnlock) + setIfKnown("effective_applepay", c.EffectiveApplePay) results = append(results, row) } - return results, nil + return results } diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go index 169f8bb..1feaf6f 100644 --- a/tables/touchid/touchid_test.go +++ b/tables/touchid/touchid_test.go @@ -279,6 +279,55 @@ func TestGetUserConfigs_SkipsNonexistentUID(t *testing.T) { assert.Empty(t, configs) } +func TestUserConfigsToRows_OmitsUnknownColumns(t *testing.T) { + t.Parallel() + // A config where some flags are known and others are unknown (""). The + // unknown ones must be OMITTED from the row map (NULL), never set to "". + // Crucially, touchid_unlock being present must NOT pull the other flags in. + configs := []*UserConfig{{ + UID: "501", + FingerprintsRegistered: "1", + Unlock: "1", + ApplePay: "", // unknown + EffectiveUnlock: "", // unknown + EffectiveApplePay: "", // unknown + }} + + rows := userConfigsToRows(configs) + require.Len(t, rows, 1) + row := rows[0] + + assert.Equal(t, "501", row["uid"]) + assert.Equal(t, "1", row["fingerprints_registered"]) + assert.Equal(t, "1", row["touchid_unlock"]) + for _, k := range []string{"touchid_applepay", "effective_unlock", "effective_applepay"} { + _, present := row[k] + assert.False(t, present, "unknown column %q must be omitted (NULL), not set to \"\"", k) + } +} + +func TestUserConfigsToRows_AllKnown(t *testing.T) { + t.Parallel() + configs := []*UserConfig{{ + UID: "501", + FingerprintsRegistered: "2", + Unlock: "1", + ApplePay: "0", + EffectiveUnlock: "1", + EffectiveApplePay: "0", + }} + rows := userConfigsToRows(configs) + require.Len(t, rows, 1) + assert.Equal(t, map[string]string{ + "uid": "501", + "fingerprints_registered": "2", + "touchid_unlock": "1", + "touchid_applepay": "0", + "effective_unlock": "1", + "effective_applepay": "0", + }, rows[0]) +} + func columnNames(cols []table.ColumnDefinition) []string { names := make([]string, len(cols)) for i, c := range cols { From de1ce53d6bb85cfe49382f3556fad32851048d02 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 2 Jun 2026 10:16:09 -0400 Subject: [PATCH 5/7] touchid: derive touchid_compatible from value; clarify uid bound comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - touchid_compatible was set to "1" based on the *presence* of the "Biometrics functionality" field rather than its value, so a hypothetical "Biometrics functionality: 0" from bioutil would report compatible=1. Derive it from the value via boolField (same field that drives touchid_enabled). Added a test for the value=0 case. - Reword the minHumanUID/maxHumanUID comment so it matches the implemented inclusive [501, 60000] range (the old "60000+ are transient" wording was ambiguous about whether 60000 itself is included — it is). Raised by Copilot on #110. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/touchid/touchid.go | 17 +++++++++++------ tables/touchid/touchid_test.go | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/tables/touchid/touchid.go b/tables/touchid/touchid.go index 7490668..5fdfa2a 100644 --- a/tables/touchid/touchid.go +++ b/tables/touchid/touchid.go @@ -33,9 +33,11 @@ const ( sysctlPath = "/usr/sbin/sysctl" ) -// minHumanUID / maxHumanUID bound real (non-system) local accounts. macOS -// reserves uids below 500 for system/service accounts; the first human account -// is 501. 60000+ are transient/Setup Assistant accounts. +// minHumanUID / maxHumanUID are the inclusive bounds for real (non-system) +// local accounts. macOS reserves uids below 500 for system/service accounts; +// the first human account is 501. Accounts above 60000 (e.g. transient / +// Setup Assistant accounts) are excluded. The filter keeps uids in +// [minHumanUID, maxHumanUID]. const ( minHumanUID = 501 maxHumanUID = 60000 @@ -175,9 +177,12 @@ func GetSystemConfig(cmder utils.CmdRunner) (*SystemConfig, error) { if out, err := cmder.RunCmd(bioutilPath, "-r", "-s"); err == nil { if fields, ok := parseBioutil(out); ok { - if _, present := fields["Biometrics functionality"]; present { - cfg.Compatible = "1" - } + // Derive compatible from the field VALUE, not merely its presence: + // if bioutil ever emits "Biometrics functionality: 0" we must report + // touchid_compatible=0, not 1. (Note: this column is "1" on every + // Apple Silicon Mac regardless — use touchid_builtin / + // touchid_sensor_present to detect an actual fingerprint sensor.) + cfg.Compatible = boolField(fields, "Biometrics functionality") cfg.Enabled = boolField(fields, "Biometrics functionality") cfg.Unlock = boolField(fields, "Biometrics for unlock") } diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go index 1feaf6f..dec8d3b 100644 --- a/tables/touchid/touchid_test.go +++ b/tables/touchid/touchid_test.go @@ -165,6 +165,25 @@ func TestGetSystemConfig_NoSensor(t *testing.T) { assert.Equal(t, "1", cfg.Compatible) } +func TestGetSystemConfig_CompatibleFromValue(t *testing.T) { + t.Parallel() + // If bioutil reports "Biometrics functionality: 0", touchid_compatible must + // be "0" (derived from the value), not "1" from the key merely being present. + bioutilOff := "System Touch ID configuration:\n\tBiometrics functionality: 0\n\tBiometrics for unlock: 0\nOperation performed successfully." + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/sbin/sysctl -n hw.model": {Output: sysctlModel}, + "/usr/bin/bioutil -r -s": {Output: bioutilOff}, + "/usr/sbin/ioreg -a -r -c AppleBiometricSensor": {Output: ioregNoNode}, + "/usr/sbin/ioreg -a -r -c AppleMesaAccessory": {Output: ioregNoNode}, + }, + } + cfg, err := GetSystemConfig(runner) + require.NoError(t, err) + assert.Equal(t, "0", cfg.Compatible) + assert.Equal(t, "0", cfg.Enabled) +} + func TestGetSystemConfig_BioutilError(t *testing.T) { t.Parallel() // bioutil fails, but the ioreg-derived columns must still be populated. From 50bf673f33f301efe2f11ad0b100024b4f3e3324 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 3 Jun 2026 17:07:28 -0400 Subject: [PATCH 6/7] touchid: document that touchid_sensor_present is the authoritative no-sensor signal Per @grahamgilbert's testing on a no-Touch-ID Mac: touchid_user_config still returns a row per local account (UID populated, Touch ID columns NULL) on such hardware, because user enumeration is independent of the sensor. Document this in the table's README notes and point callers at touchid_system_config.touchid_sensor_present = 0 as the authoritative "no usable Touch ID sensor" signal. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index acbfd92..98cdc7a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ For production deployment, you should refer to the [osquery documentation](https | `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";`) | | `touchid_system_config` | Machine-wide Touch ID / Secure Enclave posture (`touchid_compatible`, `secure_enclave`, `touchid_enabled`, `touchid_unlock`) plus `touchid_builtin` (a built-in Touch ID sensor exists — laptops) and `touchid_sensor_present` (a usable sensor exists: built-in OR an attached Magic Keyboard with Touch ID). From `bioutil` and `ioreg`. | macOS | Apple Silicon only. `touchid_compatible`/`touchid_enabled` are `1` on every Apple Silicon Mac (the Secure Enclave is on-die), so use `touchid_sensor_present` to tell a Mac that can actually enroll a fingerprint from a keyboard-less Mac mini/Studio. The accessory half of `touchid_sensor_present` is a live signal — a disconnected Touch ID keyboard reads `0`. | -| `touchid_user_config` | Per-user Touch ID configuration and enrolled fingerprint count (`uid`, `fingerprints_registered`, `touchid_unlock`, `touchid_applepay`, `effective_unlock`, `effective_applepay`). From `bioutil`. | macOS | Apple Silicon only. Without a `WHERE uid =` constraint, returns a row per real local account. `fingerprints_registered` needs root (reads all users via `bioutil -c -s`); the config flags need the user logged in (`bioutil -r` via `launchctl asuser`) and are left empty (NULL) when unavailable rather than `0`. | +| `touchid_user_config` | Per-user Touch ID configuration and enrolled fingerprint count (`uid`, `fingerprints_registered`, `touchid_unlock`, `touchid_applepay`, `effective_unlock`, `effective_applepay`). From `bioutil`. | macOS | Apple Silicon only. Without a `WHERE uid =` constraint, returns a row per real local account. `fingerprints_registered` needs root (reads all users via `bioutil -c -s`); the config flags need the user logged in (`bioutil -r` via `launchctl asuser`) and are left empty (NULL) when unavailable rather than `0`. On a Mac with no usable Touch ID sensor this table still returns a row per local account (UID populated, Touch ID columns NULL), since user enumeration is independent of the hardware — treat `touchid_system_config.touchid_sensor_present = 0` as the authoritative signal that the device has no usable Touch ID sensor. | | `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) | From 9f3cee0f5c290474f2c97473aa1766e38c90a780 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 4 Jun 2026 15:20:55 -0400 Subject: [PATCH 7/7] touchid: return no touchid_user_config rows when no sensor is present Per Graham's review: a Mac with no usable Touch ID sensor must be handled in code, not punted to callers via the README. touchid_user_config previously emitted a row per local account (UID populated, every Touch ID column NULL) on a keyboard-less Mac mini/Studio, because user enumeration via dscl is independent of the hardware. Callers then had to filter on touchid_system_config.touchid_sensor_present = 0 themselves. Now the table gates on sensor presence and returns no rows at all when no usable sensor exists. Extracted the built-in-OR-accessory detection into a shared SensorPresent helper so touchid_system_config and touchid_user_config agree on what "has a sensor" means; GetUserConfigs takes a sensorPresent bool and short-circuits to no rows when false; TouchIDUserConfigGenerate computes it via SensorPresent. Tests: TestGetUserConfigs_NoSensorReturnsNoRows and the constraint variant assert no rows on a sensor-less Mac (both with and without a WHERE uid =). README updated to document the no-rows behavior instead of caller-side filtering. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- tables/touchid/touchid.go | 73 ++++++++++++++++++++++++++-------- tables/touchid/touchid_test.go | 45 +++++++++++++++++++-- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 98cdc7a..a8c3f4c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ For production deployment, you should refer to the [osquery documentation](https | `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";`) | | `touchid_system_config` | Machine-wide Touch ID / Secure Enclave posture (`touchid_compatible`, `secure_enclave`, `touchid_enabled`, `touchid_unlock`) plus `touchid_builtin` (a built-in Touch ID sensor exists — laptops) and `touchid_sensor_present` (a usable sensor exists: built-in OR an attached Magic Keyboard with Touch ID). From `bioutil` and `ioreg`. | macOS | Apple Silicon only. `touchid_compatible`/`touchid_enabled` are `1` on every Apple Silicon Mac (the Secure Enclave is on-die), so use `touchid_sensor_present` to tell a Mac that can actually enroll a fingerprint from a keyboard-less Mac mini/Studio. The accessory half of `touchid_sensor_present` is a live signal — a disconnected Touch ID keyboard reads `0`. | -| `touchid_user_config` | Per-user Touch ID configuration and enrolled fingerprint count (`uid`, `fingerprints_registered`, `touchid_unlock`, `touchid_applepay`, `effective_unlock`, `effective_applepay`). From `bioutil`. | macOS | Apple Silicon only. Without a `WHERE uid =` constraint, returns a row per real local account. `fingerprints_registered` needs root (reads all users via `bioutil -c -s`); the config flags need the user logged in (`bioutil -r` via `launchctl asuser`) and are left empty (NULL) when unavailable rather than `0`. On a Mac with no usable Touch ID sensor this table still returns a row per local account (UID populated, Touch ID columns NULL), since user enumeration is independent of the hardware — treat `touchid_system_config.touchid_sensor_present = 0` as the authoritative signal that the device has no usable Touch ID sensor. | +| `touchid_user_config` | Per-user Touch ID configuration and enrolled fingerprint count (`uid`, `fingerprints_registered`, `touchid_unlock`, `touchid_applepay`, `effective_unlock`, `effective_applepay`). From `bioutil`. | macOS | Apple Silicon only. Without a `WHERE uid =` constraint, returns a row per real local account. `fingerprints_registered` needs root (reads all users via `bioutil -c -s`); the config flags need the user logged in (`bioutil -r` via `launchctl asuser`) and are left empty (NULL) when unavailable rather than `0`. On a Mac with no usable Touch ID sensor (`touchid_system_config.touchid_sensor_present = 0`) this table returns no rows at all, since there is no per-user Touch ID configuration to report. | | `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) | diff --git a/tables/touchid/touchid.go b/tables/touchid/touchid.go index 5fdfa2a..17122ed 100644 --- a/tables/touchid/touchid.go +++ b/tables/touchid/touchid.go @@ -145,6 +145,33 @@ func ioregClassPresent(cmder utils.CmdRunner, class string) (bool, error) { return len(nodes) > 0, nil } +// SensorPresent reports whether the Mac has a usable Touch ID fingerprint +// sensor: a built-in one (laptops register an AppleBiometricSensor node) OR an +// attached external one (a Magic Keyboard with Touch ID registers an +// AppleMesaAccessory node instead). This is the authoritative "can this Mac +// actually use Touch ID" signal — bioutil's compatibility/enabled flags are "1" +// on every Apple Silicon Mac (the Secure Enclave is on-die) even on a +// keyboard-less Mac mini/Studio with no sensor at all. Both touchid_system_config +// and touchid_user_config gate on this so a sensor-less Mac is handled +// consistently. The accessory half is a live signal: a disconnected Touch ID +// keyboard reads as absent until it reconnects. +func SensorPresent(cmder utils.CmdRunner) (bool, error) { + builtin, err := ioregClassPresent(cmder, "AppleBiometricSensor") + if err != nil { + return false, err + } + if builtin { + return true, nil + } + // No built-in sensor — check for an attached external Touch ID accessory. + // AppleMesaAccessory is a capability class, not a product-string match, so it + // is name-, transport- (USB or Bluetooth) and localization-independent, and a + // non-Touch-ID keyboard correctly reads as no sensor. The sibling classes + // AppleMesaSEPDriver / AppleMesaResources are NOT usable here — they are SEP + // scaffolding present on every Apple Silicon Mac. + return ioregClassPresent(cmder, "AppleMesaAccessory") +} + // SystemConfig holds the machine-wide Touch ID posture for one host. type SystemConfig struct { Compatible string // touchid_compatible: bioutil reports Secure Enclave biometric support @@ -199,21 +226,12 @@ func GetSystemConfig(cmder utils.CmdRunner) (*SystemConfig, error) { } cfg.Builtin = boolValue(builtin) - // Any usable sensor: built-in OR an attached external Touch ID sensor (e.g. - // a Magic Keyboard with Touch ID), which registers no AppleBiometricSensor - // node but does register an AppleMesaAccessory node ("Mesa" is Apple's - // codename for the Touch ID sensor subsystem; the "Accessory" suffix denotes - // an external sensor). This is a capability class, not a product-string - // match, so a non-Touch-ID keyboard correctly reads as no sensor. The - // sibling classes AppleMesaSEPDriver / AppleMesaResources are NOT usable for - // this — they are SEP scaffolding present on every Apple Silicon Mac. - sensorPresent := builtin - if !sensorPresent { - mesa, err := ioregClassPresent(cmder, "AppleMesaAccessory") - if err != nil { - return nil, err - } - sensorPresent = mesa + // Any usable sensor (built-in OR an attached Touch ID accessory) via the + // shared SensorPresent helper, so touchid_system_config and touchid_user_config + // agree on what "has a sensor" means. + sensorPresent, err := SensorPresent(cmder) + if err != nil { + return nil, err } cfg.SensorPresent = boolValue(sensorPresent) @@ -336,15 +354,28 @@ type UserConfig struct { // be read the flag columns are left empty (unknown) rather than "0", so an // enabled-but-logged-out user is not misreported as disabled. // +// sensorPresent gates the whole table: a Mac with no usable Touch ID sensor +// emits no rows at all. User enumeration (dscl) is independent of the hardware, +// so without this gate a keyboard-less Mac mini/Studio would report a row per +// local account with every Touch ID column NULL — noise that callers would have +// to filter on touchid_system_config.touchid_sensor_present themselves. Handling +// it here keeps that knowledge in one place. +// // targetUIDs, when non-empty, restricts the rows (from a `WHERE uid =` // constraint); otherwise every real local account is reported. perUserRunner // runs `bioutil -r` as a given uid; it is injected for testability. func GetUserConfigs( cmder utils.CmdRunner, + sensorPresent bool, exists uidExists, targetUIDs []string, perUserRunner func(uid string) ([]byte, error), ) ([]*UserConfig, error) { + // No usable Touch ID sensor => no per-user Touch ID configuration to report. + if !sensorPresent { + return nil, nil + } + counts := map[string]int{} countsKnown := false if out, err := cmder.RunCmd(bioutilPath, "-c", "-s"); err == nil { @@ -444,7 +475,17 @@ func defaultPerUserRunner(cmder utils.CmdRunner) func(uid string) ([]byte, error // touchid_user_config. func TouchIDUserConfigGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { runner := utils.NewRunner().Runner - configs, err := GetUserConfigs(runner, defaultUIDExists, uidConstraints(queryContext), defaultPerUserRunner(runner)) + + // A Mac with no usable Touch ID sensor has no per-user Touch ID config to + // report, so the table yields no rows (rather than a NULL-filled row per + // local account). touchid_system_config.touchid_sensor_present exposes the + // same signal for queries that want it explicitly. + sensorPresent, err := SensorPresent(runner) + if err != nil { + return nil, err + } + + configs, err := GetUserConfigs(runner, sensorPresent, defaultUIDExists, uidConstraints(queryContext), defaultPerUserRunner(runner)) if err != nil { return nil, err } diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go index dec8d3b..7e0f6f9 100644 --- a/tables/touchid/touchid_test.go +++ b/tables/touchid/touchid_test.go @@ -221,7 +221,7 @@ func TestGetUserConfigs_AllAccounts(t *testing.T) { } exists := func(string) bool { return true } - configs, err := GetUserConfigs(runner, exists, nil, perUser) + configs, err := GetUserConfigs(runner, true, exists, nil, perUser) require.NoError(t, err) require.Len(t, configs, 3) @@ -252,7 +252,7 @@ func TestGetUserConfigs_ConstraintAndZeroForcesEffective(t *testing.T) { perUser := func(string) ([]byte, error) { return []byte(userBioutil), nil } exists := func(string) bool { return true } - configs, err := GetUserConfigs(runner, exists, []string{"501"}, perUser) + configs, err := GetUserConfigs(runner, true, exists, []string{"501"}, perUser) require.NoError(t, err) require.Len(t, configs, 1) c := configs[0] @@ -275,7 +275,7 @@ func TestGetUserConfigs_CountUnknownPreservesEffective(t *testing.T) { perUser := func(string) ([]byte, error) { return []byte(userBioutil), nil } exists := func(string) bool { return true } - configs, err := GetUserConfigs(runner, exists, []string{"501"}, perUser) + configs, err := GetUserConfigs(runner, true, exists, []string{"501"}, perUser) require.NoError(t, err) require.Len(t, configs, 1) c := configs[0] @@ -293,11 +293,48 @@ func TestGetUserConfigs_SkipsNonexistentUID(t *testing.T) { perUser := func(string) ([]byte, error) { return nil, errors.New("nope") } exists := func(string) bool { return false } - configs, err := GetUserConfigs(runner, exists, []string{"99999"}, perUser) + configs, err := GetUserConfigs(runner, true, exists, []string{"99999"}, perUser) require.NoError(t, err) assert.Empty(t, configs) } +func TestGetUserConfigs_NoSensorReturnsNoRows(t *testing.T) { + t.Parallel() + // A Mac with no usable Touch ID sensor (sensorPresent=false) must not emit + // any per-user rows, even though dscl can still enumerate local accounts and + // bioutil -c -s succeeds. User enumeration is independent of the hardware, so + // without this gate the table would report a row per account on a sensor-less + // Mac. This is handled in code (not punted to callers via touchid_sensor_present). + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/bin/bioutil -c -s": {Output: allCounts}, + "/usr/bin/dscl . -list /Users UniqueID": {Output: "alice 501\nbob 503\n"}, + }, + } + perUser := func(string) ([]byte, error) { return []byte(userBioutil), nil } + exists := func(string) bool { return true } + + configs, err := GetUserConfigs(runner, false, exists, nil, perUser) + require.NoError(t, err) + assert.Empty(t, configs, "no rows when the Mac has no usable Touch ID sensor") +} + +func TestGetUserConfigs_NoSensorWithConstraintReturnsNoRows(t *testing.T) { + t.Parallel() + // Even an explicit WHERE uid = constraint yields no rows on a sensor-less Mac. + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/usr/bin/bioutil -c -s": {Output: allCounts}, + }, + } + perUser := func(string) ([]byte, error) { return []byte(userBioutil), nil } + exists := func(string) bool { return true } + + configs, err := GetUserConfigs(runner, false, exists, []string{"501"}, perUser) + require.NoError(t, err) + assert.Empty(t, configs, "no rows even with a uid constraint when no sensor is present") +} + func TestUserConfigsToRows_OmitsUnknownColumns(t *testing.T) { t.Parallel() // A config where some flags are known and others are unknown (""). The