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..a8c3f4c 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`. 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/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..fd4d556 --- /dev/null +++ b/tables/touchid/BUILD.bazel @@ -0,0 +1,25 @@ +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_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 new file mode 100644 index 0000000..17122ed --- /dev/null +++ b/tables/touchid/touchid.go @@ -0,0 +1,520 @@ +// 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" + sysctlPath = "/usr/sbin/sysctl" +) + +// 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 +) + +// 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 +// configuration lines. Section headers ("System Touch ID configuration:") and +// the trailing "Operation performed successfully." line have no value after the +// colon and are skipped. +// 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() { + 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 + } + if s.Err() != nil { + return nil, false + } + 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. 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" + } + 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 { + 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 +} + +// 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 + 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 +} + +// 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", + } + + // 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(sysctlPath, "-n", "hw.model"); err == nil { + cfg.SecureEnclave = strings.TrimSpace(string(out)) + } + + if out, err := cmder.RunCmd(bioutilPath, "-r", "-s"); err == nil { + if fields, ok := parseBioutil(out); ok { + // 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") + } + } + + // 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 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) + + 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 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() { + 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 + } + } + } + if s.Err() != nil { + return nil, false + } + return counts, true +} + +// 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 := newLineScanner(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]) + } + if s.Err() != nil { + return nil + } + 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. +// +// 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 { + // 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 + 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 { + 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" + } + } + } + } + + 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 + + // 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 + } + + 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} + 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 +} diff --git a/tables/touchid/touchid_test.go b/tables/touchid/touchid_test.go new file mode 100644 index 0000000..7e0f6f9 --- /dev/null +++ b/tables/touchid/touchid_test.go @@ -0,0 +1,404 @@ +package touchid + +import ( + "errors" + "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" +) + +// 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." + +// `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" + +// 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, 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"] + 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 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, ok := parseFingerprintCounts([]byte(allCounts)) + require.True(t, ok) + assert.Equal(t, map[string]int{"501": 1, "503": 2}, got) + + single, ok := parseFingerprintCounts([]byte("User 501:\t3 biometric template(s)\nOperation performed successfully.")) + require.True(t, ok) + assert.Equal(t, 3, single["501"]) + + 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) { + t.Parallel() + // Only human-range uids (501, 503) survive; system accounts are dropped. + assert.Equal(t, []string{"501", "503"}, parseLocalUIDs([]byte(dsclUsers))) +} + +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/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}, + }, + } +} + +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_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. + runner := utils.MultiMockCmdRunner{ + Commands: map[string]utils.MockCmdRunner{ + "/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}, + }, + } + 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, true, 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, true, 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, true, 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, 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 + // 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 { + names[i] = c.Name + } + return names +} + +func TestColumns(t *testing.T) { + t.Parallel() + // 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())) +}