From f4f3334927f2b0b080b7fdd676b9b4163e4536b0 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 5 Jun 2026 17:19:52 -0400 Subject: [PATCH 01/36] eapol_status: add macOS 802.1X/EAPOL supplicant state table Add the eapol_status table to expose per-interface EAPOL supplicant state via EAPOLControlCopyStateAndStatus() from /System/Library/PrivateFrameworks/EAP8021X.framework. 23 columns: interface, state/state_name, supplicant_state/supplicant_state_name, eap_type/eap_type_name, inner_eap_type/inner_eap_type_name, client_status, domain_specific_error, authenticator_mac_address, mode/mode_name, tls_session_was_resumed, tls_server_certificate_chain (RFC4514 LDAP DNs), tls_server_certificate_sha1, tls_server_certificate_serials, tls_trust_client_status, tls_negotiated_protocol_version, tls_negotiated_cipher, last_status_timestamp, unique_identifier. Supports WHERE interface = 'en0' constraint filtering (exact match only); otherwise enumerates en0 through en9. No root required. Context cancellation supported. Uses cgo on darwin (EAP8021X via dlopen/dlsym + CoreFoundation); no-op stub for non-darwin platforms. Apple CC toolchain via apple_support 1.24.5 added to WORKSPACE; cgo=True and pure=off on darwin go_binary targets. Table is testable without a live 802.1X connection via EAPOLBackend interface and fakeBackend mock. EAP8021X.framework linked via dlopen/dlsym (not build-time -framework); CoreFoundation linked via clinkopts. --- BUILD.bazel | 9 + README.md | 1 + WORKSPACE | 52 ++ main.go | 2 + tables/eapolstatus/BUILD.bazel | 44 ++ tables/eapolstatus/eapol_darwin.go | 436 ++++++++++++++++ tables/eapolstatus/eapol_darwin_test.go | 37 ++ tables/eapolstatus/eapol_other.go | 15 + tables/eapolstatus/eapolstatus.go | 443 ++++++++++++++++ tables/eapolstatus/eapolstatus_test.go | 663 ++++++++++++++++++++++++ 10 files changed, 1702 insertions(+) create mode 100644 tables/eapolstatus/BUILD.bazel create mode 100644 tables/eapolstatus/eapol_darwin.go create mode 100644 tables/eapolstatus/eapol_darwin_test.go create mode 100644 tables/eapolstatus/eapol_other.go create mode 100644 tables/eapolstatus/eapolstatus.go create mode 100644 tables/eapolstatus/eapolstatus_test.go diff --git a/BUILD.bazel b/BUILD.bazel index c3b8b2e..4968f7e 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -41,6 +41,7 @@ go_library( "//tables/authdb", "//tables/chromeuserprofiles", "//tables/crowdstrike_falcon", + "//tables/eapolstatus", "//tables/energyimpact", "//tables/fileline", "//tables/filevaultusers", @@ -62,19 +63,27 @@ go_library( ], ) +# The eapol_status table (and any future cgo tables) need cgo + Apple CC +# toolchain on darwin. These are darwin-only binaries (goos = "darwin"), +# so cgo=True does not affect Linux/Windows builds. The apple_support +# toolchain in WORKSPACE satisfies the CC requirement on macOS. go_binary( name = "osquery-extension-mac-arm", + cgo = True, embed = [":osquery-extension_lib"], goarch = "arm64", goos = "darwin", + pure = "off", visibility = ["//visibility:public"], ) go_binary( name = "osquery-extension-mac-amd", + cgo = True, embed = [":osquery-extension_lib"], goarch = "amd64", goos = "darwin", + pure = "off", visibility = ["//visibility:public"], ) diff --git a/README.md b/README.md index ba6ee57..78be392 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ For production deployment, you should refer to the [osquery documentation](https | `alt_system_info` | Alternative system_info table | macOS | This table is an alternative to the built-in system_info table in osquery, which triggers an `Allow "osquery" to find devices on local networks?` prompt on macOS 15.0. On versions other than 15.0, this table falls back to the built-in system_info table. Note: this table returns an empty `cpu_subtype` field. See [#58](https://github.com/macadmins/osquery-extension/pull/58) for more details. | | `authdb` | macOS Authorization database | macOS | Use the constraint `name` to specify a right name to query, otherwise all rights will be returned. | | `crowdstrike_falcon` | Provides basic information about the currently installed Falcon sensor. | Linux / macOS | Requires Falcon to be installed. | +| `eapol_status` | Per-interface macOS 802.1X / EAPOL supplicant state and authentication status | macOS | Exposes state, supplicant state, EAP method (including inner EAP for tunneled auth), TLS server certificate chain (DNs, SHA-1, serials), TLS trust client status, cipher, protocol version, session resumption, authenticator MAC, and status timestamp. Use `WHERE interface = 'en0'` to query a specific interface; otherwise probes en0 through en9. No root required. | | `energy_impact` | Process energy impact data from `powermetrics` | macOS | Use the `interval` constraint to specify sampling duration in milliseconds (default: 1000). | | `file_lines` | Read an arbitrary file | Linux / macOS / Windows | Use the constraint `path` and `last` to specify the file to read lines from | | `filevault_users` | Information on the users able to unlock the current boot volume when encrypted with Filevault | macOS | | diff --git a/WORKSPACE b/WORKSPACE index a9287a8..5d01af5 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -1,5 +1,57 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +# --- Apple C/C++ toolchain for cgo on macOS ------------------------------- +# The eapol_status table calls EAP8021X.framework (CoreFoundation) via cgo, +# so the macOS go_binary targets are built with cgo enabled (see +# BUILD.bazel: cgo = True, pure = "off"). That requires an Apple CC toolchain, +# which apple_support provides. This block must precede rules_go so its CC +# toolchain is registered for the Apple cc actions cgo uses. +# +# NOTE: In WORKSPACE mode (no bzlmod), these http_archive + load calls are +# unconditional — they are fetched on every platform even though only darwin +# builds consume them. The alternative (platform-gating with a load/condition) +# adds complexity without meaningful savings, since Bazel lazy-fetches archives +# and the WORKSPACE is already ~200 lines of unconditional deps. On bzlmod the +# toolchain is registered via MODULE.bazel, not this file. +# +# Pinned to 1.24.5: the last apple_support release that supports WORKSPACE mode +# (2.0.0 dropped it), and >= 1.19.0 so wrapped_clang is built WITHOUT the +# -Wl,-no_uuid workaround that recent macOS / dyld versions reject with +# "missing LC_UUID load command" (apple_support PR #373; bazelbuild/bazel#27026). +http_archive( + name = "build_bazel_apple_support", + sha256 = "1ae6fcf983cff3edab717636f91ad0efff2e5ba75607fdddddfd6ad0dbdfaf10", + url = "https://github.com/bazelbuild/apple_support/releases/download/1.24.5/apple_support.1.24.5.tar.gz", +) + +load( + "@build_bazel_apple_support//lib:repositories.bzl", + "apple_support_dependencies", +) + +apple_support_dependencies() + +# bazel_features (pulled in by apple_support_dependencies) deps. +load("@bazel_features//:deps.bzl", "bazel_features_deps") + +bazel_features_deps() + +# rules_cc (pulled in by apple_support_dependencies) deps + toolchains, plus the +# WORKSPACE-mode compatibility proxy repo (cc_compatibility_proxy) that the +# rules_cc 0.2.x APIs resolve through. In bzlmod this repo is created by a module +# extension; in WORKSPACE mode it must be created explicitly here. +load("@rules_cc//cc:repositories.bzl", "rules_cc_dependencies", "rules_cc_toolchains") + +rules_cc_dependencies() + +rules_cc_toolchains() + +load("@rules_cc//cc:extensions.bzl", "compatibility_proxy_repo") + +compatibility_proxy_repo() + +# -------------------------------------------------------------------------- + http_archive( name = "io_bazel_rules_go", sha256 = "68af54cb97fbdee5e5e8fe8d210d15a518f9d62abfd71620c3eaff3b26a5ff86", diff --git a/main.go b/main.go index 7e9f7b5..d4ab7ed 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/macadmins/osquery-extension/tables/alt_system_info" "github.com/macadmins/osquery-extension/tables/chromeuserprofiles" "github.com/macadmins/osquery-extension/tables/crowdstrike_falcon" + "github.com/macadmins/osquery-extension/tables/eapolstatus" "github.com/macadmins/osquery-extension/tables/energyimpact" "github.com/macadmins/osquery-extension/tables/fileline" "github.com/macadmins/osquery-extension/tables/filevaultusers" @@ -95,6 +96,7 @@ func main() { if runtime.GOOS == "darwin" { darwinPlugins := []osquery.OsqueryPlugin{ + table.NewPlugin("eapol_status", eapolstatus.EAPOLStatusColumns(), eapolstatus.EAPOLStatusGenerate), table.NewPlugin("energy_impact", energyimpact.EnergyImpactColumns(), energyimpact.EnergyImpactGenerate), table.NewPlugin("filevault_users", filevaultusers.FileVaultUsersColumns(), filevaultusers.FileVaultUsersGenerate), table.NewPlugin("local_network_permissions", localnetworkpermissions.LocalNetworkPermissionsColumns(), localnetworkpermissions.LocalNetworkPermissionsGenerate), diff --git a/tables/eapolstatus/BUILD.bazel b/tables/eapolstatus/BUILD.bazel new file mode 100644 index 0000000..bf2a0a5 --- /dev/null +++ b/tables/eapolstatus/BUILD.bazel @@ -0,0 +1,44 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "eapolstatus", + srcs = [ + "eapolstatus.go", + "eapol_darwin.go", + "eapol_other.go", + ], + # cgo is only needed on darwin: eapol_darwin.go (//go:build darwin) calls + # EAP8021X.framework (via dlopen/dlsym) + CoreFoundation via cgo. + # The darwin build constraint does NOT include ios, so only darwin gets + # the cgo + framework links; linux/windows stay pure Go. + cgo = select({ + "@io_bazel_rules_go//go/platform:darwin": True, + "//conditions:default": False, + }), + clinkopts = select({ + "@io_bazel_rules_go//go/platform:darwin": [ + "-framework", + "CoreFoundation", + ], + "//conditions:default": [], + }), + importpath = "github.com/macadmins/osquery-extension/tables/eapolstatus", + visibility = ["//visibility:public"], + deps = [ + "@com_github_osquery_osquery_go//plugin/table", + ], +) + +go_test( + name = "eapolstatus_test", + srcs = [ + "eapolstatus_test.go", + "eapol_darwin_test.go", + ], + embed = [":eapolstatus"], + deps = [ + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/tables/eapolstatus/eapol_darwin.go b/tables/eapolstatus/eapol_darwin.go new file mode 100644 index 0000000..d9cd25d --- /dev/null +++ b/tables/eapolstatus/eapol_darwin.go @@ -0,0 +1,436 @@ +//go:build darwin + +package eapolstatus + +/* +#include +#include +#include +#include +#include + +// EAPOLControlState enum from EAPOLControlTypes.h +enum { + kEAPOLControlStateIdle = 0, + kEAPOLControlStateStarting = 1, + kEAPOLControlStateRunning = 2, + kEAPOLControlStateStopping = 3, +}; + +typedef int (*EAPOLControlCopyStateAndStatusFn)(const char*, uint32_t*, CFDictionaryRef*); + +static EAPOLControlCopyStateAndStatusFn copy_state_fn = NULL; + +static int load_eapol(void) { + if (copy_state_fn) return 1; + // Handle intentionally held for process lifetime (sync.Once); the + // framework must stay loaded for copy_state_fn to remain valid. + void* h = dlopen("/System/Library/PrivateFrameworks/EAP8021X.framework/EAP8021X", RTLD_LAZY); + if (!h) return 0; + copy_state_fn = (EAPOLControlCopyStateAndStatusFn)dlsym(h, "EAPOLControlCopyStateAndStatus"); + if (!copy_state_fn) { + dlclose(h); + return 0; + } + return 1; +} + +// cfstring_go creates a Go-owned copy of a CFString as a malloc'd C string. +static char* cfstring_go(CFStringRef s) { + if (!s) return NULL; + CFIndex len = CFStringGetMaximumSizeForEncoding(CFStringGetLength(s), kCFStringEncodingUTF8) + 1; + char* buf = (char*)malloc((size_t)len); + if (buf && CFStringGetCString(s, buf, len, kCFStringEncodingUTF8)) { + return buf; + } + free(buf); + return NULL; +} + +// cfnumber_int returns the int value of a CFNumber, or -1. +static int cfnumber_int(CFTypeRef v) { + if (!v || CFGetTypeID(v) != CFNumberGetTypeID()) return -1; + int result = -1; + if (!CFNumberGetValue((CFNumberRef)v, kCFNumberIntType, &result)) return -1; + return result; +} + +// cfbool_int returns 1 if the value is kCFBooleanTrue, 0 otherwise. +static int cfbool_int(CFTypeRef v) { + if (!v) return 0; + return (v == kCFBooleanTrue) ? 1 : 0; +} + +// cfdata_bytes copies CFData bytes to a malloc'd buffer. *out_len receives the length. +static uint8_t* cfdata_bytes(CFTypeRef v, CFIndex* out_len) { + if (!v || CFGetTypeID(v) != CFDataGetTypeID()) { + *out_len = 0; + return NULL; + } + CFIndex len = CFDataGetLength((CFDataRef)v); + uint8_t* buf = (uint8_t*)malloc((size_t)len); + if (buf) { + CFDataGetBytes((CFDataRef)v, CFRangeMake(0, len), buf); + *out_len = len; + } else { + *out_len = 0; + } + return buf; +} + +// get_dict_int_v extracts an integer value for a CFString key from the dictionary. +static int get_dict_int_v(CFDictionaryRef d, CFStringRef key) { + CFTypeRef v = NULL; + CFDictionaryGetValueIfPresent(d, key, &v); + return cfnumber_int(v); +} + +// get_dict_string_v extracts a string value for a CFString key from the dictionary. +static char* get_dict_string_v(CFDictionaryRef d, CFStringRef key) { + CFTypeRef v = NULL; + CFDictionaryGetValueIfPresent(d, key, &v); + if (!v || CFGetTypeID(v) != CFStringGetTypeID()) return NULL; + return cfstring_go((CFStringRef)v); +} + +// get_dict_bool_v extracts a boolean value for a CFString key from the dictionary. +static int get_dict_bool_v(CFDictionaryRef d, CFStringRef key) { + CFTypeRef v = NULL; + CFDictionaryGetValueIfPresent(d, key, &v); + return cfbool_int(v); +} + +// get_dict_data_v extracts raw bytes from a CFData value for a CFString key. +static uint8_t* get_dict_data_v(CFDictionaryRef d, CFStringRef key, CFIndex* out_len) { + CFTypeRef v = NULL; + CFDictionaryGetValueIfPresent(d, key, &v); + return cfdata_bytes(v, out_len); +} + +// cfdate_iso8601 converts a CFDate to an ISO 8601 string. +static char* cfdate_iso8601(CFTypeRef v) { + if (!v || CFGetTypeID(v) != CFDateGetTypeID()) return NULL; + CFAbsoluteTime abs = CFDateGetAbsoluteTime((CFDateRef)v); + // CFAbsoluteTime is seconds since 2001-01-01 00:00:00 UTC. + // Convert to Unix timestamp and format with strftime. + time_t unix = (time_t)(abs + 978307200.0); // 978307200 = seconds from 1970 to 2001 + struct tm utc; + if (!gmtime_r(&unix, &utc)) return NULL; + char buf[32]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &utc); + return strdup(buf); +} + +// pack_cert_chain takes a CFArray of CFData (DER-encoded certificates) +// and packs them into a single malloc'd byte buffer as a sequence of +// (4-byte big-endian length || DER bytes) entries. *out_len receives the +// total buffer length. Returns NULL if the array is empty or invalid. +// The caller must free the returned buffer. +static uint8_t* pack_cert_chain(CFArrayRef certs, CFIndex* out_len) { + *out_len = 0; + if (!certs || CFGetTypeID(certs) != CFArrayGetTypeID()) return NULL; + CFIndex count = CFArrayGetCount(certs); + if (count == 0) return NULL; + + // First pass: calculate total buffer size. + CFIndex total = 0; + for (CFIndex i = 0; i < count; i++) { + CFTypeRef item = CFArrayGetValueAtIndex(certs, i); + if (!item || CFGetTypeID(item) != CFDataGetTypeID()) continue; + total += 4 + CFDataGetLength((CFDataRef)item); + } + if (total == 0) return NULL; + + uint8_t* buf = (uint8_t*)malloc((size_t)total); + if (!buf) return NULL; + + // Second pass: write (len BE || data) for each cert. + uint8_t* dst = buf; + CFIndex written = 0; + for (CFIndex i = 0; i < count; i++) { + CFTypeRef item = CFArrayGetValueAtIndex(certs, i); + if (!item || CFGetTypeID(item) != CFDataGetTypeID()) continue; + CFDataRef data = (CFDataRef)item; + CFIndex len = CFDataGetLength(data); + if (len > 0xffffffff) continue; // exceeds 4-byte packed format + // Big-endian length prefix. + dst[0] = (uint8_t)((len >> 24) & 0xff); + dst[1] = (uint8_t)((len >> 16) & 0xff); + dst[2] = (uint8_t)((len >> 8) & 0xff); + dst[3] = (uint8_t)(len & 0xff); + dst += 4; + CFDataGetBytes(data, CFRangeMake(0, len), dst); + dst += len; + written += 4 + len; + } + + if (written == 0) { + free(buf); + *out_len = 0; + return NULL; + } + *out_len = written; + return buf; +} + +// eapol_query calls EAPOLControlCopyStateAndStatus for the given interface +// and fills the provided Go-accessible fields. Returns 0 on success. +// All string/buffer outputs are malloc'd and must be freed by the caller. +int eapol_query( + const char* ifname, + int* out_state, + int* out_supplicant_state, + int* out_eap_type, + char** out_eap_type_name, + int* out_client_status, + int* out_domain_specific_error, + uint8_t** out_auth_mac, + CFIndex* out_auth_mac_len, + int* out_mode, + int* out_tls_session_was_resumed, + uint8_t** out_cert_chain_data, + CFIndex* out_cert_chain_len, + int* out_tls_trust_client_status, + int* out_tls_negotiated_cipher, + char** out_tls_negotiated_protocol_version, + int* out_inner_eap_type, + char** out_inner_eap_type_name, + char** out_last_status_timestamp, + char** out_unique_identifier +) { + // Initialize all outputs before any early return path. + *out_state = -1; + *out_supplicant_state = -1; + *out_eap_type = -1; + *out_eap_type_name = NULL; + *out_client_status = -1; + *out_domain_specific_error = -1; + *out_auth_mac = NULL; + *out_auth_mac_len = 0; + *out_mode = -1; + *out_tls_session_was_resumed = 0; + *out_cert_chain_data = NULL; + *out_cert_chain_len = 0; + *out_tls_trust_client_status = -1; + *out_tls_negotiated_cipher = -1; + *out_tls_negotiated_protocol_version = NULL; + *out_inner_eap_type = -1; + *out_inner_eap_type_name = NULL; + *out_last_status_timestamp = NULL; + *out_unique_identifier = NULL; + + if (!copy_state_fn) return -1; + + uint32_t state = 0; + CFDictionaryRef status = NULL; + int ret = copy_state_fn(ifname, &state, &status); + + if (ret != 0 || status == NULL) { + if (status) CFRelease(status); + return ret; + } + + *out_state = (int)state; + + // Pre-create CFString keys for all dictionary lookups to avoid + // per-lookup CFStringCreateWithCString/CFRelease overhead. + CFStringRef kSupplicantState = CFSTR("SupplicantState"); + CFStringRef kEAPType = CFSTR("EAPType"); + CFStringRef kEAPTypeName = CFSTR("EAPTypeName"); + CFStringRef kClientStatus = CFSTR("ClientStatus"); + CFStringRef kDomainSpecificError = CFSTR("DomainSpecificError"); + CFStringRef kAuthenticatorMACAddress = CFSTR("AuthenticatorMACAddress"); + CFStringRef kMode = CFSTR("Mode"); + CFStringRef kUniqueIdentifier = CFSTR("UniqueIdentifier"); + CFStringRef kLastStatusTimestamp = CFSTR("LastStatusTimestamp"); + CFStringRef kAdditionalProperties = CFSTR("AdditionalProperties"); + CFStringRef kTLSSessionWasResumed = CFSTR("TLSSessionWasResumed"); + CFStringRef kTLSServerCertChain = CFSTR("TLSServerCertificateChain"); + CFStringRef kTLSTrustClientStatus = CFSTR("TLSTrustClientStatus"); + CFStringRef kTLSNegotiatedCipher = CFSTR("TLSNegotiatedCipher"); + CFStringRef kTLSNegProtocolVersion = CFSTR("TLSNegotiatedProtocolVersion"); + CFStringRef kInnerEAPType = CFSTR("InnerEAPType"); + CFStringRef kInnerEAPTypeName = CFSTR("InnerEAPTypeName"); + + *out_supplicant_state = get_dict_int_v(status, kSupplicantState); + *out_eap_type = get_dict_int_v(status, kEAPType); + *out_eap_type_name = get_dict_string_v(status, kEAPTypeName); + *out_client_status = get_dict_int_v(status, kClientStatus); + *out_domain_specific_error = get_dict_int_v(status, kDomainSpecificError); + *out_auth_mac = get_dict_data_v(status, kAuthenticatorMACAddress, out_auth_mac_len); + *out_mode = get_dict_int_v(status, kMode); + *out_unique_identifier = get_dict_string_v(status, kUniqueIdentifier); + + // LastStatusTimestamp lives in the main status dict as a CFDate. + { + CFTypeRef tsVal = NULL; + CFDictionaryGetValueIfPresent(status, kLastStatusTimestamp, &tsVal); + *out_last_status_timestamp = cfdate_iso8601(tsVal); + } + + // TLSSessionWasResumed, TLSServerCertificateChain, + // TLSTrustClientStatus, and TLSNegotiatedProtocolVersion live in + // AdditionalProperties sub-dictionary. + { + CFTypeRef apVal = NULL; + if (CFDictionaryGetValueIfPresent(status, kAdditionalProperties, &apVal) && apVal) { + if (CFGetTypeID(apVal) == CFDictionaryGetTypeID()) { + CFDictionaryRef apDict = (CFDictionaryRef)apVal; + *out_tls_session_was_resumed = get_dict_bool_v(apDict, kTLSSessionWasResumed); + CFTypeRef certChain = NULL; + CFDictionaryGetValueIfPresent(apDict, kTLSServerCertChain, &certChain); + if (certChain && CFGetTypeID(certChain) == CFArrayGetTypeID()) { + *out_cert_chain_data = pack_cert_chain((CFArrayRef)certChain, + out_cert_chain_len); + } + *out_tls_trust_client_status = get_dict_int_v(apDict, kTLSTrustClientStatus); + *out_tls_negotiated_cipher = get_dict_int_v(apDict, kTLSNegotiatedCipher); + *out_tls_negotiated_protocol_version = get_dict_string_v(apDict, + kTLSNegProtocolVersion); + *out_inner_eap_type = get_dict_int_v(apDict, kInnerEAPType); + *out_inner_eap_type_name = get_dict_string_v(apDict, kInnerEAPTypeName); + } + } + } + + CFRelease(status); + return 0; +} +*/ +import "C" +import ( + "fmt" + "sync" + "unsafe" +) + +// productionBackend calls EAPOLControlCopyStateAndStatus via cgo. +type productionBackend struct{} + +var loadOnce sync.Once + +func newBackend() EAPOLBackend { + loadOnce.Do(func() { C.load_eapol() }) + return productionBackend{} +} + +func (productionBackend) GetStatus(ifname string) (EAPOLStatus, error) { + cName := C.CString(ifname) + defer C.free(unsafe.Pointer(cName)) + + var ( + cState C.int + cSupplicantState C.int + cEAPType C.int + cEAPTypeName *C.char + cClientStatus C.int + cDomainError C.int + cAuthMAC *C.uint8_t + cAuthMACLen C.CFIndex + cMode C.int + cTLSResumed C.int + cCertChainData *C.uint8_t + cCertChainLen C.CFIndex + cTLSTrustStatus C.int + cTLSCipher C.int + cTLSProtoVersion *C.char + cInnerEAPType C.int + cInnerEAPTypeName *C.char + cLastTimestamp *C.char + cUniqueID *C.char + ) + + ret := C.eapol_query( + cName, + &cState, + &cSupplicantState, + &cEAPType, + &cEAPTypeName, + &cClientStatus, + &cDomainError, + &cAuthMAC, + &cAuthMACLen, + &cMode, + &cTLSResumed, + &cCertChainData, + &cCertChainLen, + &cTLSTrustStatus, + &cTLSCipher, + &cTLSProtoVersion, + &cInnerEAPType, + &cInnerEAPTypeName, + &cLastTimestamp, + &cUniqueID, + ) + + s := EAPOLStatus{ + Interface: ifname, + State: int(cState), + SupplicantState: int(cSupplicantState), + EAPType: int(cEAPType), + ClientStatus: int(cClientStatus), + DomainSpecificError: int(cDomainError), + Mode: int(cMode), + TLSSessionWasResumed: cTLSResumed == 1, + } + + defer func() { + if cEAPTypeName != nil { + C.free(unsafe.Pointer(cEAPTypeName)) + } + if cAuthMAC != nil { + C.free(unsafe.Pointer(cAuthMAC)) + } + if cCertChainData != nil { + C.free(unsafe.Pointer(cCertChainData)) + } + if cTLSProtoVersion != nil { + C.free(unsafe.Pointer(cTLSProtoVersion)) + } + if cInnerEAPTypeName != nil { + C.free(unsafe.Pointer(cInnerEAPTypeName)) + } + if cLastTimestamp != nil { + C.free(unsafe.Pointer(cLastTimestamp)) + } + if cUniqueID != nil { + C.free(unsafe.Pointer(cUniqueID)) + } + }() + + if cEAPTypeName != nil { + s.EAPTypeName = C.GoString(cEAPTypeName) + } + if cAuthMAC != nil && cAuthMACLen == 6 { + macBytes := unsafe.Slice((*byte)(unsafe.Pointer(cAuthMAC)), 6) + s.AuthenticatorMACAddress = macAddrString(macBytes) + } + if cCertChainData != nil && cCertChainLen > 0 { + s.TLSServerCertificateChain, s.TLSServerCertificateSHA1, s.TLSServerCertificateSerials = parseTLSCertChain( + unsafe.Slice((*byte)(unsafe.Pointer(cCertChainData)), int(cCertChainLen))) + } + s.TLSTrustClientStatus = int(cTLSTrustStatus) + s.TLSNegotiatedCipher = int(cTLSCipher) + if cTLSProtoVersion != nil { + s.TLSNegotiatedProtocolVersion = C.GoString(cTLSProtoVersion) + } + s.InnerEAPType = int(cInnerEAPType) + if cInnerEAPTypeName != nil { + s.InnerEAPTypeName = C.GoString(cInnerEAPTypeName) + } + if cLastTimestamp != nil { + s.LastStatusTimestamp = C.GoString(cLastTimestamp) + } + if cUniqueID != nil { + s.UniqueIdentifier = C.GoString(cUniqueID) + } + + if ret != 0 { + if ret == -1 { + return s, fmt.Errorf("%w: could not load EAPOLControlCopyStateAndStatus for %s", ErrBackendUnavailable, ifname) + } + return s, fmt.Errorf("EAPOLControlCopyStateAndStatus returned %d for %s", int(ret), ifname) + } + + return s, nil +} diff --git a/tables/eapolstatus/eapol_darwin_test.go b/tables/eapolstatus/eapol_darwin_test.go new file mode 100644 index 0000000..e305ac7 --- /dev/null +++ b/tables/eapolstatus/eapol_darwin_test.go @@ -0,0 +1,37 @@ +//go:build darwin + +package eapolstatus + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCgoFrameworkLoading(t *testing.T) { + // Not parallel: dlopen has global state via the static copy_state_fn. + backend := newBackend() + + // Verify the framework loads and the symbol resolves. + // load_eapol() is called in newBackend via sync.Once, so the + // framework is loaded before the first GetStatus call. + _, err := backend.GetStatus("en0") + // EAP8021X.framework should be loadable on any macOS system. + // The call should NOT fail with ErrBackendUnavailable. + assert.NotErrorIs(t, err, ErrBackendUnavailable, + "EAP8021X.framework should be loadable on darwin") + + // If there's no active EAP session on en0, that's fine — we get a + // non-nil error but NOT ErrBackendUnavailable. +} + +func TestCgoBogusInterface(t *testing.T) { + backend := newBackend() + + _, err := backend.GetStatus("bogus999999") + require.Error(t, err) + // Bogus interface should not trigger framework-unavailable error. + assert.NotErrorIs(t, err, ErrBackendUnavailable) + assert.Contains(t, err.Error(), "bogus999999") +} diff --git a/tables/eapolstatus/eapol_other.go b/tables/eapolstatus/eapol_other.go new file mode 100644 index 0000000..daa37d8 --- /dev/null +++ b/tables/eapolstatus/eapol_other.go @@ -0,0 +1,15 @@ +//go:build !darwin + +package eapolstatus + +import "fmt" + +func newBackend() EAPOLBackend { + return noopBackend{} +} + +type noopBackend struct{} + +func (noopBackend) GetStatus(ifname string) (EAPOLStatus, error) { + return EAPOLStatus{Interface: ifname}, fmt.Errorf("%w: EAP8021X framework not available on this platform", ErrBackendUnavailable) +} diff --git a/tables/eapolstatus/eapolstatus.go b/tables/eapolstatus/eapolstatus.go new file mode 100644 index 0000000..104e2b8 --- /dev/null +++ b/tables/eapolstatus/eapolstatus.go @@ -0,0 +1,443 @@ +package eapolstatus + +import ( + "context" + "crypto/sha1" //nolint:gosec // sha1 used only for certificate fingerprint display + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/osquery/osquery-go/plugin/table" +) + +// EAPOLStatus holds the EAPOL state and status for a single interface. +type EAPOLStatus struct { + Interface string + State int // EAPOLControlState: 0=Idle,1=Starting,2=Running,3=Stopping + SupplicantState int // 802.1X supplicant state machine value + EAPType int // EAP method code (e.g. 13=TLS) + EAPTypeName string // human-readable EAP method (e.g. "EAP-TLS") + ClientStatus int // 0=ok, nonzero=error code + DomainSpecificError int + AuthenticatorMACAddress string // colon-separated + Mode int // 0=None,1=User,2=LoginWindow,3=System + TLSSessionWasResumed bool + TLSServerCertificateChain string // pipe-separated subject DNs in LDAP notation + TLSServerCertificateSHA1 string // comma-separated colon-separated SHA-1 fingerprints + TLSServerCertificateSerials string // comma-separated hex serial numbers + TLSTrustClientStatus int // trust evaluation error code (0=ok) + TLSNegotiatedProtocolVersion string // "1.2" or "1.3" + TLSNegotiatedCipher int // TLS cipher suite code + InnerEAPType int // inner EAP method for tunneled auth (PEAP/TTLS) + InnerEAPTypeName string // human-readable inner EAP method + LastStatusTimestamp string // ISO 8601 + UniqueIdentifier string +} + +// EAPOLBackend fetches EAPOL status for a named interface. +// Production implementation calls EAPOLControlCopyStateAndStatus +// via cgo (eapol_darwin.go); tests inject a fake. +type EAPOLBackend interface { + GetStatus(ifname string) (EAPOLStatus, error) +} + +// ErrBackendUnavailable is returned by GetStatus when the EAP8021X +// framework cannot be loaded (a systemic failure, not per-interface). +var ErrBackendUnavailable = errors.New("EAP8021X.framework unavailable") + +// stateNames maps EAPOLControlState to human-readable strings. +var stateNames = map[int]string{ + 0: "Idle", + 1: "Starting", + 2: "Running", + 3: "Stopping", +} + +// supplicantStateNames maps SupplicantState to human-readable strings. +var supplicantStateNames = map[int]string{ + 0: "Disconnected", + 1: "Connecting", + 2: "Acquired", + 3: "Authenticating", + 4: "Authenticated", + 5: "Held", + 6: "Logoff", + 7: "Inactive", + 8: "No Authenticator", +} + +// eapTypeNames maps EAPType codes to human-readable strings. +var eapTypeNames = map[int]string{ + 1: "Identity", + 2: "Notification", + 3: "Nak", + 4: "MD5-Challenge", + 5: "One-Time Password", + 6: "Generic Token Card", + 13: "EAP-TLS", + 17: "Cisco LEAP", + 18: "EAP-SIM", + 19: "SRP-SHA1", + 21: "EAP-TTLS", + 23: "EAP-AKA", + 25: "PEAP", + 26: "MSCHAPv2", + 33: "Extensions", + 43: "EAP-FAST", + 50: "EAP-AKA-Prime", +} + +// modeNames maps EAPOLControlMode to human-readable strings. +var modeNames = map[int]string{ + 0: "None", + 1: "User", + 2: "LoginWindow", + 3: "System", +} + +// EAPOLStatusColumns returns the column definitions. +func EAPOLStatusColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn("interface"), + table.IntegerColumn("state"), + table.TextColumn("state_name"), + table.IntegerColumn("supplicant_state"), + table.TextColumn("supplicant_state_name"), + table.IntegerColumn("eap_type"), + table.TextColumn("eap_type_name"), + table.IntegerColumn("client_status"), + table.IntegerColumn("domain_specific_error"), + table.TextColumn("authenticator_mac_address"), + table.IntegerColumn("mode"), + table.TextColumn("mode_name"), + table.IntegerColumn("tls_session_was_resumed"), + table.TextColumn("tls_server_certificate_chain"), + table.TextColumn("tls_server_certificate_sha1"), + table.TextColumn("tls_server_certificate_serials"), + table.IntegerColumn("tls_trust_client_status"), + table.TextColumn("tls_negotiated_protocol_version"), + table.IntegerColumn("tls_negotiated_cipher"), + table.IntegerColumn("inner_eap_type"), + table.TextColumn("inner_eap_type_name"), + table.TextColumn("last_status_timestamp"), + table.TextColumn("unique_identifier"), + } +} + +// EAPOLStatusGenerate generates table rows by querying each interface. +func EAPOLStatusGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + return generateRows(ctx, newBackend(), queryContext) +} + +// generateRows queries the backend for the requested interfaces. If the +// constraint "interface" is provided, only that interface is queried; +// otherwise en0 through en9 are probed. The context is checked before each +// backend call to support cancellation. +func generateRows(ctx context.Context, backend EAPOLBackend, queryContext table.QueryContext) ([]map[string]string, error) { + ifaces := interfacesToQuery(queryContext) + var rows []map[string]string + + for _, ifname := range ifaces { + if err := ctx.Err(); err != nil { + return nil, err + } + s, err := backend.GetStatus(ifname) + if err != nil { + if errors.Is(err, ErrBackendUnavailable) { + return nil, err + } + continue + } + rows = append(rows, rowFromStatus(s)) + } + + return rows, nil +} + +// interfacesToQuery returns the list of interfaces to query. If a WHERE +// constraint "interface" is present, only that interface is returned; +// otherwise en0 through en9 are enumerated. +func interfacesToQuery(queryContext table.QueryContext) []string { + if constraints, ok := queryContext.Constraints["interface"]; ok { + seen := make(map[string]struct{}) + var ifaces []string + for _, c := range constraints.Constraints { + if c.Operator == table.OperatorEquals && c.Expression != "" { + if _, ok := seen[c.Expression]; ok { + continue + } + seen[c.Expression] = struct{}{} + ifaces = append(ifaces, c.Expression) + } + } + if len(ifaces) > 0 { + return ifaces + } + } + + ifaces := make([]string, 0, 10) + for i := 0; i < 10; i++ { + ifaces = append(ifaces, "en"+strconv.Itoa(i)) + } + return ifaces +} + +func rowFromStatus(s EAPOLStatus) map[string]string { + row := map[string]string{ + "interface": s.Interface, + "state": itoa(s.State), + "state_name": lookupName(stateNames, s.State), + "supplicant_state": itoa(s.SupplicantState), + "supplicant_state_name": lookupName(supplicantStateNames, s.SupplicantState), + "eap_type": itoa(s.EAPType), + "eap_type_name": "", + "client_status": itoa(s.ClientStatus), + "domain_specific_error": itoa(s.DomainSpecificError), + "authenticator_mac_address": s.AuthenticatorMACAddress, + "mode": itoa(s.Mode), + "mode_name": lookupName(modeNames, s.Mode), + "tls_server_certificate_chain": s.TLSServerCertificateChain, + "tls_server_certificate_sha1": s.TLSServerCertificateSHA1, + "tls_server_certificate_serials": s.TLSServerCertificateSerials, + "tls_trust_client_status": itoa(s.TLSTrustClientStatus), + "tls_negotiated_protocol_version": s.TLSNegotiatedProtocolVersion, + "tls_negotiated_cipher": itoa(s.TLSNegotiatedCipher), + "inner_eap_type": itoa(s.InnerEAPType), + "inner_eap_type_name": "", + "last_status_timestamp": s.LastStatusTimestamp, + "unique_identifier": s.UniqueIdentifier, + } + if s.TLSSessionWasResumed { + row["tls_session_was_resumed"] = "1" + } else { + row["tls_session_was_resumed"] = "0" + } + if s.EAPTypeName != "" { + row["eap_type_name"] = s.EAPTypeName + } else if s.EAPType > 0 { + row["eap_type_name"] = lookupName(eapTypeNames, s.EAPType) + } + if s.InnerEAPTypeName != "" { + row["inner_eap_type_name"] = s.InnerEAPTypeName + } else if s.InnerEAPType > 0 { + row["inner_eap_type_name"] = lookupName(eapTypeNames, s.InnerEAPType) + } + return row +} + +func itoa(v int) string { + if v < 0 { + return "" + } + return strconv.Itoa(v) +} + +func lookupName(names map[int]string, v int) string { + if name, ok := names[v]; ok { + return name + } + if v < 0 { + return "" + } + return "Unknown(" + strconv.Itoa(v) + ")" +} + +// parseTLSCertChain unpacks a packed buffer of DER certificates (each +// prefixed with a 4-byte big-endian length) and returns (subject DNs in LDAP +// notation, SHA-1 fingerprints, serial numbers). DNs are pipe-separated +// ("|") because LDAP DNs themselves use commas as RDN separators. If the +// input is entirely empty the results are empty strings; if parsing of a +// single cert fails, it is skipped and previously-parsed certs are retained. +func parseTLSCertChain(packed []byte) (subjects, sha1s, serials string) { + if len(packed) == 0 { + return "", "", "" + } + var dnParts, sha1Parts, serialParts []string + offset := 0 + for offset+4 <= len(packed) { + length := binary.BigEndian.Uint32(packed[offset : offset+4]) + offset += 4 + if length == 0 { + continue + } + // Validate with uint32 arithmetic before converting to int, + // avoiding overflow on 32-bit platforms or malformed input. + if uint64(offset)+uint64(length) > uint64(len(packed)) { + break + } + intLen := int(length) + der := packed[offset : offset+intLen] + offset += intLen + cert, err := x509.ParseCertificate(der) + if err != nil { + continue + } + dnParts = append(dnParts, renderRDNSequence(reverseRDNSequence(cert.Subject.ToRDNSequence()))) + sha1Parts = append(sha1Parts, sha1String(sha1.Sum(cert.Raw))) + serialParts = append(serialParts, cert.SerialNumber.Text(16)) + } + return strings.Join(dnParts, "|"), strings.Join(sha1Parts, ","), strings.Join(serialParts, ",") +} + +// renderRDNSequence converts an x509 RDN sequence to LDAP notation +// (e.g. "CN=radius.campus.edu,OU=IT,O=Campus"). The input should be in +// display order (most-specific first). Use reverseRDNSequence to convert +// from cert.Subject.ToRDNSequence() which returns least-specific first. +func renderRDNSequence(rdns pkix.RDNSequence) string { + var parts []string + for _, rdn := range rdns { + var rdnParts []string + for _, atv := range rdn { + key := atv.Type.String() + // Shorten OID strings to common names. + switch key { + case "2.5.4.3": + key = "CN" + case "2.5.4.6": + key = "C" + case "2.5.4.7": + key = "L" + case "2.5.4.8": + key = "ST" + case "2.5.4.10": + key = "O" + case "2.5.4.11": + key = "OU" + } + val, ok := atv.Value.(string) + if !ok { + val = fmt.Sprintf("%v", atv.Value) + } + rdnParts = append(rdnParts, fmt.Sprintf("%s=%s", key, escapeDN(val))) + } + parts = append(parts, strings.Join(rdnParts, "+")) + } + return strings.Join(parts, ",") +} + +// needsEscape checks whether a character in s at position i needs RFC4514 +// escaping. Handles: special chars, leading/trailing space, leading #, and +// control characters. +func needsEscape(s string, i int) bool { + r := s[i] + if r == ',' || r == '+' || r == '"' || r == '\\' || r == '<' || r == '>' || r == ';' || r == '=' || r == 0x7f || r < ' ' { + return true + } + if r == ' ' && (i == 0 || i == len(s)-1) { + return true + } + if r == '#' && i == 0 { + return true + } + return false +} + +// escapeDN escapes a DN attribute value per RFC4514 section 2.4. +func escapeDN(s string) string { + if s == "" { + return s + } + needs := false + for i := range s { + if needsEscape(s, i) { + needs = true + break + } + } + if !needs { + return s + } + var buf strings.Builder + for i, r := range s { + switch r { + case ',': + buf.WriteString("\\,") + case '+': + buf.WriteString("\\+") + case '"': + buf.WriteString("\\\"") + case '\\': + buf.WriteString("\\\\") + case '<': + buf.WriteString("\\<") + case '>': + buf.WriteString("\\>") + case ';': + buf.WriteString("\\;") + case '=': + buf.WriteString("\\=") + case ' ': + if i == 0 || i == len(s)-1 { + buf.WriteString("\\ ") + } else { + buf.WriteRune(' ') + } + case '#': + if i == 0 { + buf.WriteString("\\#") + } else { + buf.WriteRune('#') + } + default: + if r < ' ' || r == 0x7f { + fmt.Fprintf(&buf, "\\%02X", r) + } else { + buf.WriteRune(r) + } + } + } + return buf.String() +} + +// reverseRDNSequence returns a new RDNSequence in reverse order. +// cert.Subject.ToRDNSequence() returns least-specific-first (C,O,OU,CN); +// this produces display order (CN,OU,O,C). +func reverseRDNSequence(rdns pkix.RDNSequence) pkix.RDNSequence { + if len(rdns) == 0 { + return rdns + } + reversed := make(pkix.RDNSequence, len(rdns)) + for i, rdn := range rdns { + reversed[len(rdns)-1-i] = rdn + } + return reversed +} + +// sha1String formats a 20-byte SHA-1 hash as colon-separated hex pairs. +func sha1String(hash [20]byte) string { + b := make([]byte, 0, 59) // 19 colons + 40 hex chars + for i, v := range hash { + if i > 0 { + b = append(b, ':') + } + b = append(b, hexChar(v>>4), hexChar(v&0x0f)) + } + return string(b) +} + +func hexChar(n byte) byte { + n &= 0xf + if n < 10 { + return '0' + n + } + return 'a' + n - 10 +} + +// macAddrString formats 6 raw bytes as a colon-separated MAC address. +func macAddrString(b []byte) string { + if len(b) != 6 { + return "" + } + buf := make([]byte, 0, 17) // 5 colons + 12 hex chars + for i, v := range b { + if i > 0 { + buf = append(buf, ':') + } + buf = append(buf, hexChar(v>>4), hexChar(v&0x0f)) + } + return string(buf) +} diff --git a/tables/eapolstatus/eapolstatus_test.go b/tables/eapolstatus/eapolstatus_test.go new file mode 100644 index 0000000..1bbc990 --- /dev/null +++ b/tables/eapolstatus/eapolstatus_test.go @@ -0,0 +1,663 @@ +package eapolstatus + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha1" //nolint:gosec // sha1 used only for certificate fingerprint display + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/binary" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/osquery/osquery-go/plugin/table" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeBackend is a deterministic in-memory EAPOLBackend for tests. +type fakeBackend struct { + statuses map[string]EAPOLStatus +} + +func (f fakeBackend) GetStatus(ifname string) (EAPOLStatus, error) { + s, ok := f.statuses[ifname] + if ok { + return s, nil + } + return EAPOLStatus{Interface: ifname}, errors.New("not found") +} + +func TestEAPOLStatusColumns(t *testing.T) { + t.Parallel() + want := []string{ + "interface", "state", "state_name", + "supplicant_state", "supplicant_state_name", + "eap_type", "eap_type_name", + "client_status", "domain_specific_error", + "authenticator_mac_address", + "mode", "mode_name", + "tls_session_was_resumed", + "tls_server_certificate_chain", + "tls_server_certificate_sha1", + "tls_server_certificate_serials", + "tls_trust_client_status", + "tls_negotiated_protocol_version", + "tls_negotiated_cipher", + "inner_eap_type", + "inner_eap_type_name", + "last_status_timestamp", + "unique_identifier", + } + cols := EAPOLStatusColumns() + require.Len(t, cols, len(want)) + for i, c := range cols { + assert.Equal(t, want[i], c.Name) + } +} + +func TestInterfacesToQuery(t *testing.T) { + t.Parallel() + + t.Run("no constraint returns en0-en9", func(t *testing.T) { + t.Parallel() + qc := table.QueryContext{} + ifaces := interfacesToQuery(qc) + assert.Len(t, ifaces, 10) + assert.Equal(t, "en0", ifaces[0]) + assert.Equal(t, "en9", ifaces[9]) + }) + + t.Run("with equals constraint returns specified interface", func(t *testing.T) { + t.Parallel() + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + }, + }, + }, + } + ifaces := interfacesToQuery(qc) + assert.Equal(t, []string{"en0"}, ifaces) + }) + + t.Run("with LIKE constraint falls back to en0-en9", func(t *testing.T) { + t.Parallel() + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorLike, Expression: "en%"}, + }, + }, + }, + } + ifaces := interfacesToQuery(qc) + assert.Len(t, ifaces, 10) + }) + + t.Run("duplicate constraints deduplicated", func(t *testing.T) { + t.Parallel() + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + {Operator: table.OperatorEquals, Expression: "en0"}, + {Operator: table.OperatorEquals, Expression: "en1"}, + }, + }, + }, + } + ifaces := interfacesToQuery(qc) + assert.Equal(t, []string{"en0", "en1"}, ifaces) + }) +} + +func TestRowFromStatus(t *testing.T) { + t.Parallel() + + s := EAPOLStatus{ + Interface: "en0", + State: 2, + SupplicantState: 4, + EAPType: 13, + EAPTypeName: "EAP-TLS", + ClientStatus: 0, + DomainSpecificError: 0, + AuthenticatorMACAddress: "aa:bb:cc:dd:ee:ff", + Mode: 1, + TLSSessionWasResumed: true, + TLSServerCertificateChain: "CN=radius.campus.edu,OU=IT,O=Campus,C=US", + TLSServerCertificateSHA1: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + TLSServerCertificateSerials: "7D3A1F9E2B5C", + TLSTrustClientStatus: 0, + TLSNegotiatedProtocolVersion: "1.2", + TLSNegotiatedCipher: 0xC02B, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + InnerEAPType: 26, + InnerEAPTypeName: "MSCHAPv2", + LastStatusTimestamp: "2026-06-05T12:00:00Z", + UniqueIdentifier: "abc-123", + } + + row := rowFromStatus(s) + assert.Equal(t, "en0", row["interface"]) + assert.Equal(t, "2", row["state"]) + assert.Equal(t, "Running", row["state_name"]) + assert.Equal(t, "4", row["supplicant_state"]) + assert.Equal(t, "Authenticated", row["supplicant_state_name"]) + assert.Equal(t, "13", row["eap_type"]) + assert.Equal(t, "EAP-TLS", row["eap_type_name"]) + assert.Equal(t, "0", row["client_status"]) + assert.Equal(t, "0", row["domain_specific_error"]) + assert.Equal(t, "aa:bb:cc:dd:ee:ff", row["authenticator_mac_address"]) + assert.Equal(t, "1", row["mode"]) + assert.Equal(t, "User", row["mode_name"]) + assert.Equal(t, "1", row["tls_session_was_resumed"]) + assert.Equal(t, "CN=radius.campus.edu,OU=IT,O=Campus,C=US", row["tls_server_certificate_chain"]) + assert.Equal(t, "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", row["tls_server_certificate_sha1"]) + assert.Equal(t, "7D3A1F9E2B5C", row["tls_server_certificate_serials"]) + assert.Equal(t, "0", row["tls_trust_client_status"]) + assert.Equal(t, "1.2", row["tls_negotiated_protocol_version"]) + assert.Equal(t, "49195", row["tls_negotiated_cipher"]) + assert.Equal(t, "26", row["inner_eap_type"]) + assert.Equal(t, "MSCHAPv2", row["inner_eap_type_name"]) + assert.Equal(t, "2026-06-05T12:00:00Z", row["last_status_timestamp"]) + assert.Equal(t, "abc-123", row["unique_identifier"]) +} + +func TestRowFromStatusUnsetFields(t *testing.T) { + t.Parallel() + + s := EAPOLStatus{ + Interface: "en0", + State: 0, + SupplicantState: 8, + TLSSessionWasResumed: false, + } + + row := rowFromStatus(s) + assert.Equal(t, "Idle", row["state_name"]) + assert.Equal(t, "No Authenticator", row["supplicant_state_name"]) + assert.Equal(t, "", row["eap_type_name"]) + assert.Equal(t, "0", row["tls_session_was_resumed"]) +} + +func TestRowFromStatusUnknownEnumValues(t *testing.T) { + t.Parallel() + + s := EAPOLStatus{ + Interface: "en0", + State: 99, + SupplicantState: 99, + Mode: 99, + EAPType: 0, + } + + row := rowFromStatus(s) + assert.Equal(t, "99", row["state"]) + assert.Equal(t, "Unknown(99)", row["state_name"]) + assert.Equal(t, "99", row["supplicant_state"]) + assert.Equal(t, "Unknown(99)", row["supplicant_state_name"]) + assert.Equal(t, "Unknown(99)", row["mode_name"]) + assert.Equal(t, "", row["eap_type_name"]) +} + +func TestLookupNameNegative(t *testing.T) { + t.Parallel() + + assert.Equal(t, "", lookupName(stateNames, -1)) + assert.Equal(t, "", lookupName(stateNames, -5)) +} + +func TestGenerateRowsWithConstraint(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en0": { + Interface: "en0", + State: 2, + SupplicantState: 4, + EAPType: 13, + EAPTypeName: "EAP-TLS", + Mode: 1, + }, + }, + } + + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + }, + }, + }, + } + + rows, err := generateRows(context.Background(), backend, qc) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "en0", rows[0]["interface"]) + assert.Equal(t, "Running", rows[0]["state_name"]) + assert.Equal(t, "Authenticated", rows[0]["supplicant_state_name"]) +} + +func TestGenerateRowsNoActiveInterface(t *testing.T) { + t.Parallel() + + backend := fakeBackend{statuses: map[string]EAPOLStatus{}} + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en9"}, + }, + }, + }, + } + + rows, err := generateRows(context.Background(), backend, qc) + require.NoError(t, err) + assert.Empty(t, rows) +} + +func TestGenerateRowsSkipsErrors(t *testing.T) { + t.Parallel() + + // en0 has a valid status, en1 errors — should return only en0 + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en0": { + Interface: "en0", + State: 2, + SupplicantState: 4, + }, + }, + } + + qc := table.QueryContext{} + rows, err := generateRows(context.Background(), backend, qc) + require.NoError(t, err) + assert.Len(t, rows, 1) + assert.Equal(t, "en0", rows[0]["interface"]) +} + +func TestGenerateRowsBackendUnavailable(t *testing.T) { + t.Parallel() + + wrapper := errBackend{err: ErrBackendUnavailable} + rows, err := generateRows(context.Background(), wrapper, table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + }, + }, + }, + }) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Empty(t, rows) +} + +type errBackend struct { + err error +} + +func (e errBackend) GetStatus(ifname string) (EAPOLStatus, error) { + return EAPOLStatus{Interface: ifname}, e.err +} + +func TestMacAddrString(t *testing.T) { + t.Parallel() + + assert.Equal(t, "aa:bb:cc:dd:ee:ff", + macAddrString([]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff})) + assert.Equal(t, "", macAddrString([]byte{0xaa})) // too short + assert.Equal(t, "", macAddrString([]byte{0, 1, 2, 3, 4, 5, 6})) // too long + assert.Equal(t, "", macAddrString(nil)) +} + +func TestEAPOLStatusGenerate(t *testing.T) { + t.Parallel() + + rows, err := generateRows(context.Background(), fakeBackend{statuses: map[string]EAPOLStatus{}}, table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "nonexistent_en999"}, + }, + }, + }, + }) + require.NoError(t, err) + assert.Empty(t, rows) +} + +func TestGenerateRowsContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + rows, err := generateRows(ctx, fakeBackend{}, table.QueryContext{}) + assert.ErrorIs(t, err, context.Canceled) + assert.Empty(t, rows) +} + +func TestItoa(t *testing.T) { + t.Parallel() + + assert.Equal(t, "42", itoa(42)) + assert.Equal(t, "0", itoa(0)) + assert.Equal(t, "", itoa(-1)) +} + +func TestNameMapsComplete(t *testing.T) { + t.Parallel() + + for i, name := range stateNames { + assert.NotEmpty(t, name, "stateNames missing entry for %d", i) + } + + for i, name := range supplicantStateNames { + assert.NotEmpty(t, name, "supplicantStateNames missing entry for %d", i) + } + + // Separately verify expected count matches enum range + for i := 0; i <= 8; i++ { + _, ok := supplicantStateNames[i] + assert.True(t, ok, "supplicantStateNames missing index %d", i) + } + + for i := 0; i <= 3; i++ { + _, ok := stateNames[i] + assert.True(t, ok, "stateNames missing index %d", i) + } + + for i := 0; i <= 3; i++ { + _, ok := modeNames[i] + assert.True(t, ok, "modeNames missing index %d", i) + } +} + +func TestRowFromStatusEAPTypeNameFallback(t *testing.T) { + t.Parallel() + + // When no explicit EAPTypeName is set, fall back to eapTypeNames map + s := EAPOLStatus{Interface: "en0", EAPType: 13} + row := rowFromStatus(s) + assert.Equal(t, "EAP-TLS", row["eap_type_name"]) + + // EAPTypeName set explicitly takes precedence + s2 := EAPOLStatus{Interface: "en0", EAPType: 13, EAPTypeName: "Custom-EAP"} + row2 := rowFromStatus(s2) + assert.Equal(t, "Custom-EAP", row2["eap_type_name"]) +} + +func TestMacAddrStringCaps(t *testing.T) { + t.Parallel() + // Just verify mac address strings use lowercase hex (verify). + addr := macAddrString([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}) + assert.Equal(t, "aa:bb:cc:dd:ee:ff", addr) + assert.True(t, strings.Contains(addr, "aa"), "should use lowercase") +} + +func TestParseTLSCertChain(t *testing.T) { + // Subtests that generate keys/certs with crypto/rand are serialized + // (no t.Parallel); data-only subtests use t.Parallel(). + + t.Run("empty", func(t *testing.T) { + t.Parallel() + subj, sha1s, serials := parseTLSCertChain(nil) + assert.Equal(t, "", subj) + assert.Equal(t, "", sha1s) + assert.Equal(t, "", serials) + subj, sha1s, serials = parseTLSCertChain([]byte{}) + assert.Equal(t, "", subj) + assert.Equal(t, "", sha1s) + assert.Equal(t, "", serials) + }) + + t.Run("truncated length prefix", func(t *testing.T) { + t.Parallel() + subj, sha1s, serials := parseTLSCertChain([]byte{0x00, 0x00, 0x00}) + assert.Equal(t, "", subj) + assert.Equal(t, "", sha1s) + assert.Equal(t, "", serials) + }) + + t.Run("invalid DER", func(t *testing.T) { + t.Parallel() + packed := []byte{0x00, 0x00, 0x00, 0x04, 'n', 'o', 'p', 'e'} + subj, sha1s, serials := parseTLSCertChain(packed) + assert.Equal(t, "", subj) + assert.Equal(t, "", sha1s) + assert.Equal(t, "", serials) + }) + + t.Run("length exceeds buffer", func(t *testing.T) { + t.Parallel() + packed := []byte{0x00, 0x00, 0x00, 0xff, 0x00} + subj, sha1s, serials := parseTLSCertChain(packed) + assert.Equal(t, "", subj) + assert.Equal(t, "", sha1s) + assert.Equal(t, "", serials) + }) + + t.Run("valid_single_cert", func(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(12345), + Subject: pkix.Name{ + CommonName: "test.example.com", + Organization: []string{"Test Org"}, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + + buf := make([]byte, 4+len(der)) + binary.BigEndian.PutUint32(buf, uint32(len(der))) + copy(buf[4:], der) + + subj, sha1s, serials := parseTLSCertChain(buf) + assert.Equal(t, "CN=test.example.com,O=Test Org", subj) + h := sha1.Sum(der) + expectedSHA1 := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x:%02x", + h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8], h[9], + h[10], h[11], h[12], h[13], h[14], h[15], h[16], h[17], h[18], h[19], + ) + assert.Equal(t, expectedSHA1, sha1s) + assert.Equal(t, "3039", serials) // 12345 in hex + }) + + t.Run("zero_length_entry_skipped", func(t *testing.T) { + // Two entries: first has length=0, second is a valid cert. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(999), + Subject: pkix.Name{ + CommonName: "valid.example.com", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + + buf := make([]byte, 4+0+4+len(der)) + binary.BigEndian.PutUint32(buf[0:4], 0) // zero-length entry + binary.BigEndian.PutUint32(buf[4:8], uint32(len(der))) + copy(buf[8:], der) + + subj, sha1s, serials := parseTLSCertChain(buf) + assert.Equal(t, "CN=valid.example.com", subj) + assert.NotEmpty(t, sha1s) + assert.Equal(t, "3e7", serials) // 999 in hex (lowercase from Text(16)) + }) + + t.Run("mixed_valid_and_invalid", func(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "good.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + + // Layout: valid cert | invalid garbage | valid cert + invalidBuf := []byte{0x00, 0x00, 0x00, 0x04, 'b', 'a', 'd', '!'} + buf := make([]byte, 4+len(der)+len(invalidBuf)+4+len(der)) + binary.BigEndian.PutUint32(buf[0:4], uint32(len(der))) + copy(buf[4:], der) + copy(buf[4+len(der):], invalidBuf) + binary.BigEndian.PutUint32(buf[4+len(der)+len(invalidBuf):], uint32(len(der))) + copy(buf[4+len(der)+len(invalidBuf)+4:], der) + + subj, sha1s, serials := parseTLSCertChain(buf) + parts := strings.Split(subj, "|") + assert.Len(t, parts, 2) + assert.Len(t, strings.Split(sha1s, ","), 2) + assert.Len(t, strings.Split(serials, ","), 2) + }) +} + +func TestRenderRDNSequence(t *testing.T) { + t.Parallel() + + t.Run("nil returns empty", func(t *testing.T) { + t.Parallel() + result := renderRDNSequence(nil) + assert.Equal(t, "", result) + }) + + t.Run("well_known_oids", func(t *testing.T) { + t.Parallel() + seq := pkix.RDNSequence{ + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: "radius.campus.edu"}, + }, + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 10}, Value: "CampusGroup"}, + }, + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 11}, Value: "IT"}, + }, + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 6}, Value: "US"}, + }, + } + result := renderRDNSequence(seq) + assert.Equal(t, "CN=radius.campus.edu,O=CampusGroup,OU=IT,C=US", result) + }) + + t.Run("escapes_special_chars", func(t *testing.T) { + t.Parallel() + seq := pkix.RDNSequence{ + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: "dot.com, Inc."}, + }, + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 10}, Value: "Org + Subsidiary"}, + }, + } + result := renderRDNSequence(seq) + assert.Equal(t, "CN=dot.com\\, Inc.,O=Org \\+ Subsidiary", result) + }) + + t.Run("multi_valued_rdn", func(t *testing.T) { + t.Parallel() + seq := pkix.RDNSequence{ + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 10}, Value: "Org"}, + {Type: asn1.ObjectIdentifier{2, 5, 4, 11}, Value: "Dept"}, + }, + { + {Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: "example.com"}, + }, + } + result := renderRDNSequence(seq) + assert.Equal(t, "O=Org+OU=Dept,CN=example.com", result) + }) + + t.Run("additional_rfc4514_escapes", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "equals_in_value", + input: "key=value", + expected: "key\\=value", + }, + { + name: "leading_space", + input: " leading", + expected: "\\ leading", + }, + { + name: "trailing_space", + input: "trailing ", + expected: "trailing\\ ", + }, + { + name: "leading_hash", + input: "#leading", + expected: "\\#leading", + }, + { + name: "hash_not_first", + input: "mid#hash", + expected: "mid#hash", + }, + { + name: "control_char_null", + input: "test\x00null", + expected: "test\\00null", + }, + { + name: "control_char_del", + input: "test\x7fdel", + expected: "test\\7Fdel", + }, + { + name: "control_char_esc", + input: "test\x1besc", + expected: "test\\1Besc", + }, + { + name: "single_space", + input: " ", + expected: "\\ ", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := escapeDN(tc.input) + assert.Equal(t, tc.expected, result) + }) + } + }) +} From fbe964e8f0908dfb50262b55a0e412b43b0e0978 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 15:03:52 -0400 Subject: [PATCH 02/36] CI: gate darwin binary builds behind macOS host platform check The darwin go_binary targets (osquery-extension-mac-arm/amd) require cgo + an Apple C++ toolchain. On Linux CI runners, no such toolchain exists, causing build failures. Gate the mac binary build targets in the Makefile behind so they only build on macOS hosts. Non-macOS CI continues to build+test all other targets. --- BUILD.bazel | 2 ++ Makefile | 2 ++ 2 files changed, 4 insertions(+) diff --git a/BUILD.bazel b/BUILD.bazel index 4968f7e..b1af11e 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -67,6 +67,8 @@ go_library( # toolchain on darwin. These are darwin-only binaries (goos = "darwin"), # so cgo=True does not affect Linux/Windows builds. The apple_support # toolchain in WORKSPACE satisfies the CC requirement on macOS. +# Note: these targets require a darwin C++ toolchain, so they only build +# on macOS hosts (the Makefile gates them behind `ifeq ($(shell uname),Darwin)`). go_binary( name = "osquery-extension-mac-arm", cgo = True, diff --git a/Makefile b/Makefile index 54ca489..e2b0d9b 100644 --- a/Makefile +++ b/Makefile @@ -53,8 +53,10 @@ test: bazel test --test_output=errors //... build: .pre-build +ifeq ($(shell uname),Darwin) bazel build --verbose_failures //:osquery-extension-mac-amd bazel build --verbose_failures //:osquery-extension-mac-arm +endif bazel build --verbose_failures //:osquery-extension-linux-amd bazel build --verbose_failures //:osquery-extension-linux-arm bazel build --verbose_failures //:osquery-extension-win-amd From 72810953cec8c59d2b2acfbb12d195f14e67a930 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 15:04:45 -0400 Subject: [PATCH 03/36] CI: gate mac binary cquery in bazel_to_builddir.sh behind macOS host check --- tools/bazel_to_builddir.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index ea71341..c7e0db5 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -6,9 +6,11 @@ mkdir -p build/windows APP_NAME="macadmins_extension" -cp $(bazel cquery --output=files //:osquery-extension-mac-amd 2>/dev/null) build/darwin/${APP_NAME}.amd64.ext - -cp $(bazel cquery --output=files //:osquery-extension-mac-arm 2>/dev/null) build/darwin/${APP_NAME}.arm64.ext +# Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). +if [ "$(uname)" = "Darwin" ]; then + cp $(bazel cquery --output=files //:osquery-extension-mac-amd 2>/dev/null) build/darwin/${APP_NAME}.amd64.ext + cp $(bazel cquery --output=files //:osquery-extension-mac-arm 2>/dev/null) build/darwin/${APP_NAME}.arm64.ext +fi cp $(bazel cquery --output=files //:osquery-extension-linux-amd 2>/dev/null) build/linux/${APP_NAME}.amd64.ext From e5898f1d3ce37e0ee1d7abe0665fdc64f681b0a3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 15:09:37 -0400 Subject: [PATCH 04/36] CI: scope `make test` to only go_test targets, not all targets `bazel test //...` analyzes every target in workspace including binary targets. The darwin binary targets with pure="off", cgo=True fail analysis on Linux CI because no darwin C++ toolchain exists. Switch to `bazel test $(bazel query 'kind(go_test, //...)')` which only analyzes and tests go_test targets. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e2b0d9b..541e850 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ update-repos: bazel run //:gazelle-update-repos -- -from_file=go.mod test: - bazel test --test_output=errors //... + bazel test --test_output=errors $$(bazel query 'kind(go_test, //...)') build: .pre-build ifeq ($(shell uname),Darwin) From d7bbdfe424664b72d77fdb3a7424a2322d80de90 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 15:31:39 -0400 Subject: [PATCH 05/36] CI: exclude eapolstatus from go-test-coverage on Linux eapolstatus cgo files are excluded by darwin build tags on Linux, leaving only the stub (eapol_other.go). The go-test-coverage tool reports 0% coverage for the package on Linux because the stub has minimal lines. The real test coverage is on macOS where the full cgo implementation runs. Exclude the package from coverage thresholds like other darwin-specific tables (puppet, pendingappleupdates). --- .testcoverage.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.testcoverage.yml b/.testcoverage.yml index 6082a01..c08161d 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -60,6 +60,9 @@ exclude: - tables/puppet/puppet_logs.go - tables/puppet/puppet_state.go - tables/puppet/yaml.go + - ^tables/eapolstatus$ + - tables/eapolstatus/eapolstatus.go + - tables/eapolstatus/eapol_other.go # - \.pb\.go$ # excludes all protobuf generated files # - ^pkg/bar # exclude package `pkg/bar` From e2ca4d173b4e19945765b3981eb48e24c19ce0e4 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 16:12:58 -0400 Subject: [PATCH 06/36] eapol_status: add Windows backend using wlanapi.dll Implement the EAPOLBackend interface for Windows using the Native Wifi (wlanapi.dll) API. The backend enumerates wireless adapters, queries connection state, and parses WLAN profile XML to populate: - interface, state, supplicant_state (from WLAN_INTERFACE_STATE) - eap_type (from profile XML EapMethod/Type) - mode (from profile XML authMode: machine/user/machineOrUser/guest) - inner_eap_type (from nested EapMethod for PEAP/TTLS) - authenticator_mac_address (from BSSID in connection attributes) - unique_identifier (interface GUID) - tls_server_certificate_sha1 (from profile XML TrustedRootCA) Register eapol_status for both darwin and windows in main.go. Add per-platform defaultInterfaces() so Windows enumerates real adapter names while darwin/other fall back to en0-en9. Co-Authored-By: Claude Opus 4.6 --- main.go | 10 +- tables/eapolstatus/BUILD.bazel | 1 + tables/eapolstatus/eapol_darwin.go | 9 + tables/eapolstatus/eapol_other.go | 15 +- tables/eapolstatus/eapol_windows.go | 509 ++++++++++++++++++++++++++++ tables/eapolstatus/eapolstatus.go | 14 +- 6 files changed, 547 insertions(+), 11 deletions(-) create mode 100644 tables/eapolstatus/eapol_windows.go diff --git a/main.go b/main.go index d4ab7ed..6acb732 100644 --- a/main.go +++ b/main.go @@ -78,9 +78,12 @@ func main() { } // Platform specific tables - // if runtime.GOOS == "windows" { - // If there were windows only tables, they would go here - // } + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + eapolPlugins := []osquery.OsqueryPlugin{ + table.NewPlugin("eapol_status", eapolstatus.EAPOLStatusColumns(), eapolstatus.EAPOLStatusGenerate), + } + plugins = append(plugins, eapolPlugins...) + } if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { linuxPlugins := []osquery.OsqueryPlugin{ @@ -96,7 +99,6 @@ func main() { if runtime.GOOS == "darwin" { darwinPlugins := []osquery.OsqueryPlugin{ - table.NewPlugin("eapol_status", eapolstatus.EAPOLStatusColumns(), eapolstatus.EAPOLStatusGenerate), table.NewPlugin("energy_impact", energyimpact.EnergyImpactColumns(), energyimpact.EnergyImpactGenerate), table.NewPlugin("filevault_users", filevaultusers.FileVaultUsersColumns(), filevaultusers.FileVaultUsersGenerate), table.NewPlugin("local_network_permissions", localnetworkpermissions.LocalNetworkPermissionsColumns(), localnetworkpermissions.LocalNetworkPermissionsGenerate), diff --git a/tables/eapolstatus/BUILD.bazel b/tables/eapolstatus/BUILD.bazel index bf2a0a5..db6ac63 100644 --- a/tables/eapolstatus/BUILD.bazel +++ b/tables/eapolstatus/BUILD.bazel @@ -6,6 +6,7 @@ go_library( "eapolstatus.go", "eapol_darwin.go", "eapol_other.go", + "eapol_windows.go", ], # cgo is only needed on darwin: eapol_darwin.go (//go:build darwin) calls # EAP8021X.framework (via dlopen/dlsym) + CoreFoundation via cgo. diff --git a/tables/eapolstatus/eapol_darwin.go b/tables/eapolstatus/eapol_darwin.go index d9cd25d..63b723b 100644 --- a/tables/eapolstatus/eapol_darwin.go +++ b/tables/eapolstatus/eapol_darwin.go @@ -300,6 +300,7 @@ int eapol_query( import "C" import ( "fmt" + "strconv" "sync" "unsafe" ) @@ -434,3 +435,11 @@ func (productionBackend) GetStatus(ifname string) (EAPOLStatus, error) { return s, nil } + +func defaultInterfaces() []string { + ifaces := make([]string, 0, 10) + for i := 0; i < 10; i++ { + ifaces = append(ifaces, "en"+strconv.Itoa(i)) + } + return ifaces +} diff --git a/tables/eapolstatus/eapol_other.go b/tables/eapolstatus/eapol_other.go index daa37d8..9a1bc29 100644 --- a/tables/eapolstatus/eapol_other.go +++ b/tables/eapolstatus/eapol_other.go @@ -1,8 +1,11 @@ -//go:build !darwin +//go:build !darwin && !windows package eapolstatus -import "fmt" +import ( + "fmt" + "strconv" +) func newBackend() EAPOLBackend { return noopBackend{} @@ -13,3 +16,11 @@ type noopBackend struct{} func (noopBackend) GetStatus(ifname string) (EAPOLStatus, error) { return EAPOLStatus{Interface: ifname}, fmt.Errorf("%w: EAP8021X framework not available on this platform", ErrBackendUnavailable) } + +func defaultInterfaces() []string { + ifaces := make([]string, 0, 10) + for i := 0; i < 10; i++ { + ifaces = append(ifaces, "en"+strconv.Itoa(i)) + } + return ifaces +} diff --git a/tables/eapolstatus/eapol_windows.go b/tables/eapolstatus/eapol_windows.go new file mode 100644 index 0000000..ccede6b --- /dev/null +++ b/tables/eapolstatus/eapol_windows.go @@ -0,0 +1,509 @@ +//go:build windows + +package eapolstatus + +import ( + "fmt" + "strconv" + "strings" + "sync" + "syscall" + "unsafe" +) + +const ( + wlanClientVersion = 2 + + wlanIntfOpcodeCurrentConnection uint32 = 7 + + wlanIfaceStateNotReady uint32 = 0 + wlanIfaceStateConnected uint32 = 1 + wlanIfaceStateAdHocFormed uint32 = 2 + wlanIfaceStateDisconnecting uint32 = 3 + wlanIfaceStateDisconnected uint32 = 4 + wlanIfaceStateAssociating uint32 = 5 + wlanIfaceStateDiscovering uint32 = 6 + wlanIfaceStateAuthenticating uint32 = 7 +) + +type windowsGUID struct { + Data1 uint32 + Data2 uint16 + Data3 uint16 + Data4 [8]byte +} + +func (g windowsGUID) String() string { + return fmt.Sprintf("{%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X}", + g.Data1, g.Data2, g.Data3, + g.Data4[0], g.Data4[1], + g.Data4[2], g.Data4[3], g.Data4[4], g.Data4[5], g.Data4[6], g.Data4[7]) +} + +type wlanInterfaceInfo struct { + InterfaceGuid windowsGUID + StrInterfaceDescription [256]uint16 + IsState uint32 +} + +type wlanInterfaceInfoList struct { + NumberOfItems uint32 + Index uint32 +} + +type dot11SSID struct { + SSIDLength uint32 + SSID [32]byte +} + +type wlanAssociationAttributes struct { + Dot11Ssid dot11SSID + Dot11BssType uint32 + Dot11Bssid [6]byte + _ [2]byte // align to 4-byte boundary + Dot11PhyType uint32 + Dot11PhyIndex uint32 + WlanSignalQuality uint32 + RxRate uint32 + TxRate uint32 +} + +type wlanSecurityAttributes struct { + SecurityEnabled int32 + OneXEnabled int32 + AuthAlgorithm uint32 + CipherAlgorithm uint32 +} + +type wlanConnectionAttributes struct { + IsState uint32 + ConnectionMode uint32 + ProfileName [256]uint16 + AssociationAttributes wlanAssociationAttributes + SecurityAttributes wlanSecurityAttributes +} + +var ( + modWlanapi = syscall.NewLazyDLL("wlanapi.dll") + + procWlanOpenHandle = modWlanapi.NewProc("WlanOpenHandle") + procWlanCloseHandle = modWlanapi.NewProc("WlanCloseHandle") + procWlanEnumInterfaces = modWlanapi.NewProc("WlanEnumInterfaces") + procWlanQueryInterface = modWlanapi.NewProc("WlanQueryInterface") + procWlanGetProfile = modWlanapi.NewProc("WlanGetProfile") + procWlanFreeMemory = modWlanapi.NewProc("WlanFreeMemory") +) + +var ( + wlanOnce sync.Once + wlanAvail bool +) + +func initWlan() { + if err := modWlanapi.Load(); err != nil { + return + } + for _, p := range []*syscall.LazyProc{ + procWlanOpenHandle, procWlanCloseHandle, + procWlanEnumInterfaces, procWlanQueryInterface, + procWlanGetProfile, procWlanFreeMemory, + } { + if err := p.Find(); err != nil { + return + } + } + wlanAvail = true +} + +type windowsBackend struct{} + +func newBackend() EAPOLBackend { + wlanOnce.Do(initWlan) + if !wlanAvail { + return unavailableBackend{} + } + return windowsBackend{} +} + +type unavailableBackend struct{} + +func (unavailableBackend) GetStatus(ifname string) (EAPOLStatus, error) { + return EAPOLStatus{Interface: ifname}, + fmt.Errorf("%w: wlanapi.dll not available on this system", ErrBackendUnavailable) +} + +func openWlanHandle() (uintptr, error) { + var negotiatedVersion uint32 + var handle uintptr + ret, _, _ := procWlanOpenHandle.Call( + uintptr(wlanClientVersion), + 0, + uintptr(unsafe.Pointer(&negotiatedVersion)), + uintptr(unsafe.Pointer(&handle)), + ) + if ret != 0 { + return 0, fmt.Errorf("WlanOpenHandle failed: %d", ret) + } + return handle, nil +} + +func closeWlanHandle(handle uintptr) { + procWlanCloseHandle.Call(handle, 0) //nolint:errcheck +} + +func freeWlanMemory(p uintptr) { + procWlanFreeMemory.Call(p) //nolint:errcheck +} + +// enumerateWlanInterfaces returns the descriptions of all wireless interfaces. +func enumerateWlanInterfaces() []string { + handle, err := openWlanHandle() + if err != nil { + return nil + } + defer closeWlanHandle(handle) + + var listPtr unsafe.Pointer + ret, _, _ := procWlanEnumInterfaces.Call(handle, 0, uintptr(unsafe.Pointer(&listPtr))) + if ret != 0 || listPtr == nil { + return nil + } + defer freeWlanMemory(uintptr(listPtr)) + + list := (*wlanInterfaceInfoList)(listPtr) + if list.NumberOfItems == 0 { + return nil + } + + headerSize := unsafe.Sizeof(*list) + itemSize := unsafe.Sizeof(wlanInterfaceInfo{}) + names := make([]string, 0, list.NumberOfItems) + for i := uint32(0); i < list.NumberOfItems; i++ { + offset := headerSize + uintptr(i)*itemSize + info := (*wlanInterfaceInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(list)) + offset)) + names = append(names, utf16ToString(info.StrInterfaceDescription[:])) + } + return names +} + +func defaultInterfaces() []string { + if !wlanAvail { + return nil + } + ifaces := enumerateWlanInterfaces() + if len(ifaces) == 0 { + return nil + } + return ifaces +} + +func (windowsBackend) GetStatus(ifname string) (EAPOLStatus, error) { + handle, err := openWlanHandle() + if err != nil { + return EAPOLStatus{Interface: ifname}, + fmt.Errorf("%w: %v", ErrBackendUnavailable, err) + } + defer closeWlanHandle(handle) + + guid, ifState, err := findWlanInterface(handle, ifname) + if err != nil { + return EAPOLStatus{Interface: ifname}, err + } + + s := EAPOLStatus{ + Interface: ifname, + UniqueIdentifier: guid.String(), + } + + s.State, s.SupplicantState = mapWlanState(ifState) + s.ClientStatus = -1 + s.DomainSpecificError = -1 + s.Mode = -1 + s.TLSTrustClientStatus = -1 + s.TLSNegotiatedCipher = -1 + s.InnerEAPType = -1 + s.EAPType = -1 + + if ifState != wlanIfaceStateConnected && ifState != wlanIfaceStateAuthenticating { + return s, nil + } + + var dataSize uint32 + var dataPtr unsafe.Pointer + ret, _, _ := procWlanQueryInterface.Call( + handle, + uintptr(unsafe.Pointer(guid)), + uintptr(wlanIntfOpcodeCurrentConnection), + 0, + uintptr(unsafe.Pointer(&dataSize)), + uintptr(unsafe.Pointer(&dataPtr)), + 0, + ) + if ret != 0 || dataPtr == nil { + return s, nil + } + defer freeWlanMemory(uintptr(dataPtr)) + + conn := (*wlanConnectionAttributes)(dataPtr) + + s.AuthenticatorMACAddress = macAddrString(conn.AssociationAttributes.Dot11Bssid[:]) + + sec := conn.SecurityAttributes + if sec.OneXEnabled != 0 { + if ifState == wlanIfaceStateConnected { + s.SupplicantState = 4 // Authenticated + s.ClientStatus = 0 + } + } + + profileName := utf16ToString(conn.ProfileName[:]) + if profileName != "" { + if xml, err := getWlanProfileXML(handle, guid, profileName); err == nil { + if eapType := extractEAPTypeFromXML(xml); eapType > 0 { + s.EAPType = eapType + } + if mode := extractAuthModeFromXML(xml); mode >= 0 { + s.Mode = mode + } + if innerType := extractInnerEAPTypeFromXML(xml); innerType > 0 { + s.InnerEAPType = innerType + } + if sha1 := extractTrustedRootCAFromXML(xml); sha1 != "" { + s.TLSServerCertificateSHA1 = sha1 + } + } + } + + return s, nil +} + +// getWlanProfileXML calls WlanGetProfile and returns the profile XML string. +func getWlanProfileXML(handle uintptr, guid *windowsGUID, profileName string) (string, error) { + namePtr, err := syscall.UTF16PtrFromString(profileName) + if err != nil { + return "", err + } + var xmlPtr *uint16 + var flags uint32 + ret, _, _ := procWlanGetProfile.Call( + handle, + uintptr(unsafe.Pointer(guid)), + uintptr(unsafe.Pointer(namePtr)), + 0, + uintptr(unsafe.Pointer(&xmlPtr)), + uintptr(unsafe.Pointer(&flags)), + 0, + ) + if ret != 0 || xmlPtr == nil { + return "", fmt.Errorf("WlanGetProfile failed: %d", ret) + } + defer freeWlanMemory(uintptr(unsafe.Pointer(xmlPtr))) + return utf16PtrToString(xmlPtr), nil +} + +// extractEAPTypeFromXML parses the EAP method type from a WLAN profile XML. +// The EAP type lives at EapMethod > Type inside the EAPConfig section. +func extractEAPTypeFromXML(xml string) int { + methodIdx := strings.Index(xml, "") + if methodIdx < 0 { + return -1 + } + sub := xml[methodIdx:] + typeStart := strings.Index(sub, "') + if gt < 0 { + return -1 + } + sub = sub[gt+1:] + lt := strings.IndexByte(sub, '<') + if lt < 0 { + return -1 + } + v, err := strconv.Atoi(strings.TrimSpace(sub[:lt])) + if err != nil { + return -1 + } + return v +} + +// extractAuthModeFromXML parses from the OneX section of a WLAN +// profile XML and maps it to an EAPOLControlMode value. +func extractAuthModeFromXML(xml string) int { + const open = "" + const close = "" + start := strings.Index(xml, open) + if start < 0 { + return -1 + } + sub := xml[start+len(open):] + end := strings.Index(sub, close) + if end < 0 { + return -1 + } + switch strings.TrimSpace(sub[:end]) { + case "machine": + return 3 // System + case "user": + return 1 // User + case "machineOrUser": + return 2 // LoginWindow + case "guest": + return 0 // None + default: + return -1 + } +} + +// extractInnerEAPTypeFromXML parses the inner EAP method type used by +// tunneled methods like PEAP (25) or EAP-TTLS (21). The inner method +// appears as a second inside the outer EAP config. +func extractInnerEAPTypeFromXML(xml string) int { + first := strings.Index(xml, "") + if first < 0 { + return -1 + } + rest := xml[first+len(""):] + second := strings.Index(rest, "") + if second < 0 { + return -1 + } + sub := rest[second:] + typeStart := strings.Index(sub, "') + if gt < 0 { + return -1 + } + sub = sub[gt+1:] + lt := strings.IndexByte(sub, '<') + if lt < 0 { + return -1 + } + v, err := strconv.Atoi(strings.TrimSpace(sub[:lt])) + if err != nil { + return -1 + } + return v +} + +// extractTrustedRootCAFromXML parses hex hashes from the +// ServerValidation section of a WLAN profile XML. These are the SHA-1 +// fingerprints of the CA certificates configured for RADIUS server validation. +// Multiple hashes are comma-separated with colon-delimited hex pairs. +func extractTrustedRootCAFromXML(xml string) string { + var hashes []string + remaining := xml + for { + const open = "" + const close = "" + start := strings.Index(remaining, open) + if start < 0 { + break + } + sub := remaining[start+len(open):] + end := strings.Index(sub, close) + if end < 0 { + break + } + hex := strings.ReplaceAll(strings.TrimSpace(sub[:end]), " ", "") + if len(hex) == 40 { + hashes = append(hashes, formatSHA1Hex(hex)) + } + remaining = sub[end+len(close):] + } + return strings.Join(hashes, ",") +} + +// formatSHA1Hex converts a 40-char hex string to colon-separated pairs +// (e.g. "aabb..." -> "aa:bb:..."). +func formatSHA1Hex(hex string) string { + hex = strings.ToLower(hex) + var buf strings.Builder + buf.Grow(59) + for i := 0; i < len(hex); i += 2 { + if i > 0 { + buf.WriteByte(':') + } + buf.WriteString(hex[i : i+2]) + } + return buf.String() +} + +func utf16PtrToString(p *uint16) string { + if p == nil { + return "" + } + const maxChars = 32768 + s := unsafe.Slice(p, maxChars) + for i, v := range s { + if v == 0 { + return syscall.UTF16ToString(s[:i]) + } + } + return syscall.UTF16ToString(s) +} + +func findWlanInterface(handle uintptr, name string) (*windowsGUID, uint32, error) { + var listPtr unsafe.Pointer + ret, _, _ := procWlanEnumInterfaces.Call(handle, 0, uintptr(unsafe.Pointer(&listPtr))) + if ret != 0 || listPtr == nil { + return nil, 0, fmt.Errorf("WlanEnumInterfaces failed: %d", ret) + } + defer freeWlanMemory(uintptr(listPtr)) + + list := (*wlanInterfaceInfoList)(listPtr) + if list.NumberOfItems == 0 { + return nil, 0, fmt.Errorf("no wireless interfaces found") + } + + headerSize := unsafe.Sizeof(*list) + itemSize := unsafe.Sizeof(wlanInterfaceInfo{}) + for i := uint32(0); i < list.NumberOfItems; i++ { + offset := headerSize + uintptr(i)*itemSize + info := (*wlanInterfaceInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(list)) + offset)) + desc := utf16ToString(info.StrInterfaceDescription[:]) + if desc == name { + guid := info.InterfaceGuid + return &guid, info.IsState, nil + } + } + return nil, 0, fmt.Errorf("wireless interface %q not found", name) +} + +// mapWlanState maps WLAN_INTERFACE_STATE to (EAPOLControlState, SupplicantState). +func mapWlanState(state uint32) (int, int) { + switch state { + case wlanIfaceStateConnected: + return 2, 4 // Running, Authenticated + case wlanIfaceStateAuthenticating: + return 2, 3 // Running, Authenticating + case wlanIfaceStateAssociating: + return 1, 1 // Starting, Connecting + case wlanIfaceStateDiscovering: + return 1, 2 // Starting, Acquired + case wlanIfaceStateDisconnecting: + return 3, 6 // Stopping, Logoff + case wlanIfaceStateDisconnected: + return 0, 0 // Idle, Disconnected + case wlanIfaceStateNotReady: + return 0, 7 // Idle, Inactive + default: + return 0, 0 + } +} + +func utf16ToString(s []uint16) string { + for i, v := range s { + if v == 0 { + return syscall.UTF16ToString(s[:i]) + } + } + return syscall.UTF16ToString(s) +} diff --git a/tables/eapolstatus/eapolstatus.go b/tables/eapolstatus/eapolstatus.go index 104e2b8..24d7a6c 100644 --- a/tables/eapolstatus/eapolstatus.go +++ b/tables/eapolstatus/eapolstatus.go @@ -160,7 +160,8 @@ func generateRows(ctx context.Context, backend EAPOLBackend, queryContext table. // interfacesToQuery returns the list of interfaces to query. If a WHERE // constraint "interface" is present, only that interface is returned; -// otherwise en0 through en9 are enumerated. +// otherwise the platform-specific default list is used (e.g. real +// wireless adapter names on Windows, en0-en9 on macOS). func interfacesToQuery(queryContext table.QueryContext) []string { if constraints, ok := queryContext.Constraints["interface"]; ok { seen := make(map[string]struct{}) @@ -179,11 +180,14 @@ func interfacesToQuery(queryContext table.QueryContext) []string { } } - ifaces := make([]string, 0, 10) - for i := 0; i < 10; i++ { - ifaces = append(ifaces, "en"+strconv.Itoa(i)) + if ifaces := defaultInterfaces(); len(ifaces) > 0 { + return ifaces } - return ifaces + fallback := make([]string, 10) + for i := range fallback { + fallback[i] = "en" + strconv.Itoa(i) + } + return fallback } func rowFromStatus(s EAPOLStatus) map[string]string { From 6628f2c3ca7a29be823cbf745019eed2c57533b4 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 18:19:15 -0400 Subject: [PATCH 07/36] docs: update README eapol_status entry for Windows support Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78be392..191e8bc 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ For production deployment, you should refer to the [osquery documentation](https | `alt_system_info` | Alternative system_info table | macOS | This table is an alternative to the built-in system_info table in osquery, which triggers an `Allow "osquery" to find devices on local networks?` prompt on macOS 15.0. On versions other than 15.0, this table falls back to the built-in system_info table. Note: this table returns an empty `cpu_subtype` field. See [#58](https://github.com/macadmins/osquery-extension/pull/58) for more details. | | `authdb` | macOS Authorization database | macOS | Use the constraint `name` to specify a right name to query, otherwise all rights will be returned. | | `crowdstrike_falcon` | Provides basic information about the currently installed Falcon sensor. | Linux / macOS | Requires Falcon to be installed. | -| `eapol_status` | Per-interface macOS 802.1X / EAPOL supplicant state and authentication status | macOS | Exposes state, supplicant state, EAP method (including inner EAP for tunneled auth), TLS server certificate chain (DNs, SHA-1, serials), TLS trust client status, cipher, protocol version, session resumption, authenticator MAC, and status timestamp. Use `WHERE interface = 'en0'` to query a specific interface; otherwise probes en0 through en9. No root required. | +| `eapol_status` | Per-interface 802.1X / EAPOL supplicant state and authentication status | macOS / Windows | Exposes state, supplicant state, EAP method, authenticator MAC, auth mode, and unique identifier. macOS additionally provides TLS certificate chain, trust status, cipher, protocol version, and timestamp via EAP8021X.framework. Windows queries wlanapi.dll and parses WLAN profile XML. Use `WHERE interface = 'en0'` (macOS) or `WHERE interface = 'Adapter Name'` (Windows). No root required. | | `energy_impact` | Process energy impact data from `powermetrics` | macOS | Use the `interval` constraint to specify sampling duration in milliseconds (default: 1000). | | `file_lines` | Read an arbitrary file | Linux / macOS / Windows | Use the constraint `path` and `last` to specify the file to read lines from | | `filevault_users` | Information on the users able to unlock the current boot volume when encrypted with Filevault | macOS | | From 7230e49fe3f09a8d4381f141f9f0f77dbad9ce00 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 18:52:19 -0400 Subject: [PATCH 08/36] eapol_status: add Windows unit tests and mock tests Add eapol_windows_test.go with comprehensive tests for all Windows- specific functions: - XML extraction: extractEAPTypeFromXML, extractAuthModeFromXML, extractInnerEAPTypeFromXML, extractTrustedRootCAFromXML (table-driven with real and synthetic profile XML) - State mapping: mapWlanState for all WLAN_INTERFACE_STATE values - Helpers: windowsGUID.String, utf16ToString, utf16PtrToString, formatSHA1Hex, unavailableBackend - Mock integration: fakeBackend-driven tests for connected EAP-TLS, disconnected, PEAP+MSCHAPv2, not-found, and unavailable scenarios - Live smoke tests: exercise real wlanapi.dll backend and profile XML extraction on machines with wireless adapters (skipped if unavailable) Fix pre-existing TestInterfacesToQuery and TestGenerateRowsSkipsErrors to be platform-aware (Windows enumerates real adapter names, not en0-en9). Co-Authored-By: Claude Opus 4.6 --- tables/eapolstatus/BUILD.bazel | 1 + tables/eapolstatus/eapol_windows_test.go | 567 +++++++++++++++++++++++ tables/eapolstatus/eapolstatus_test.go | 28 +- 3 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 tables/eapolstatus/eapol_windows_test.go diff --git a/tables/eapolstatus/BUILD.bazel b/tables/eapolstatus/BUILD.bazel index db6ac63..704cd58 100644 --- a/tables/eapolstatus/BUILD.bazel +++ b/tables/eapolstatus/BUILD.bazel @@ -35,6 +35,7 @@ go_test( srcs = [ "eapolstatus_test.go", "eapol_darwin_test.go", + "eapol_windows_test.go", ], embed = [":eapolstatus"], deps = [ diff --git a/tables/eapolstatus/eapol_windows_test.go b/tables/eapolstatus/eapol_windows_test.go new file mode 100644 index 0000000..3338005 --- /dev/null +++ b/tables/eapolstatus/eapol_windows_test.go @@ -0,0 +1,567 @@ +//go:build windows + +package eapolstatus + +import ( + "testing" + + "github.com/osquery/osquery-go/plugin/table" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- XML extraction tests --- + +const sampleProfileXML = ` + + Campus + + + + WPA2 + AES + true + + + machine + + + + 13 + 0 + 0 + 0 + + + + 13 + + + true + + 23 a6 b1 0a be 8a 4a 37 72 11 e2 f4 2c 36 67 f1 36 e9 08 bf + + + + + + + + + +` + +const peapProfileXML = ` + + PEAPNetwork + + + + user + + + + 25 + + + + 25 + + + aa bb cc dd ee ff 00 11 22 33 44 55 66 77 88 99 aa bb cc dd + 11 22 33 44 55 66 77 88 99 00 aa bb cc dd ee ff 11 22 33 44 + + false + + 26 + + + 26 + + + + + + + + + + + +` + +func TestExtractEAPTypeFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want int + }{ + {"EAP-TLS", sampleProfileXML, 13}, + {"PEAP", peapProfileXML, 25}, + {"no EapMethod", `open`, -1}, + {"empty", "", -1}, + {"EapMethod but no Type", ``, -1}, + {"malformed Type value", `abc`, -1}, + {"namespace prefixed (not matched)", `13`, -1}, + {"Type with attributes", `21`, 21}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractEAPTypeFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractAuthModeFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want int + }{ + {"machine", sampleProfileXML, 3}, + {"user", peapProfileXML, 1}, + {"machineOrUser", `machineOrUser`, 2}, + {"guest", `guest`, 0}, + {"unknown value", `somethingElse`, -1}, + {"no authMode", ``, -1}, + {"empty", "", -1}, + {"whitespace around value", ` machine `, 3}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractAuthModeFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractInnerEAPTypeFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want int + }{ + {"PEAP with MSCHAPv2 inner", peapProfileXML, 26}, + {"EAP-TLS no inner", sampleProfileXML, -1}, + {"no EapMethod at all", ``, -1}, + {"single EapMethod only", `13`, -1}, + {"empty", "", -1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractInnerEAPTypeFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractTrustedRootCAFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want string + }{ + { + "single CA with spaces", + sampleProfileXML, + "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", + }, + { + "multiple CAs", + peapProfileXML, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd," + + "11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff:11:22:33:44", + }, + { + "contiguous hex (no spaces)", + `aabbccddeeff00112233445566778899aabbccdd`, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + }, + { + "uppercase hex", + `AABBCCDDEEFF00112233445566778899AABBCCDD`, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + }, + {"no TrustedRootCA", ``, ""}, + {"empty", "", ""}, + { + "wrong length ignored", + `aabb`, + "", + }, + { + "whitespace only", + ` `, + "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractTrustedRootCAFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFormatSHA1Hex(t *testing.T) { + t.Parallel() + + assert.Equal(t, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + formatSHA1Hex("aabbccddeeff00112233445566778899aabbccdd")) + assert.Equal(t, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + formatSHA1Hex("AABBCCDDEEFF00112233445566778899AABBCCDD")) +} + +// --- mapWlanState tests --- + +func TestMapWlanState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input uint32 + wantState int + wantSupplicant int + }{ + {"connected", wlanIfaceStateConnected, 2, 4}, + {"authenticating", wlanIfaceStateAuthenticating, 2, 3}, + {"associating", wlanIfaceStateAssociating, 1, 1}, + {"discovering", wlanIfaceStateDiscovering, 1, 2}, + {"disconnecting", wlanIfaceStateDisconnecting, 3, 6}, + {"disconnected", wlanIfaceStateDisconnected, 0, 0}, + {"not ready", wlanIfaceStateNotReady, 0, 7}, + {"ad hoc formed (default)", wlanIfaceStateAdHocFormed, 0, 0}, + {"unknown value 99", 99, 0, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotState, gotSupplicant := mapWlanState(tc.input) + assert.Equal(t, tc.wantState, gotState, "state") + assert.Equal(t, tc.wantSupplicant, gotSupplicant, "supplicant") + }) + } +} + +// --- GUID formatting --- + +func TestWindowsGUIDString(t *testing.T) { + t.Parallel() + + g := windowsGUID{ + Data1: 0x9A82D898, + Data2: 0x7B57, + Data3: 0x40AA, + Data4: [8]byte{0xA3, 0x30, 0xE2, 0xB9, 0x9D, 0x10, 0xBD, 0x77}, + } + assert.Equal(t, "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", g.String()) +} + +func TestWindowsGUIDStringZero(t *testing.T) { + t.Parallel() + + g := windowsGUID{} + assert.Equal(t, "{00000000-0000-0000-0000-000000000000}", g.String()) +} + +// --- UTF-16 helpers --- + +func TestUtf16ToString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []uint16 + want string + }{ + {"simple ASCII", []uint16{'H', 'i', 0}, "Hi"}, + {"empty (just null)", []uint16{0}, ""}, + {"no null terminator", []uint16{'A', 'B', 'C'}, "ABC"}, + {"unicode", []uint16{0x00C9, 0x006D, 0x0069, 0x006C, 0x0065, 0}, "Émile"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := utf16ToString(tc.input) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestUtf16PtrToString(t *testing.T) { + t.Parallel() + + t.Run("nil pointer", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "", utf16PtrToString(nil)) + }) + + t.Run("normal string", func(t *testing.T) { + t.Parallel() + data := []uint16{'T', 'e', 's', 't', 0} + assert.Equal(t, "Test", utf16PtrToString(&data[0])) + }) +} + +// --- unavailableBackend --- + +func TestUnavailableBackend(t *testing.T) { + t.Parallel() + + b := unavailableBackend{} + s, err := b.GetStatus("wifi0") + require.Error(t, err) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Equal(t, "wifi0", s.Interface) +} + +// --- Mock-based integration tests using the shared EAPOLBackend interface --- + +func TestWindowsMockBackendConnected(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "RZ616 Wi-Fi 6E 160MHz": { + Interface: "RZ616 Wi-Fi 6E 160MHz", + State: 2, + SupplicantState: 4, + EAPType: 13, + ClientStatus: 0, + AuthenticatorMACAddress: "26:0b:8b:00:f2:34", + Mode: 3, + TLSServerCertificateSHA1: "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", + UniqueIdentifier: "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", + DomainSpecificError: -1, + TLSTrustClientStatus: -1, + TLSNegotiatedCipher: -1, + InnerEAPType: -1, + }, + }, + } + + qc := constraintFor("RZ616 Wi-Fi 6E 160MHz") + rows, err := generateRows(t.Context(), backend, qc) + require.NoError(t, err) + require.Len(t, rows, 1) + + row := rows[0] + assert.Equal(t, "RZ616 Wi-Fi 6E 160MHz", row["interface"]) + assert.Equal(t, "2", row["state"]) + assert.Equal(t, "Running", row["state_name"]) + assert.Equal(t, "4", row["supplicant_state"]) + assert.Equal(t, "Authenticated", row["supplicant_state_name"]) + assert.Equal(t, "13", row["eap_type"]) + assert.Equal(t, "EAP-TLS", row["eap_type_name"]) + assert.Equal(t, "0", row["client_status"]) + assert.Equal(t, "26:0b:8b:00:f2:34", row["authenticator_mac_address"]) + assert.Equal(t, "3", row["mode"]) + assert.Equal(t, "System", row["mode_name"]) + assert.Equal(t, "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", row["tls_server_certificate_sha1"]) + assert.Equal(t, "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", row["unique_identifier"]) + assert.Equal(t, "", row["domain_specific_error"]) + assert.Equal(t, "", row["tls_trust_client_status"]) + assert.Equal(t, "", row["tls_negotiated_cipher"]) + assert.Equal(t, "", row["inner_eap_type"]) + assert.Equal(t, "", row["inner_eap_type_name"]) +} + +func TestWindowsMockBackendDisconnected(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "Intel Wi-Fi 6": { + Interface: "Intel Wi-Fi 6", + State: 0, + SupplicantState: 0, + EAPType: -1, + ClientStatus: -1, + Mode: -1, + DomainSpecificError: -1, + TLSTrustClientStatus: -1, + TLSNegotiatedCipher: -1, + InnerEAPType: -1, + UniqueIdentifier: "{ABCDEF01-2345-6789-ABCD-EF0123456789}", + }, + }, + } + + qc := constraintFor("Intel Wi-Fi 6") + rows, err := generateRows(t.Context(), backend, qc) + require.NoError(t, err) + require.Len(t, rows, 1) + + row := rows[0] + assert.Equal(t, "Intel Wi-Fi 6", row["interface"]) + assert.Equal(t, "0", row["state"]) + assert.Equal(t, "Idle", row["state_name"]) + assert.Equal(t, "0", row["supplicant_state"]) + assert.Equal(t, "Disconnected", row["supplicant_state_name"]) + assert.Equal(t, "", row["eap_type"]) + assert.Equal(t, "", row["eap_type_name"]) + assert.Equal(t, "", row["client_status"]) + assert.Equal(t, "", row["authenticator_mac_address"]) + assert.Equal(t, "", row["mode"]) + assert.Equal(t, "", row["mode_name"]) +} + +func TestWindowsMockBackendPEAP(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "Realtek Wi-Fi": { + Interface: "Realtek Wi-Fi", + State: 2, + SupplicantState: 4, + EAPType: 25, + InnerEAPType: 26, + ClientStatus: 0, + Mode: 1, + AuthenticatorMACAddress: "aa:bb:cc:dd:ee:ff", + UniqueIdentifier: "{11111111-2222-3333-4444-555555555555}", + DomainSpecificError: -1, + TLSTrustClientStatus: -1, + TLSNegotiatedCipher: -1, + }, + }, + } + + qc := constraintFor("Realtek Wi-Fi") + rows, err := generateRows(t.Context(), backend, qc) + require.NoError(t, err) + require.Len(t, rows, 1) + + row := rows[0] + assert.Equal(t, "25", row["eap_type"]) + assert.Equal(t, "PEAP", row["eap_type_name"]) + assert.Equal(t, "26", row["inner_eap_type"]) + assert.Equal(t, "MSCHAPv2", row["inner_eap_type_name"]) + assert.Equal(t, "1", row["mode"]) + assert.Equal(t, "User", row["mode_name"]) +} + +func TestWindowsMockBackendNotFound(t *testing.T) { + t.Parallel() + + backend := fakeBackend{statuses: map[string]EAPOLStatus{}} + qc := constraintFor("nonexistent adapter") + rows, err := generateRows(t.Context(), backend, qc) + require.NoError(t, err) + assert.Empty(t, rows) +} + +func TestWindowsMockBackendUnavailable(t *testing.T) { + t.Parallel() + + backend := errBackend{err: ErrBackendUnavailable} + qc := constraintFor("any") + rows, err := generateRows(t.Context(), backend, qc) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Empty(t, rows) +} + +// --- Live backend smoke test --- + +func TestWindowsLiveBackend(t *testing.T) { + backend := newBackend() + + if _, ok := backend.(unavailableBackend); ok { + t.Skip("wlanapi.dll not available on this system") + } + + ifaces := enumerateWlanInterfaces() + if len(ifaces) == 0 { + t.Skip("no wireless interfaces found") + } + + for _, ifname := range ifaces { + s, err := backend.GetStatus(ifname) + require.NoError(t, err) + assert.Equal(t, ifname, s.Interface) + assert.NotEmpty(t, s.UniqueIdentifier, "GUID should always be set") + assert.GreaterOrEqual(t, s.State, 0) + assert.LessOrEqual(t, s.State, 3) + } +} + +func TestWindowsLiveBackendBogusInterface(t *testing.T) { + backend := newBackend() + + if _, ok := backend.(unavailableBackend); ok { + t.Skip("wlanapi.dll not available on this system") + } + + _, err := backend.GetStatus("nonexistent_adapter_999") + require.Error(t, err) + assert.NotErrorIs(t, err, ErrBackendUnavailable) + assert.Contains(t, err.Error(), "nonexistent_adapter_999") +} + +// --- Real profile XML extraction (end-to-end on live system) --- + +func TestWindowsLiveProfileXMLExtraction(t *testing.T) { + backend := newBackend() + + if _, ok := backend.(unavailableBackend); ok { + t.Skip("wlanapi.dll not available on this system") + } + + ifaces := enumerateWlanInterfaces() + if len(ifaces) == 0 { + t.Skip("no wireless interfaces found") + } + + for _, ifname := range ifaces { + s, err := backend.GetStatus(ifname) + if err != nil { + continue + } + if s.State != 2 { + continue + } + // Connected interface should have at minimum an EAP type if 802.1X + if s.EAPType > 0 { + assert.NotEmpty(t, lookupName(eapTypeNames, s.EAPType), + "known EAP type %d should have a name", s.EAPType) + } + return + } + t.Skip("no connected wireless interface found") +} + +// --- helpers --- + +func constraintFor(ifname string) table.QueryContext { + return table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: ifname}, + }, + }, + }, + } +} diff --git a/tables/eapolstatus/eapolstatus_test.go b/tables/eapolstatus/eapolstatus_test.go index 1bbc990..1bfad86 100644 --- a/tables/eapolstatus/eapolstatus_test.go +++ b/tables/eapolstatus/eapolstatus_test.go @@ -66,13 +66,18 @@ func TestEAPOLStatusColumns(t *testing.T) { func TestInterfacesToQuery(t *testing.T) { t.Parallel() - t.Run("no constraint returns en0-en9", func(t *testing.T) { + t.Run("no constraint returns default interfaces", func(t *testing.T) { t.Parallel() qc := table.QueryContext{} ifaces := interfacesToQuery(qc) - assert.Len(t, ifaces, 10) - assert.Equal(t, "en0", ifaces[0]) - assert.Equal(t, "en9", ifaces[9]) + assert.NotEmpty(t, ifaces) + if di := defaultInterfaces(); len(di) > 0 { + assert.Equal(t, di, ifaces) + } else { + assert.Len(t, ifaces, 10) + assert.Equal(t, "en0", ifaces[0]) + assert.Equal(t, "en9", ifaces[9]) + } }) t.Run("with equals constraint returns specified interface", func(t *testing.T) { @@ -90,7 +95,7 @@ func TestInterfacesToQuery(t *testing.T) { assert.Equal(t, []string{"en0"}, ifaces) }) - t.Run("with LIKE constraint falls back to en0-en9", func(t *testing.T) { + t.Run("with LIKE constraint falls back to defaults", func(t *testing.T) { t.Parallel() qc := table.QueryContext{ Constraints: map[string]table.ConstraintList{ @@ -102,7 +107,7 @@ func TestInterfacesToQuery(t *testing.T) { }, } ifaces := interfacesToQuery(qc) - assert.Len(t, ifaces, 10) + assert.NotEmpty(t, ifaces) }) t.Run("duplicate constraints deduplicated", func(t *testing.T) { @@ -286,7 +291,16 @@ func TestGenerateRowsSkipsErrors(t *testing.T) { }, } - qc := table.QueryContext{} + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + {Operator: table.OperatorEquals, Expression: "en1"}, + }, + }, + }, + } rows, err := generateRows(context.Background(), backend, qc) require.NoError(t, err) assert.Len(t, rows, 1) From b5e89b28180970b34f02573fb2e93c81bfc04f62 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 19:09:34 -0400 Subject: [PATCH 09/36] coverage: narrow eapolstatus exclusion to platform-specific files only Keep eapolstatus.go (shared logic) in coverage since it now has good test coverage. Only exclude the platform-specific backend files (eapol_darwin.go, eapol_windows.go, eapol_other.go) which can't run cross-platform in the coverage tool. Co-Authored-By: Claude Opus 4.6 --- .testcoverage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.testcoverage.yml b/.testcoverage.yml index c08161d..a88c693 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -60,8 +60,8 @@ exclude: - tables/puppet/puppet_logs.go - tables/puppet/puppet_state.go - tables/puppet/yaml.go - - ^tables/eapolstatus$ - - tables/eapolstatus/eapolstatus.go + - tables/eapolstatus/eapol_darwin.go + - tables/eapolstatus/eapol_windows.go - tables/eapolstatus/eapol_other.go # - \.pb\.go$ # excludes all protobuf generated files # - ^pkg/bar # exclude package `pkg/bar` From 7fd7b48b045ad1948e051e219b58d2519f48bce3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 19:14:36 -0400 Subject: [PATCH 10/36] CI: broaden make test query and harden bazel_to_builddir.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #113 review feedback: - Makefile: scope `make test` to all *_test kinds via kind(".*_test", //...) instead of only go_test, so non-Go tests (sh_test, py_test, etc.) are picked up if added later. Binary targets stay out of analysis, preserving the Linux-CI fix. - bazel_to_builddir.sh: replace unquoted `cp $(bazel cquery ...)` with a copy_bazel_output helper that captures the cquery result, validates exactly one non-empty path, and quotes it — robust to whitespace and unexpected multi-file output. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 7 ++++++- tools/bazel_to_builddir.sh | 32 ++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 541e850..e47e581 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,12 @@ update-repos: bazel run //:gazelle-update-repos -- -from_file=go.mod test: - bazel test --test_output=errors $$(bazel query 'kind(go_test, //...)') + # Query only test targets (any *_test kind) rather than `bazel test //...`, + # which would also analyze the darwin cgo binary targets (pure="off", + # cgo=True) and fail on Linux CI where no darwin C++ toolchain exists. + # Matching all *_test kinds (not just go_test) keeps non-Go tests + # (sh_test, py_test, etc.) in scope if any are added later. + bazel test --test_output=errors $$(bazel query 'kind(".*_test", //...)') build: .pre-build ifeq ($(shell uname),Darwin) diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index c7e0db5..1d94952 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -euo pipefail mkdir -p build/darwin mkdir -p build/linux @@ -6,16 +7,31 @@ mkdir -p build/windows APP_NAME="macadmins_extension" +# copy_bazel_output TARGET DEST copies the single output file of a bazel +# target to DEST. It captures the cquery result, validates that exactly one +# non-empty path was returned, and quotes the path so files with whitespace +# (or an unexpected multi-file output) are handled safely rather than being +# silently word-split. +copy_bazel_output() { + local target="$1" dest="$2" file count + file="$(bazel cquery --output=files "$target" 2>/dev/null)" + count="$(printf '%s' "$file" | grep -c .)" + if [ "$count" -ne 1 ]; then + echo "error: expected exactly one output file for ${target}, got ${count}:" >&2 + printf '%s\n' "$file" >&2 + return 1 + fi + cp "$file" "$dest" +} + # Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). if [ "$(uname)" = "Darwin" ]; then - cp $(bazel cquery --output=files //:osquery-extension-mac-amd 2>/dev/null) build/darwin/${APP_NAME}.amd64.ext - cp $(bazel cquery --output=files //:osquery-extension-mac-arm 2>/dev/null) build/darwin/${APP_NAME}.arm64.ext + copy_bazel_output //:osquery-extension-mac-amd "build/darwin/${APP_NAME}.amd64.ext" + copy_bazel_output //:osquery-extension-mac-arm "build/darwin/${APP_NAME}.arm64.ext" fi -cp $(bazel cquery --output=files //:osquery-extension-linux-amd 2>/dev/null) build/linux/${APP_NAME}.amd64.ext - -cp $(bazel cquery --output=files //:osquery-extension-linux-arm 2>/dev/null) build/linux/${APP_NAME}.arm64.ext - -cp $(bazel cquery --output=files //:osquery-extension-win-amd 2>/dev/null) build/windows/${APP_NAME}.amd64.ext.exe +copy_bazel_output //:osquery-extension-linux-amd "build/linux/${APP_NAME}.amd64.ext" +copy_bazel_output //:osquery-extension-linux-arm "build/linux/${APP_NAME}.arm64.ext" +copy_bazel_output //:osquery-extension-win-amd "build/windows/${APP_NAME}.amd64.ext.exe" -# mv $(bazel cquery --output=files //:osquery-extension-win-arm 2>/dev/null) build/windows/${APP_NAME}.arm64.ext.exe +# copy_bazel_output //:osquery-extension-win-arm "build/windows/${APP_NAME}.arm64.ext.exe" From 34aa32ca6f6a6ba734239f3fc4ef55d3ac3aa55e Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 19:18:50 -0400 Subject: [PATCH 11/36] eapol_status: add macOS mock-backend tests Mirror the Windows mock tests on darwin: drive generateRows -> rowFromStatus through the shared EAPOLBackend interface with canned EAPOLStatus values, so they run deterministically without the EAP8021X framework or an active 802.1X session. Covers the macOS-rich fields the cgo backend populates (TLS server certificate chain/SHA-1/serials, negotiated protocol version + cipher, trust status, last-status timestamp, System/LoginWindow modes) plus idle, PEAP-with-inner-EAP, unknown EAP type, multi-interface probing, not-found skip, and ErrBackendUnavailable propagation. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/eapolstatus/eapol_darwin_test.go | 251 ++++++++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/tables/eapolstatus/eapol_darwin_test.go b/tables/eapolstatus/eapol_darwin_test.go index e305ac7..973d3a3 100644 --- a/tables/eapolstatus/eapol_darwin_test.go +++ b/tables/eapolstatus/eapol_darwin_test.go @@ -5,6 +5,7 @@ package eapolstatus import ( "testing" + "github.com/osquery/osquery-go/plugin/table" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -35,3 +36,253 @@ func TestCgoBogusInterface(t *testing.T) { assert.NotErrorIs(t, err, ErrBackendUnavailable) assert.Contains(t, err.Error(), "bogus999999") } + +// --- Mock-based integration tests using the shared EAPOLBackend interface --- +// +// These exercise the full generateRows -> rowFromStatus path with canned +// EAPOLStatus values, so they run deterministically without the EAP8021X +// framework or an active 802.1X session. They focus on the macOS-rich fields +// (TLS server certificate chain, fingerprints, serials, negotiated protocol +// version/cipher, trust status, and last-status timestamp) that the cgo +// backend populates but the Windows backend does not. + +func TestDarwinMockBackendSystemEAPTLS(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en0": { + Interface: "en0", + State: 2, // Running + SupplicantState: 4, // Authenticated + EAPType: 13, + EAPTypeName: "EAP-TLS", + ClientStatus: 0, + DomainSpecificError: 0, + AuthenticatorMACAddress: "00:11:22:33:44:55", + Mode: 3, // System + TLSSessionWasResumed: true, + // Two-cert chain: leaf | issuing CA. DNs are pipe-separated + // because LDAP DNs themselves use commas as RDN separators. + TLSServerCertificateChain: "CN=radius.campus.edu,OU=IT,O=CampusGroup,C=US|CN=CampusGroup Root CA,O=CampusGroup,C=US", + TLSServerCertificateSHA1: "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf," + + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + TLSServerCertificateSerials: "7d3a1f9e2b5c,3039", + TLSTrustClientStatus: 0, + TLSNegotiatedProtocolVersion: "1.2", + TLSNegotiatedCipher: 0xC02B, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + InnerEAPType: -1, + LastStatusTimestamp: "2026-06-06T12:00:00Z", + UniqueIdentifier: "11111111-2222-3333-4444-555555555555", + }, + }, + } + + rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + require.NoError(t, err) + require.Len(t, rows, 1) + + row := rows[0] + assert.Equal(t, "en0", row["interface"]) + assert.Equal(t, "2", row["state"]) + assert.Equal(t, "Running", row["state_name"]) + assert.Equal(t, "4", row["supplicant_state"]) + assert.Equal(t, "Authenticated", row["supplicant_state_name"]) + assert.Equal(t, "13", row["eap_type"]) + assert.Equal(t, "EAP-TLS", row["eap_type_name"]) + assert.Equal(t, "0", row["client_status"]) + assert.Equal(t, "0", row["domain_specific_error"]) + assert.Equal(t, "00:11:22:33:44:55", row["authenticator_mac_address"]) + assert.Equal(t, "3", row["mode"]) + assert.Equal(t, "System", row["mode_name"]) + assert.Equal(t, "1", row["tls_session_was_resumed"]) + assert.Equal(t, "CN=radius.campus.edu,OU=IT,O=CampusGroup,C=US|CN=CampusGroup Root CA,O=CampusGroup,C=US", row["tls_server_certificate_chain"]) + assert.Equal(t, "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf,"+ + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", row["tls_server_certificate_sha1"]) + assert.Equal(t, "7d3a1f9e2b5c,3039", row["tls_server_certificate_serials"]) + assert.Equal(t, "0", row["tls_trust_client_status"]) + assert.Equal(t, "1.2", row["tls_negotiated_protocol_version"]) + assert.Equal(t, "49195", row["tls_negotiated_cipher"]) + assert.Equal(t, "", row["inner_eap_type"]) + assert.Equal(t, "", row["inner_eap_type_name"]) + assert.Equal(t, "2026-06-06T12:00:00Z", row["last_status_timestamp"]) + assert.Equal(t, "11111111-2222-3333-4444-555555555555", row["unique_identifier"]) +} + +func TestDarwinMockBackendIdle(t *testing.T) { + t.Parallel() + + // An interface with no active EAPOL session: state Idle, supplicant + // Disconnected, and all the optional numeric fields set to the cgo + // backend's -1 sentinel (which rowFromStatus renders as empty strings). + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en0": { + Interface: "en0", + State: 0, // Idle + SupplicantState: 0, // Disconnected + EAPType: -1, + ClientStatus: -1, + DomainSpecificError: -1, + Mode: -1, + TLSTrustClientStatus: -1, + TLSNegotiatedCipher: -1, + InnerEAPType: -1, + }, + }, + } + + rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + require.NoError(t, err) + require.Len(t, rows, 1) + + row := rows[0] + assert.Equal(t, "en0", row["interface"]) + assert.Equal(t, "0", row["state"]) + assert.Equal(t, "Idle", row["state_name"]) + assert.Equal(t, "0", row["supplicant_state"]) + assert.Equal(t, "Disconnected", row["supplicant_state_name"]) + assert.Equal(t, "", row["eap_type"]) + assert.Equal(t, "", row["eap_type_name"]) + assert.Equal(t, "", row["client_status"]) + assert.Equal(t, "", row["mode"]) + assert.Equal(t, "", row["mode_name"]) + assert.Equal(t, "", row["tls_negotiated_cipher"]) + assert.Equal(t, "0", row["tls_session_was_resumed"]) + assert.Equal(t, "", row["tls_server_certificate_chain"]) +} + +func TestDarwinMockBackendPEAP(t *testing.T) { + t.Parallel() + + // PEAP (25) tunneling MSCHAPv2 (26) on a LoginWindow-mode interface, + // negotiated over TLS 1.3 with a resumed session. + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en1": { + Interface: "en1", + State: 2, // Running + SupplicantState: 4, // Authenticated + EAPType: 25, + InnerEAPType: 26, + ClientStatus: 0, + DomainSpecificError: 0, + Mode: 2, // LoginWindow + AuthenticatorMACAddress: "aa:bb:cc:dd:ee:ff", + TLSSessionWasResumed: true, + TLSTrustClientStatus: 0, + TLSNegotiatedProtocolVersion: "1.3", + TLSNegotiatedCipher: 0x1301, // TLS_AES_128_GCM_SHA256 + UniqueIdentifier: "22222222-3333-4444-5555-666666666666", + }, + }, + } + + rows, err := generateRows(t.Context(), backend, constraintFor("en1")) + require.NoError(t, err) + require.Len(t, rows, 1) + + row := rows[0] + assert.Equal(t, "en1", row["interface"]) + assert.Equal(t, "25", row["eap_type"]) + assert.Equal(t, "PEAP", row["eap_type_name"]) + assert.Equal(t, "26", row["inner_eap_type"]) + assert.Equal(t, "MSCHAPv2", row["inner_eap_type_name"]) + assert.Equal(t, "2", row["mode"]) + assert.Equal(t, "LoginWindow", row["mode_name"]) + assert.Equal(t, "1", row["tls_session_was_resumed"]) + assert.Equal(t, "1.3", row["tls_negotiated_protocol_version"]) + assert.Equal(t, "4865", row["tls_negotiated_cipher"]) // 0x1301 +} + +func TestDarwinMockBackendUnknownEAPType(t *testing.T) { + t.Parallel() + + // A positive EAP type code missing from eapTypeNames should render as + // "Unknown()", consistent with state/supplicant/mode handling. + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en0": { + Interface: "en0", + State: 2, + SupplicantState: 4, + EAPType: 99, + InnerEAPType: -1, + Mode: 3, + }, + }, + } + + rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "99", rows[0]["eap_type"]) + assert.Equal(t, "Unknown(99)", rows[0]["eap_type_name"]) +} + +func TestDarwinMockBackendMultipleInterfaces(t *testing.T) { + t.Parallel() + + // Probing two interfaces where both are active yields one row each. + backend := fakeBackend{ + statuses: map[string]EAPOLStatus{ + "en0": {Interface: "en0", State: 2, SupplicantState: 4, EAPType: 13, EAPTypeName: "EAP-TLS", Mode: 3, InnerEAPType: -1}, + "en1": {Interface: "en1", State: 2, SupplicantState: 4, EAPType: 25, InnerEAPType: 26, Mode: 1}, + }, + } + + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + {Operator: table.OperatorEquals, Expression: "en1"}, + }, + }, + }, + } + + rows, err := generateRows(t.Context(), backend, qc) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, "en0", rows[0]["interface"]) + assert.Equal(t, "EAP-TLS", rows[0]["eap_type_name"]) + assert.Equal(t, "en1", rows[1]["interface"]) + assert.Equal(t, "PEAP", rows[1]["eap_type_name"]) +} + +func TestDarwinMockBackendNotFound(t *testing.T) { + t.Parallel() + + // An interface the backend doesn't know about errors per-interface, and + // generateRows skips it, yielding zero rows. + backend := fakeBackend{statuses: map[string]EAPOLStatus{}} + rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + require.NoError(t, err) + assert.Empty(t, rows) +} + +func TestDarwinMockBackendUnavailable(t *testing.T) { + t.Parallel() + + // A systemic ErrBackendUnavailable (e.g. EAP8021X.framework failed to + // load) propagates rather than being swallowed as a per-interface miss. + backend := errBackend{err: ErrBackendUnavailable} + rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Empty(t, rows) +} + +// --- helpers --- + +func constraintFor(ifname string) table.QueryContext { + return table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: ifname}, + }, + }, + }, + } +} From e0612d9dc46564a2c860ca2614e9cff5dcdf2e62 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 19:30:59 -0400 Subject: [PATCH 12/36] Rename eapol_status table to dot1x Now that both macOS and Windows backends are in place, "dot1x" (802.1X) describes the table better than the macOS-specific "eapol_status". - Table name: eapol_status -> dot1x (precedent: bare names like mdm, authdb) - Directory: tables/eapolstatus -> tables/dot1x (git mv, history preserved) - Files: eapol_*.go / eapolstatus*.go -> dot1x*.go - Package: eapolstatus -> dot1x - Exported API: EAPOLStatus -> Dot1XStatus, EAPOLBackend -> Dot1XBackend, EAPOLStatusColumns/Generate -> Dot1XStatusColumns/Generate - cgo wrappers: load_eapol -> load_dot1x, eapol_query -> dot1x_query - Updated main.go, root BUILD.bazel, tables/dot1x/BUILD.bazel, WORKSPACE, .testcoverage.yml, README Apple's own symbols are left intact: EAPOLControlCopyStateAndStatus (the exact dlsym string), kEAPOLControlState* enum, and EAP8021X.framework. Co-Authored-By: Claude Opus 4.8 (1M context) --- .testcoverage.yml | 6 +- BUILD.bazel | 4 +- README.md | 2 +- WORKSPACE | 2 +- main.go | 8 +- tables/{eapolstatus => dot1x}/BUILD.bazel | 24 ++--- .../eapolstatus.go => dot1x/dot1x.go} | 88 +++++++++--------- .../eapol_darwin.go => dot1x/dot1x_darwin.go} | 70 +++++++-------- .../dot1x_darwin_test.go} | 22 ++--- .../eapol_other.go => dot1x/dot1x_other.go} | 8 +- .../dot1x_test.go} | 90 +++++++++---------- .../dot1x_windows.go} | 40 ++++----- .../dot1x_windows_test.go} | 40 ++++----- 13 files changed, 203 insertions(+), 201 deletions(-) rename tables/{eapolstatus => dot1x}/BUILD.bazel (73%) rename tables/{eapolstatus/eapolstatus.go => dot1x/dot1x.go} (79%) rename tables/{eapolstatus/eapol_darwin.go => dot1x/dot1x_darwin.go} (92%) rename tables/{eapolstatus/eapol_darwin_test.go => dot1x/dot1x_darwin_test.go} (94%) rename tables/{eapolstatus/eapol_other.go => dot1x/dot1x_other.go} (66%) rename tables/{eapolstatus/eapolstatus_test.go => dot1x/dot1x_test.go} (90%) rename tables/{eapolstatus/eapol_windows.go => dot1x/dot1x_windows.go} (93%) rename tables/{eapolstatus/eapol_windows_test.go => dot1x/dot1x_windows_test.go} (95%) diff --git a/.testcoverage.yml b/.testcoverage.yml index a88c693..bc3513a 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -60,9 +60,9 @@ exclude: - tables/puppet/puppet_logs.go - tables/puppet/puppet_state.go - tables/puppet/yaml.go - - tables/eapolstatus/eapol_darwin.go - - tables/eapolstatus/eapol_windows.go - - tables/eapolstatus/eapol_other.go + - tables/dot1x/dot1x_darwin.go + - tables/dot1x/dot1x_windows.go + - tables/dot1x/dot1x_other.go # - \.pb\.go$ # excludes all protobuf generated files # - ^pkg/bar # exclude package `pkg/bar` diff --git a/BUILD.bazel b/BUILD.bazel index b1af11e..1f0adc4 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -41,7 +41,7 @@ go_library( "//tables/authdb", "//tables/chromeuserprofiles", "//tables/crowdstrike_falcon", - "//tables/eapolstatus", + "//tables/dot1x", "//tables/energyimpact", "//tables/fileline", "//tables/filevaultusers", @@ -63,7 +63,7 @@ go_library( ], ) -# The eapol_status table (and any future cgo tables) need cgo + Apple CC +# The dot1x table (and any future cgo tables) need cgo + Apple CC # toolchain on darwin. These are darwin-only binaries (goos = "darwin"), # so cgo=True does not affect Linux/Windows builds. The apple_support # toolchain in WORKSPACE satisfies the CC requirement on macOS. diff --git a/README.md b/README.md index 191e8bc..2f4c020 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ For production deployment, you should refer to the [osquery documentation](https | `alt_system_info` | Alternative system_info table | macOS | This table is an alternative to the built-in system_info table in osquery, which triggers an `Allow "osquery" to find devices on local networks?` prompt on macOS 15.0. On versions other than 15.0, this table falls back to the built-in system_info table. Note: this table returns an empty `cpu_subtype` field. See [#58](https://github.com/macadmins/osquery-extension/pull/58) for more details. | | `authdb` | macOS Authorization database | macOS | Use the constraint `name` to specify a right name to query, otherwise all rights will be returned. | | `crowdstrike_falcon` | Provides basic information about the currently installed Falcon sensor. | Linux / macOS | Requires Falcon to be installed. | -| `eapol_status` | Per-interface 802.1X / EAPOL supplicant state and authentication status | macOS / Windows | Exposes state, supplicant state, EAP method, authenticator MAC, auth mode, and unique identifier. macOS additionally provides TLS certificate chain, trust status, cipher, protocol version, and timestamp via EAP8021X.framework. Windows queries wlanapi.dll and parses WLAN profile XML. Use `WHERE interface = 'en0'` (macOS) or `WHERE interface = 'Adapter Name'` (Windows). No root required. | +| `dot1x` | Per-interface 802.1X / EAPOL supplicant state and authentication status | macOS / Windows | Exposes state, supplicant state, EAP method, authenticator MAC, auth mode, and unique identifier. macOS additionally provides TLS certificate chain, trust status, cipher, protocol version, and timestamp via EAP8021X.framework. Windows queries wlanapi.dll and parses WLAN profile XML. Use `WHERE interface = 'en0'` (macOS) or `WHERE interface = 'Adapter Name'` (Windows). No root required. | | `energy_impact` | Process energy impact data from `powermetrics` | macOS | Use the `interval` constraint to specify sampling duration in milliseconds (default: 1000). | | `file_lines` | Read an arbitrary file | Linux / macOS / Windows | Use the constraint `path` and `last` to specify the file to read lines from | | `filevault_users` | Information on the users able to unlock the current boot volume when encrypted with Filevault | macOS | | diff --git a/WORKSPACE b/WORKSPACE index 5d01af5..01735b2 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -1,7 +1,7 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # --- Apple C/C++ toolchain for cgo on macOS ------------------------------- -# The eapol_status table calls EAP8021X.framework (CoreFoundation) via cgo, +# The dot1x table calls EAP8021X.framework (CoreFoundation) via cgo, # so the macOS go_binary targets are built with cgo enabled (see # BUILD.bazel: cgo = True, pure = "off"). That requires an Apple CC toolchain, # which apple_support provides. This block must precede rules_go so its CC diff --git a/main.go b/main.go index 6acb732..345ed2a 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,7 @@ import ( "github.com/macadmins/osquery-extension/tables/alt_system_info" "github.com/macadmins/osquery-extension/tables/chromeuserprofiles" "github.com/macadmins/osquery-extension/tables/crowdstrike_falcon" - "github.com/macadmins/osquery-extension/tables/eapolstatus" + "github.com/macadmins/osquery-extension/tables/dot1x" "github.com/macadmins/osquery-extension/tables/energyimpact" "github.com/macadmins/osquery-extension/tables/fileline" "github.com/macadmins/osquery-extension/tables/filevaultusers" @@ -79,10 +79,10 @@ func main() { // Platform specific tables if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { - eapolPlugins := []osquery.OsqueryPlugin{ - table.NewPlugin("eapol_status", eapolstatus.EAPOLStatusColumns(), eapolstatus.EAPOLStatusGenerate), + dot1xPlugins := []osquery.OsqueryPlugin{ + table.NewPlugin("dot1x", dot1x.Dot1XStatusColumns(), dot1x.Dot1XStatusGenerate), } - plugins = append(plugins, eapolPlugins...) + plugins = append(plugins, dot1xPlugins...) } if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { diff --git a/tables/eapolstatus/BUILD.bazel b/tables/dot1x/BUILD.bazel similarity index 73% rename from tables/eapolstatus/BUILD.bazel rename to tables/dot1x/BUILD.bazel index 704cd58..37c08f4 100644 --- a/tables/eapolstatus/BUILD.bazel +++ b/tables/dot1x/BUILD.bazel @@ -1,14 +1,14 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") go_library( - name = "eapolstatus", + name = "dot1x", srcs = [ - "eapolstatus.go", - "eapol_darwin.go", - "eapol_other.go", - "eapol_windows.go", + "dot1x.go", + "dot1x_darwin.go", + "dot1x_other.go", + "dot1x_windows.go", ], - # cgo is only needed on darwin: eapol_darwin.go (//go:build darwin) calls + # cgo is only needed on darwin: dot1x_darwin.go (//go:build darwin) calls # EAP8021X.framework (via dlopen/dlsym) + CoreFoundation via cgo. # The darwin build constraint does NOT include ios, so only darwin gets # the cgo + framework links; linux/windows stay pure Go. @@ -23,7 +23,7 @@ go_library( ], "//conditions:default": [], }), - importpath = "github.com/macadmins/osquery-extension/tables/eapolstatus", + importpath = "github.com/macadmins/osquery-extension/tables/dot1x", visibility = ["//visibility:public"], deps = [ "@com_github_osquery_osquery_go//plugin/table", @@ -31,13 +31,13 @@ go_library( ) go_test( - name = "eapolstatus_test", + name = "dot1x_test", srcs = [ - "eapolstatus_test.go", - "eapol_darwin_test.go", - "eapol_windows_test.go", + "dot1x_test.go", + "dot1x_darwin_test.go", + "dot1x_windows_test.go", ], - embed = [":eapolstatus"], + embed = [":dot1x"], deps = [ "@com_github_osquery_osquery_go//plugin/table", "@com_github_stretchr_testify//assert", diff --git a/tables/eapolstatus/eapolstatus.go b/tables/dot1x/dot1x.go similarity index 79% rename from tables/eapolstatus/eapolstatus.go rename to tables/dot1x/dot1x.go index 24d7a6c..dad38c0 100644 --- a/tables/eapolstatus/eapolstatus.go +++ b/tables/dot1x/dot1x.go @@ -1,4 +1,4 @@ -package eapolstatus +package dot1x import ( "context" @@ -14,21 +14,23 @@ import ( "github.com/osquery/osquery-go/plugin/table" ) -// EAPOLStatus holds the EAPOL state and status for a single interface. -type EAPOLStatus struct { - Interface string - State int // EAPOLControlState: 0=Idle,1=Starting,2=Running,3=Stopping - SupplicantState int // 802.1X supplicant state machine value - EAPType int // EAP method code (e.g. 13=TLS) - EAPTypeName string // human-readable EAP method (e.g. "EAP-TLS") - ClientStatus int // 0=ok, nonzero=error code - DomainSpecificError int - AuthenticatorMACAddress string // colon-separated - Mode int // 0=None,1=User,2=LoginWindow,3=System - TLSSessionWasResumed bool - TLSServerCertificateChain string // pipe-separated subject DNs in LDAP notation - TLSServerCertificateSHA1 string // comma-separated colon-separated SHA-1 fingerprints - TLSServerCertificateSerials string // comma-separated hex serial numbers +// Dot1XStatus holds the 802.1X supplicant state and status for a single +// interface. On macOS the values come from the EAPOL (EAP over LAN) layer of +// the EAP8021X framework; on Windows they come from the WLAN/OneX APIs. +type Dot1XStatus struct { + Interface string + State int // EAPOLControlState: 0=Idle,1=Starting,2=Running,3=Stopping + SupplicantState int // 802.1X supplicant state machine value + EAPType int // EAP method code (e.g. 13=TLS) + EAPTypeName string // human-readable EAP method (e.g. "EAP-TLS") + ClientStatus int // 0=ok, nonzero=error code + DomainSpecificError int + AuthenticatorMACAddress string // colon-separated + Mode int // 0=None,1=User,2=LoginWindow,3=System + TLSSessionWasResumed bool + TLSServerCertificateChain string // pipe-separated subject DNs in LDAP notation + TLSServerCertificateSHA1 string // comma-separated colon-separated SHA-1 fingerprints + TLSServerCertificateSerials string // comma-separated hex serial numbers TLSTrustClientStatus int // trust evaluation error code (0=ok) TLSNegotiatedProtocolVersion string // "1.2" or "1.3" TLSNegotiatedCipher int // TLS cipher suite code @@ -38,11 +40,11 @@ type EAPOLStatus struct { UniqueIdentifier string } -// EAPOLBackend fetches EAPOL status for a named interface. +// Dot1XBackend fetches 802.1X status for a named interface. // Production implementation calls EAPOLControlCopyStateAndStatus -// via cgo (eapol_darwin.go); tests inject a fake. -type EAPOLBackend interface { - GetStatus(ifname string) (EAPOLStatus, error) +// via cgo (dot1x_darwin.go); tests inject a fake. +type Dot1XBackend interface { + GetStatus(ifname string) (Dot1XStatus, error) } // ErrBackendUnavailable is returned by GetStatus when the EAP8021X @@ -99,8 +101,8 @@ var modeNames = map[int]string{ 3: "System", } -// EAPOLStatusColumns returns the column definitions. -func EAPOLStatusColumns() []table.ColumnDefinition { +// Dot1XStatusColumns returns the column definitions. +func Dot1XStatusColumns() []table.ColumnDefinition { return []table.ColumnDefinition{ table.TextColumn("interface"), table.IntegerColumn("state"), @@ -128,8 +130,8 @@ func EAPOLStatusColumns() []table.ColumnDefinition { } } -// EAPOLStatusGenerate generates table rows by querying each interface. -func EAPOLStatusGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { +// Dot1XStatusGenerate generates table rows by querying each interface. +func Dot1XStatusGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { return generateRows(ctx, newBackend(), queryContext) } @@ -137,7 +139,7 @@ func EAPOLStatusGenerate(ctx context.Context, queryContext table.QueryContext) ( // constraint "interface" is provided, only that interface is queried; // otherwise en0 through en9 are probed. The context is checked before each // backend call to support cancellation. -func generateRows(ctx context.Context, backend EAPOLBackend, queryContext table.QueryContext) ([]map[string]string, error) { +func generateRows(ctx context.Context, backend Dot1XBackend, queryContext table.QueryContext) ([]map[string]string, error) { ifaces := interfacesToQuery(queryContext) var rows []map[string]string @@ -190,30 +192,30 @@ func interfacesToQuery(queryContext table.QueryContext) []string { return fallback } -func rowFromStatus(s EAPOLStatus) map[string]string { +func rowFromStatus(s Dot1XStatus) map[string]string { row := map[string]string{ - "interface": s.Interface, - "state": itoa(s.State), - "state_name": lookupName(stateNames, s.State), - "supplicant_state": itoa(s.SupplicantState), - "supplicant_state_name": lookupName(supplicantStateNames, s.SupplicantState), - "eap_type": itoa(s.EAPType), - "eap_type_name": "", - "client_status": itoa(s.ClientStatus), - "domain_specific_error": itoa(s.DomainSpecificError), - "authenticator_mac_address": s.AuthenticatorMACAddress, - "mode": itoa(s.Mode), - "mode_name": lookupName(modeNames, s.Mode), - "tls_server_certificate_chain": s.TLSServerCertificateChain, - "tls_server_certificate_sha1": s.TLSServerCertificateSHA1, - "tls_server_certificate_serials": s.TLSServerCertificateSerials, - "tls_trust_client_status": itoa(s.TLSTrustClientStatus), + "interface": s.Interface, + "state": itoa(s.State), + "state_name": lookupName(stateNames, s.State), + "supplicant_state": itoa(s.SupplicantState), + "supplicant_state_name": lookupName(supplicantStateNames, s.SupplicantState), + "eap_type": itoa(s.EAPType), + "eap_type_name": "", + "client_status": itoa(s.ClientStatus), + "domain_specific_error": itoa(s.DomainSpecificError), + "authenticator_mac_address": s.AuthenticatorMACAddress, + "mode": itoa(s.Mode), + "mode_name": lookupName(modeNames, s.Mode), + "tls_server_certificate_chain": s.TLSServerCertificateChain, + "tls_server_certificate_sha1": s.TLSServerCertificateSHA1, + "tls_server_certificate_serials": s.TLSServerCertificateSerials, + "tls_trust_client_status": itoa(s.TLSTrustClientStatus), "tls_negotiated_protocol_version": s.TLSNegotiatedProtocolVersion, "tls_negotiated_cipher": itoa(s.TLSNegotiatedCipher), "inner_eap_type": itoa(s.InnerEAPType), "inner_eap_type_name": "", "last_status_timestamp": s.LastStatusTimestamp, - "unique_identifier": s.UniqueIdentifier, + "unique_identifier": s.UniqueIdentifier, } if s.TLSSessionWasResumed { row["tls_session_was_resumed"] = "1" diff --git a/tables/eapolstatus/eapol_darwin.go b/tables/dot1x/dot1x_darwin.go similarity index 92% rename from tables/eapolstatus/eapol_darwin.go rename to tables/dot1x/dot1x_darwin.go index 63b723b..d88ba59 100644 --- a/tables/eapolstatus/eapol_darwin.go +++ b/tables/dot1x/dot1x_darwin.go @@ -1,6 +1,6 @@ //go:build darwin -package eapolstatus +package dot1x /* #include @@ -21,7 +21,7 @@ typedef int (*EAPOLControlCopyStateAndStatusFn)(const char*, uint32_t*, CFDictio static EAPOLControlCopyStateAndStatusFn copy_state_fn = NULL; -static int load_eapol(void) { +static int load_dot1x(void) { if (copy_state_fn) return 1; // Handle intentionally held for process lifetime (sync.Once); the // framework must stay loaded for copy_state_fn to remain valid. @@ -173,10 +173,10 @@ static uint8_t* pack_cert_chain(CFArrayRef certs, CFIndex* out_len) { return buf; } -// eapol_query calls EAPOLControlCopyStateAndStatus for the given interface +// dot1x_query calls EAPOLControlCopyStateAndStatus for the given interface // and fills the provided Go-accessible fields. Returns 0 on success. // All string/buffer outputs are malloc'd and must be freed by the caller. -int eapol_query( +int dot1x_query( const char* ifname, int* out_state, int* out_supplicant_state, @@ -310,38 +310,38 @@ type productionBackend struct{} var loadOnce sync.Once -func newBackend() EAPOLBackend { - loadOnce.Do(func() { C.load_eapol() }) +func newBackend() Dot1XBackend { + loadOnce.Do(func() { C.load_dot1x() }) return productionBackend{} } -func (productionBackend) GetStatus(ifname string) (EAPOLStatus, error) { +func (productionBackend) GetStatus(ifname string) (Dot1XStatus, error) { cName := C.CString(ifname) defer C.free(unsafe.Pointer(cName)) var ( - cState C.int - cSupplicantState C.int - cEAPType C.int - cEAPTypeName *C.char - cClientStatus C.int - cDomainError C.int - cAuthMAC *C.uint8_t - cAuthMACLen C.CFIndex - cMode C.int - cTLSResumed C.int - cCertChainData *C.uint8_t - cCertChainLen C.CFIndex - cTLSTrustStatus C.int - cTLSCipher C.int - cTLSProtoVersion *C.char - cInnerEAPType C.int - cInnerEAPTypeName *C.char - cLastTimestamp *C.char - cUniqueID *C.char + cState C.int + cSupplicantState C.int + cEAPType C.int + cEAPTypeName *C.char + cClientStatus C.int + cDomainError C.int + cAuthMAC *C.uint8_t + cAuthMACLen C.CFIndex + cMode C.int + cTLSResumed C.int + cCertChainData *C.uint8_t + cCertChainLen C.CFIndex + cTLSTrustStatus C.int + cTLSCipher C.int + cTLSProtoVersion *C.char + cInnerEAPType C.int + cInnerEAPTypeName *C.char + cLastTimestamp *C.char + cUniqueID *C.char ) - ret := C.eapol_query( + ret := C.dot1x_query( cName, &cState, &cSupplicantState, @@ -364,14 +364,14 @@ func (productionBackend) GetStatus(ifname string) (EAPOLStatus, error) { &cUniqueID, ) - s := EAPOLStatus{ - Interface: ifname, - State: int(cState), - SupplicantState: int(cSupplicantState), - EAPType: int(cEAPType), - ClientStatus: int(cClientStatus), - DomainSpecificError: int(cDomainError), - Mode: int(cMode), + s := Dot1XStatus{ + Interface: ifname, + State: int(cState), + SupplicantState: int(cSupplicantState), + EAPType: int(cEAPType), + ClientStatus: int(cClientStatus), + DomainSpecificError: int(cDomainError), + Mode: int(cMode), TLSSessionWasResumed: cTLSResumed == 1, } diff --git a/tables/eapolstatus/eapol_darwin_test.go b/tables/dot1x/dot1x_darwin_test.go similarity index 94% rename from tables/eapolstatus/eapol_darwin_test.go rename to tables/dot1x/dot1x_darwin_test.go index 973d3a3..826bfb4 100644 --- a/tables/eapolstatus/eapol_darwin_test.go +++ b/tables/dot1x/dot1x_darwin_test.go @@ -1,6 +1,6 @@ //go:build darwin -package eapolstatus +package dot1x import ( "testing" @@ -15,7 +15,7 @@ func TestCgoFrameworkLoading(t *testing.T) { backend := newBackend() // Verify the framework loads and the symbol resolves. - // load_eapol() is called in newBackend via sync.Once, so the + // load_dot1x() is called in newBackend via sync.Once, so the // framework is loaded before the first GetStatus call. _, err := backend.GetStatus("en0") // EAP8021X.framework should be loadable on any macOS system. @@ -37,10 +37,10 @@ func TestCgoBogusInterface(t *testing.T) { assert.Contains(t, err.Error(), "bogus999999") } -// --- Mock-based integration tests using the shared EAPOLBackend interface --- +// --- Mock-based integration tests using the shared Dot1XBackend interface --- // // These exercise the full generateRows -> rowFromStatus path with canned -// EAPOLStatus values, so they run deterministically without the EAP8021X +// Dot1XStatus values, so they run deterministically without the EAP8021X // framework or an active 802.1X session. They focus on the macOS-rich fields // (TLS server certificate chain, fingerprints, serials, negotiated protocol // version/cipher, trust status, and last-status timestamp) that the cgo @@ -50,7 +50,7 @@ func TestDarwinMockBackendSystemEAPTLS(t *testing.T) { t.Parallel() backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en0": { Interface: "en0", State: 2, // Running @@ -112,11 +112,11 @@ func TestDarwinMockBackendSystemEAPTLS(t *testing.T) { func TestDarwinMockBackendIdle(t *testing.T) { t.Parallel() - // An interface with no active EAPOL session: state Idle, supplicant + // An interface with no active 802.1X session: state Idle, supplicant // Disconnected, and all the optional numeric fields set to the cgo // backend's -1 sentinel (which rowFromStatus renders as empty strings). backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en0": { Interface: "en0", State: 0, // Idle @@ -158,7 +158,7 @@ func TestDarwinMockBackendPEAP(t *testing.T) { // PEAP (25) tunneling MSCHAPv2 (26) on a LoginWindow-mode interface, // negotiated over TLS 1.3 with a resumed session. backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en1": { Interface: "en1", State: 2, // Running @@ -201,7 +201,7 @@ func TestDarwinMockBackendUnknownEAPType(t *testing.T) { // A positive EAP type code missing from eapTypeNames should render as // "Unknown()", consistent with state/supplicant/mode handling. backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en0": { Interface: "en0", State: 2, @@ -225,7 +225,7 @@ func TestDarwinMockBackendMultipleInterfaces(t *testing.T) { // Probing two interfaces where both are active yields one row each. backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en0": {Interface: "en0", State: 2, SupplicantState: 4, EAPType: 13, EAPTypeName: "EAP-TLS", Mode: 3, InnerEAPType: -1}, "en1": {Interface: "en1", State: 2, SupplicantState: 4, EAPType: 25, InnerEAPType: 26, Mode: 1}, }, @@ -256,7 +256,7 @@ func TestDarwinMockBackendNotFound(t *testing.T) { // An interface the backend doesn't know about errors per-interface, and // generateRows skips it, yielding zero rows. - backend := fakeBackend{statuses: map[string]EAPOLStatus{}} + backend := fakeBackend{statuses: map[string]Dot1XStatus{}} rows, err := generateRows(t.Context(), backend, constraintFor("en0")) require.NoError(t, err) assert.Empty(t, rows) diff --git a/tables/eapolstatus/eapol_other.go b/tables/dot1x/dot1x_other.go similarity index 66% rename from tables/eapolstatus/eapol_other.go rename to tables/dot1x/dot1x_other.go index 9a1bc29..057db6d 100644 --- a/tables/eapolstatus/eapol_other.go +++ b/tables/dot1x/dot1x_other.go @@ -1,20 +1,20 @@ //go:build !darwin && !windows -package eapolstatus +package dot1x import ( "fmt" "strconv" ) -func newBackend() EAPOLBackend { +func newBackend() Dot1XBackend { return noopBackend{} } type noopBackend struct{} -func (noopBackend) GetStatus(ifname string) (EAPOLStatus, error) { - return EAPOLStatus{Interface: ifname}, fmt.Errorf("%w: EAP8021X framework not available on this platform", ErrBackendUnavailable) +func (noopBackend) GetStatus(ifname string) (Dot1XStatus, error) { + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: EAP8021X framework not available on this platform", ErrBackendUnavailable) } func defaultInterfaces() []string { diff --git a/tables/eapolstatus/eapolstatus_test.go b/tables/dot1x/dot1x_test.go similarity index 90% rename from tables/eapolstatus/eapolstatus_test.go rename to tables/dot1x/dot1x_test.go index 1bfad86..df8c909 100644 --- a/tables/eapolstatus/eapolstatus_test.go +++ b/tables/dot1x/dot1x_test.go @@ -1,4 +1,4 @@ -package eapolstatus +package dot1x import ( "context" @@ -22,20 +22,20 @@ import ( "github.com/stretchr/testify/require" ) -// fakeBackend is a deterministic in-memory EAPOLBackend for tests. +// fakeBackend is a deterministic in-memory Dot1XBackend for tests. type fakeBackend struct { - statuses map[string]EAPOLStatus + statuses map[string]Dot1XStatus } -func (f fakeBackend) GetStatus(ifname string) (EAPOLStatus, error) { +func (f fakeBackend) GetStatus(ifname string) (Dot1XStatus, error) { s, ok := f.statuses[ifname] if ok { return s, nil } - return EAPOLStatus{Interface: ifname}, errors.New("not found") + return Dot1XStatus{Interface: ifname}, errors.New("not found") } -func TestEAPOLStatusColumns(t *testing.T) { +func TestDot1XStatusColumns(t *testing.T) { t.Parallel() want := []string{ "interface", "state", "state_name", @@ -56,7 +56,7 @@ func TestEAPOLStatusColumns(t *testing.T) { "last_status_timestamp", "unique_identifier", } - cols := EAPOLStatusColumns() + cols := Dot1XStatusColumns() require.Len(t, cols, len(want)) for i, c := range cols { assert.Equal(t, want[i], c.Name) @@ -131,27 +131,27 @@ func TestInterfacesToQuery(t *testing.T) { func TestRowFromStatus(t *testing.T) { t.Parallel() - s := EAPOLStatus{ - Interface: "en0", - State: 2, - SupplicantState: 4, - EAPType: 13, - EAPTypeName: "EAP-TLS", - ClientStatus: 0, - DomainSpecificError: 0, - AuthenticatorMACAddress: "aa:bb:cc:dd:ee:ff", - Mode: 1, - TLSSessionWasResumed: true, - TLSServerCertificateChain: "CN=radius.campus.edu,OU=IT,O=Campus,C=US", - TLSServerCertificateSHA1: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", - TLSServerCertificateSerials: "7D3A1F9E2B5C", - TLSTrustClientStatus: 0, + s := Dot1XStatus{ + Interface: "en0", + State: 2, + SupplicantState: 4, + EAPType: 13, + EAPTypeName: "EAP-TLS", + ClientStatus: 0, + DomainSpecificError: 0, + AuthenticatorMACAddress: "aa:bb:cc:dd:ee:ff", + Mode: 1, + TLSSessionWasResumed: true, + TLSServerCertificateChain: "CN=radius.campus.edu,OU=IT,O=Campus,C=US", + TLSServerCertificateSHA1: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + TLSServerCertificateSerials: "7D3A1F9E2B5C", + TLSTrustClientStatus: 0, TLSNegotiatedProtocolVersion: "1.2", TLSNegotiatedCipher: 0xC02B, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 - InnerEAPType: 26, - InnerEAPTypeName: "MSCHAPv2", - LastStatusTimestamp: "2026-06-05T12:00:00Z", - UniqueIdentifier: "abc-123", + InnerEAPType: 26, + InnerEAPTypeName: "MSCHAPv2", + LastStatusTimestamp: "2026-06-05T12:00:00Z", + UniqueIdentifier: "abc-123", } row := rowFromStatus(s) @@ -183,10 +183,10 @@ func TestRowFromStatus(t *testing.T) { func TestRowFromStatusUnsetFields(t *testing.T) { t.Parallel() - s := EAPOLStatus{ - Interface: "en0", - State: 0, - SupplicantState: 8, + s := Dot1XStatus{ + Interface: "en0", + State: 0, + SupplicantState: 8, TLSSessionWasResumed: false, } @@ -200,12 +200,12 @@ func TestRowFromStatusUnsetFields(t *testing.T) { func TestRowFromStatusUnknownEnumValues(t *testing.T) { t.Parallel() - s := EAPOLStatus{ - Interface: "en0", - State: 99, + s := Dot1XStatus{ + Interface: "en0", + State: 99, SupplicantState: 99, - Mode: 99, - EAPType: 0, + Mode: 99, + EAPType: 0, } row := rowFromStatus(s) @@ -228,7 +228,7 @@ func TestGenerateRowsWithConstraint(t *testing.T) { t.Parallel() backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en0": { Interface: "en0", State: 2, @@ -261,7 +261,7 @@ func TestGenerateRowsWithConstraint(t *testing.T) { func TestGenerateRowsNoActiveInterface(t *testing.T) { t.Parallel() - backend := fakeBackend{statuses: map[string]EAPOLStatus{}} + backend := fakeBackend{statuses: map[string]Dot1XStatus{}} qc := table.QueryContext{ Constraints: map[string]table.ConstraintList{ "interface": { @@ -282,7 +282,7 @@ func TestGenerateRowsSkipsErrors(t *testing.T) { // en0 has a valid status, en1 errors — should return only en0 backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "en0": { Interface: "en0", State: 2, @@ -328,8 +328,8 @@ type errBackend struct { err error } -func (e errBackend) GetStatus(ifname string) (EAPOLStatus, error) { - return EAPOLStatus{Interface: ifname}, e.err +func (e errBackend) GetStatus(ifname string) (Dot1XStatus, error) { + return Dot1XStatus{Interface: ifname}, e.err } func TestMacAddrString(t *testing.T) { @@ -337,15 +337,15 @@ func TestMacAddrString(t *testing.T) { assert.Equal(t, "aa:bb:cc:dd:ee:ff", macAddrString([]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff})) - assert.Equal(t, "", macAddrString([]byte{0xaa})) // too short + assert.Equal(t, "", macAddrString([]byte{0xaa})) // too short assert.Equal(t, "", macAddrString([]byte{0, 1, 2, 3, 4, 5, 6})) // too long assert.Equal(t, "", macAddrString(nil)) } -func TestEAPOLStatusGenerate(t *testing.T) { +func TestDot1XStatusGenerate(t *testing.T) { t.Parallel() - rows, err := generateRows(context.Background(), fakeBackend{statuses: map[string]EAPOLStatus{}}, table.QueryContext{ + rows, err := generateRows(context.Background(), fakeBackend{statuses: map[string]Dot1XStatus{}}, table.QueryContext{ Constraints: map[string]table.ConstraintList{ "interface": { Constraints: []table.Constraint{ @@ -409,12 +409,12 @@ func TestRowFromStatusEAPTypeNameFallback(t *testing.T) { t.Parallel() // When no explicit EAPTypeName is set, fall back to eapTypeNames map - s := EAPOLStatus{Interface: "en0", EAPType: 13} + s := Dot1XStatus{Interface: "en0", EAPType: 13} row := rowFromStatus(s) assert.Equal(t, "EAP-TLS", row["eap_type_name"]) // EAPTypeName set explicitly takes precedence - s2 := EAPOLStatus{Interface: "en0", EAPType: 13, EAPTypeName: "Custom-EAP"} + s2 := Dot1XStatus{Interface: "en0", EAPType: 13, EAPTypeName: "Custom-EAP"} row2 := rowFromStatus(s2) assert.Equal(t, "Custom-EAP", row2["eap_type_name"]) } diff --git a/tables/eapolstatus/eapol_windows.go b/tables/dot1x/dot1x_windows.go similarity index 93% rename from tables/eapolstatus/eapol_windows.go rename to tables/dot1x/dot1x_windows.go index ccede6b..e6bd57b 100644 --- a/tables/eapolstatus/eapol_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -1,6 +1,6 @@ //go:build windows -package eapolstatus +package dot1x import ( "fmt" @@ -16,14 +16,14 @@ const ( wlanIntfOpcodeCurrentConnection uint32 = 7 - wlanIfaceStateNotReady uint32 = 0 - wlanIfaceStateConnected uint32 = 1 - wlanIfaceStateAdHocFormed uint32 = 2 - wlanIfaceStateDisconnecting uint32 = 3 - wlanIfaceStateDisconnected uint32 = 4 - wlanIfaceStateAssociating uint32 = 5 - wlanIfaceStateDiscovering uint32 = 6 - wlanIfaceStateAuthenticating uint32 = 7 + wlanIfaceStateNotReady uint32 = 0 + wlanIfaceStateConnected uint32 = 1 + wlanIfaceStateAdHocFormed uint32 = 2 + wlanIfaceStateDisconnecting uint32 = 3 + wlanIfaceStateDisconnected uint32 = 4 + wlanIfaceStateAssociating uint32 = 5 + wlanIfaceStateDiscovering uint32 = 6 + wlanIfaceStateAuthenticating uint32 = 7 ) type windowsGUID struct { @@ -69,10 +69,10 @@ type wlanAssociationAttributes struct { } type wlanSecurityAttributes struct { - SecurityEnabled int32 - OneXEnabled int32 - AuthAlgorithm uint32 - CipherAlgorithm uint32 + SecurityEnabled int32 + OneXEnabled int32 + AuthAlgorithm uint32 + CipherAlgorithm uint32 } type wlanConnectionAttributes struct { @@ -117,7 +117,7 @@ func initWlan() { type windowsBackend struct{} -func newBackend() EAPOLBackend { +func newBackend() Dot1XBackend { wlanOnce.Do(initWlan) if !wlanAvail { return unavailableBackend{} @@ -127,8 +127,8 @@ func newBackend() EAPOLBackend { type unavailableBackend struct{} -func (unavailableBackend) GetStatus(ifname string) (EAPOLStatus, error) { - return EAPOLStatus{Interface: ifname}, +func (unavailableBackend) GetStatus(ifname string) (Dot1XStatus, error) { + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: wlanapi.dll not available on this system", ErrBackendUnavailable) } @@ -197,20 +197,20 @@ func defaultInterfaces() []string { return ifaces } -func (windowsBackend) GetStatus(ifname string) (EAPOLStatus, error) { +func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { handle, err := openWlanHandle() if err != nil { - return EAPOLStatus{Interface: ifname}, + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: %v", ErrBackendUnavailable, err) } defer closeWlanHandle(handle) guid, ifState, err := findWlanInterface(handle, ifname) if err != nil { - return EAPOLStatus{Interface: ifname}, err + return Dot1XStatus{Interface: ifname}, err } - s := EAPOLStatus{ + s := Dot1XStatus{ Interface: ifname, UniqueIdentifier: guid.String(), } diff --git a/tables/eapolstatus/eapol_windows_test.go b/tables/dot1x/dot1x_windows_test.go similarity index 95% rename from tables/eapolstatus/eapol_windows_test.go rename to tables/dot1x/dot1x_windows_test.go index 3338005..f11ed84 100644 --- a/tables/eapolstatus/eapol_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -1,6 +1,6 @@ //go:build windows -package eapolstatus +package dot1x import ( "testing" @@ -336,21 +336,21 @@ func TestUnavailableBackend(t *testing.T) { assert.Equal(t, "wifi0", s.Interface) } -// --- Mock-based integration tests using the shared EAPOLBackend interface --- +// --- Mock-based integration tests using the shared Dot1XBackend interface --- func TestWindowsMockBackendConnected(t *testing.T) { t.Parallel() backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "RZ616 Wi-Fi 6E 160MHz": { - Interface: "RZ616 Wi-Fi 6E 160MHz", - State: 2, - SupplicantState: 4, - EAPType: 13, - ClientStatus: 0, - AuthenticatorMACAddress: "26:0b:8b:00:f2:34", - Mode: 3, + Interface: "RZ616 Wi-Fi 6E 160MHz", + State: 2, + SupplicantState: 4, + EAPType: 13, + ClientStatus: 0, + AuthenticatorMACAddress: "26:0b:8b:00:f2:34", + Mode: 3, TLSServerCertificateSHA1: "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", UniqueIdentifier: "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", DomainSpecificError: -1, @@ -391,15 +391,15 @@ func TestWindowsMockBackendDisconnected(t *testing.T) { t.Parallel() backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "Intel Wi-Fi 6": { - Interface: "Intel Wi-Fi 6", - State: 0, - SupplicantState: 0, - EAPType: -1, - ClientStatus: -1, - Mode: -1, - DomainSpecificError: -1, + Interface: "Intel Wi-Fi 6", + State: 0, + SupplicantState: 0, + EAPType: -1, + ClientStatus: -1, + Mode: -1, + DomainSpecificError: -1, TLSTrustClientStatus: -1, TLSNegotiatedCipher: -1, InnerEAPType: -1, @@ -431,7 +431,7 @@ func TestWindowsMockBackendPEAP(t *testing.T) { t.Parallel() backend := fakeBackend{ - statuses: map[string]EAPOLStatus{ + statuses: map[string]Dot1XStatus{ "Realtek Wi-Fi": { Interface: "Realtek Wi-Fi", State: 2, @@ -466,7 +466,7 @@ func TestWindowsMockBackendPEAP(t *testing.T) { func TestWindowsMockBackendNotFound(t *testing.T) { t.Parallel() - backend := fakeBackend{statuses: map[string]EAPOLStatus{}} + backend := fakeBackend{statuses: map[string]Dot1XStatus{}} qc := constraintFor("nonexistent adapter") rows, err := generateRows(t.Context(), backend, qc) require.NoError(t, err) From c11ba8e60f2cc8318d9bf8ab5bd9a4b136ce15b8 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 19:40:26 -0400 Subject: [PATCH 13/36] Address Copilot review: neutral backend-unavailable sentinel, robust cquery copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dot1x: make ErrBackendUnavailable message platform-neutral ("802.1X backend unavailable") since it's the cross-platform sentinel; each backend already wraps it with platform-specific detail. Update the non-darwin/non-windows stub wording to match. - bazel_to_builddir.sh: fix a set -e abort I introduced — `grep -c .` exits non-zero on empty cquery output, killing the script before the error handler. Guard empty output with -z (and `|| true` on cquery), and count paths with wc -l instead of grep. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x.go | 8 +++++--- tables/dot1x/dot1x_other.go | 2 +- tools/bazel_to_builddir.sh | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tables/dot1x/dot1x.go b/tables/dot1x/dot1x.go index dad38c0..83b0bfe 100644 --- a/tables/dot1x/dot1x.go +++ b/tables/dot1x/dot1x.go @@ -47,9 +47,11 @@ type Dot1XBackend interface { GetStatus(ifname string) (Dot1XStatus, error) } -// ErrBackendUnavailable is returned by GetStatus when the EAP8021X -// framework cannot be loaded (a systemic failure, not per-interface). -var ErrBackendUnavailable = errors.New("EAP8021X.framework unavailable") +// ErrBackendUnavailable is returned by GetStatus when a platform's 802.1X +// backend cannot be initialized (e.g. EAP8021X.framework on macOS or +// wlanapi.dll on Windows) — a systemic failure, not a per-interface one. +// Each backend wraps it with platform-specific detail. +var ErrBackendUnavailable = errors.New("802.1X backend unavailable") // stateNames maps EAPOLControlState to human-readable strings. var stateNames = map[int]string{ diff --git a/tables/dot1x/dot1x_other.go b/tables/dot1x/dot1x_other.go index 057db6d..2c9a854 100644 --- a/tables/dot1x/dot1x_other.go +++ b/tables/dot1x/dot1x_other.go @@ -14,7 +14,7 @@ func newBackend() Dot1XBackend { type noopBackend struct{} func (noopBackend) GetStatus(ifname string) (Dot1XStatus, error) { - return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: EAP8021X framework not available on this platform", ErrBackendUnavailable) + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: not supported on this platform", ErrBackendUnavailable) } func defaultInterfaces() []string { diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index 1d94952..b3bf3a6 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -13,11 +13,19 @@ APP_NAME="macadmins_extension" # (or an unexpected multi-file output) are handled safely rather than being # silently word-split. copy_bazel_output() { - local target="$1" dest="$2" file count - file="$(bazel cquery --output=files "$target" 2>/dev/null)" - count="$(printf '%s' "$file" | grep -c .)" - if [ "$count" -ne 1 ]; then - echo "error: expected exactly one output file for ${target}, got ${count}:" >&2 + local target="$1" dest="$2" file lines + # `|| true` keeps a failed/empty cquery from tripping `set -e` before the + # emptiness check below can emit a clear error. + file="$(bazel cquery --output=files "$target" 2>/dev/null || true)" + if [ -z "$file" ]; then + echo "error: no output file for ${target}" >&2 + return 1 + fi + # Reject multi-path output (one path per line). wc -l is used instead of + # `grep -c`, which exits non-zero on zero matches and would trip `set -e`. + lines="$(printf '%s\n' "$file" | wc -l | tr -d '[:space:]')" + if [ "$lines" -ne 1 ]; then + echo "error: expected exactly one output file for ${target}, got ${lines}:" >&2 printf '%s\n' "$file" >&2 return 1 fi From cf97ff79ec83748746f7292a1409844b72843d6a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 23:14:26 -0400 Subject: [PATCH 14/36] dot1x: strip all whitespace from TrustedRootCA hex, not just spaces Pretty-printed WLAN profile XML can contain newlines and tabs inside values. Use strings.Fields to strip all whitespace. Co-Authored-By: Claude Opus 4.6 --- tables/dot1x/dot1x_windows.go | 2 +- tables/dot1x/dot1x_windows_test.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index e6bd57b..cf07c46 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -412,7 +412,7 @@ func extractTrustedRootCAFromXML(xml string) string { if end < 0 { break } - hex := strings.ReplaceAll(strings.TrimSpace(sub[:end]), " ", "") + hex := strings.Join(strings.Fields(strings.TrimSpace(sub[:end])), "") if len(hex) == 40 { hashes = append(hashes, formatSHA1Hex(hex)) } diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index f11ed84..6ec4cb1 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -209,6 +209,11 @@ func TestExtractTrustedRootCAFromXML(t *testing.T) { ` `, "", }, + { + "newlines and tabs in hex (pretty-printed XML)", + "\n\t\t\t\t23 a6 b1 0a ff bb cc dd ee 11\n\t\t\t\t22 33 44 55 66 77 88 99 aa bb\n\t\t\t", + "23:a6:b1:0a:ff:bb:cc:dd:ee:11:22:33:44:55:66:77:88:99:aa:bb", + }, } for _, tc := range tests { From 7189887cd470917f0b97eadcea2aa6053dec82cb Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 23:20:27 -0400 Subject: [PATCH 15/36] dot1x: reset SupplicantState for non-802.1X Wi-Fi networks When OneXEnabled == 0 (non-enterprise network), set SupplicantState to 0 instead of leaving the mapWlanState default which would show "Authenticated" for any connected Wi-Fi interface. Co-Authored-By: Claude Opus 4.6 --- tables/dot1x/dot1x_windows.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index cf07c46..76a5dd4 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -254,6 +254,8 @@ func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { s.SupplicantState = 4 // Authenticated s.ClientStatus = 0 } + } else { + s.SupplicantState = 0 // not an 802.1X network } profileName := utf16ToString(conn.ProfileName[:]) From 59f23788b6af618390ad177386595a813d59891b Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 23:26:40 -0400 Subject: [PATCH 16/36] dot1x: use syscall.Errno for Win32 errors, windows.UTF16PtrToString, xargs in Makefile - Wrap WLAN API return codes with syscall.Errno for human-readable error messages instead of raw numeric codes. - Replace hand-rolled unsafe.Slice-based utf16PtrToString with windows.UTF16PtrToString from golang.org/x/sys/windows. - Pipe bazel query through xargs in Makefile to avoid argv-length limits as the workspace grows. Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- tables/dot1x/BUILD.bazel | 7 ++++++- tables/dot1x/dot1x_windows.go | 17 ++++++----------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index e47e581..7310853 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,7 @@ test: # cgo=True) and fail on Linux CI where no darwin C++ toolchain exists. # Matching all *_test kinds (not just go_test) keeps non-Go tests # (sh_test, py_test, etc.) in scope if any are added later. - bazel test --test_output=errors $$(bazel query 'kind(".*_test", //...)') + bazel query 'kind(".*_test", //...)' | xargs bazel test --test_output=errors build: .pre-build ifeq ($(shell uname),Darwin) diff --git a/tables/dot1x/BUILD.bazel b/tables/dot1x/BUILD.bazel index 37c08f4..ff23c9d 100644 --- a/tables/dot1x/BUILD.bazel +++ b/tables/dot1x/BUILD.bazel @@ -27,7 +27,12 @@ go_library( visibility = ["//visibility:public"], deps = [ "@com_github_osquery_osquery_go//plugin/table", - ], + ] + select({ + "@io_bazel_rules_go//go/platform:windows": [ + "@org_golang_x_sys//windows", + ], + "//conditions:default": [], + }), ) go_test( diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 76a5dd4..7d7dd19 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -9,6 +9,8 @@ import ( "sync" "syscall" "unsafe" + + "golang.org/x/sys/windows" ) const ( @@ -142,7 +144,7 @@ func openWlanHandle() (uintptr, error) { uintptr(unsafe.Pointer(&handle)), ) if ret != 0 { - return 0, fmt.Errorf("WlanOpenHandle failed: %d", ret) + return 0, fmt.Errorf("WlanOpenHandle failed: %w", syscall.Errno(ret)) } return handle, nil } @@ -297,7 +299,7 @@ func getWlanProfileXML(handle uintptr, guid *windowsGUID, profileName string) (s 0, ) if ret != 0 || xmlPtr == nil { - return "", fmt.Errorf("WlanGetProfile failed: %d", ret) + return "", fmt.Errorf("WlanGetProfile failed: %w", syscall.Errno(ret)) } defer freeWlanMemory(uintptr(unsafe.Pointer(xmlPtr))) return utf16PtrToString(xmlPtr), nil @@ -442,21 +444,14 @@ func utf16PtrToString(p *uint16) string { if p == nil { return "" } - const maxChars = 32768 - s := unsafe.Slice(p, maxChars) - for i, v := range s { - if v == 0 { - return syscall.UTF16ToString(s[:i]) - } - } - return syscall.UTF16ToString(s) + return windows.UTF16PtrToString(p) } func findWlanInterface(handle uintptr, name string) (*windowsGUID, uint32, error) { var listPtr unsafe.Pointer ret, _, _ := procWlanEnumInterfaces.Call(handle, 0, uintptr(unsafe.Pointer(&listPtr))) if ret != 0 || listPtr == nil { - return nil, 0, fmt.Errorf("WlanEnumInterfaces failed: %d", ret) + return nil, 0, fmt.Errorf("WlanEnumInterfaces failed: %w", syscall.Errno(ret)) } defer freeWlanMemory(uintptr(listPtr)) From 1de369b2609e221b8e85b9f19d1bb717a7ef0254 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 23:34:58 -0400 Subject: [PATCH 17/36] dot1x: pass opcodeValueType out-param to WlanQueryInterface, capture bazel query output - Pass a proper &opcodeValueType variable instead of 0 to WlanQueryInterface's last parameter. - Capture bazel query output into a variable before passing to bazel test so a query failure stops the recipe. Co-Authored-By: Claude Opus 4.6 --- Makefile | 2 +- tables/dot1x/dot1x_windows.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 7310853..88ab79a 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,7 @@ test: # cgo=True) and fail on Linux CI where no darwin C++ toolchain exists. # Matching all *_test kinds (not just go_test) keeps non-Go tests # (sh_test, py_test, etc.) in scope if any are added later. - bazel query 'kind(".*_test", //...)' | xargs bazel test --test_output=errors + targets=$$(bazel query 'kind(".*_test", //...)') && bazel test --test_output=errors $$targets build: .pre-build ifeq ($(shell uname),Darwin) diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 7d7dd19..dceaef3 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -232,6 +232,7 @@ func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { var dataSize uint32 var dataPtr unsafe.Pointer + var opcodeValueType uint32 ret, _, _ := procWlanQueryInterface.Call( handle, uintptr(unsafe.Pointer(guid)), @@ -239,7 +240,7 @@ func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { 0, uintptr(unsafe.Pointer(&dataSize)), uintptr(unsafe.Pointer(&dataPtr)), - 0, + uintptr(unsafe.Pointer(&opcodeValueType)), ) if ret != 0 || dataPtr == nil { return s, nil From 29713485d0f3913692762c1ac477e1431537d4bd Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 6 Jun 2026 23:46:04 -0400 Subject: [PATCH 18/36] dot1x: skip live tests on ErrBackendUnavailable, clarify cgo comments - Live Windows tests now skip with t.Skip when GetStatus returns ErrBackendUnavailable (WLAN service disabled, permissions, no Wi-Fi service in CI/VM environments). - Clarify WORKSPACE and Makefile comments to reference the root BUILD.bazel go_binary targets where pure="off" is set. Co-Authored-By: Claude Opus 4.6 --- Makefile | 5 +++-- WORKSPACE | 2 +- tables/dot1x/dot1x_windows_test.go | 8 +++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 88ab79a..e467ab4 100644 --- a/Makefile +++ b/Makefile @@ -51,8 +51,9 @@ update-repos: test: # Query only test targets (any *_test kind) rather than `bazel test //...`, - # which would also analyze the darwin cgo binary targets (pure="off", - # cgo=True) and fail on Linux CI where no darwin C++ toolchain exists. + # which would also analyze the darwin go_binary targets (pure="off", + # cgo=True in root BUILD.bazel) and fail on Linux CI where no darwin + # C++ toolchain exists. # Matching all *_test kinds (not just go_test) keeps non-Go tests # (sh_test, py_test, etc.) in scope if any are added later. targets=$$(bazel query 'kind(".*_test", //...)') && bazel test --test_output=errors $$targets diff --git a/WORKSPACE b/WORKSPACE index 01735b2..8b29a95 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -3,7 +3,7 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # --- Apple C/C++ toolchain for cgo on macOS ------------------------------- # The dot1x table calls EAP8021X.framework (CoreFoundation) via cgo, # so the macOS go_binary targets are built with cgo enabled (see -# BUILD.bazel: cgo = True, pure = "off"). That requires an Apple CC toolchain, +# root BUILD.bazel go_binary targets: cgo = True, pure = "off"). That requires an Apple CC toolchain, # which apple_support provides. This block must precede rules_go so its CC # toolchain is registered for the Apple cc actions cgo uses. # diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index 6ec4cb1..b73e954 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -3,6 +3,7 @@ package dot1x import ( + "errors" "testing" "github.com/osquery/osquery-go/plugin/table" @@ -504,6 +505,9 @@ func TestWindowsLiveBackend(t *testing.T) { for _, ifname := range ifaces { s, err := backend.GetStatus(ifname) + if errors.Is(err, ErrBackendUnavailable) { + t.Skipf("WLAN service unavailable: %v", err) + } require.NoError(t, err) assert.Equal(t, ifname, s.Interface) assert.NotEmpty(t, s.UniqueIdentifier, "GUID should always be set") @@ -520,8 +524,10 @@ func TestWindowsLiveBackendBogusInterface(t *testing.T) { } _, err := backend.GetStatus("nonexistent_adapter_999") + if errors.Is(err, ErrBackendUnavailable) { + t.Skipf("WLAN service unavailable: %v", err) + } require.Error(t, err) - assert.NotErrorIs(t, err, ErrBackendUnavailable) assert.Contains(t, err.Error(), "nonexistent_adapter_999") } From 07ff06f1c2dde1439af86eca9ed547f26818cf50 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 15:35:44 -0400 Subject: [PATCH 19/36] Address Copilot review: cover Linux backend, distinguish cquery failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .testcoverage.yml: stop excluding dot1x_other.go — it's the active !darwin && !windows implementation that builds/runs on the Linux coverage job. Add dot1x_other_test.go covering newBackend, noopBackend.GetStatus, and defaultInterfaces so the un-excluded file clears the 40% gate. darwin/windows backends stay excluded (build-tagged out of the Linux run). - bazel_to_builddir.sh: capture cquery stderr + exit code so a real bazel failure (missing bazel, bad workspace, query error) is reported distinctly from a successful-but-empty result. Co-Authored-By: Claude Opus 4.8 (1M context) --- .testcoverage.yml | 3 ++- tables/dot1x/BUILD.bazel | 1 + tables/dot1x/dot1x_other_test.go | 34 ++++++++++++++++++++++++++++++++ tools/bazel_to_builddir.sh | 20 ++++++++++++++----- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 tables/dot1x/dot1x_other_test.go diff --git a/.testcoverage.yml b/.testcoverage.yml index bc3513a..4dc90ab 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -60,9 +60,10 @@ exclude: - tables/puppet/puppet_logs.go - tables/puppet/puppet_state.go - tables/puppet/yaml.go + # darwin/windows backends are build-tagged out of the Linux coverage run; + # the active !darwin && !windows path (dot1x_other.go) stays covered. - tables/dot1x/dot1x_darwin.go - tables/dot1x/dot1x_windows.go - - tables/dot1x/dot1x_other.go # - \.pb\.go$ # excludes all protobuf generated files # - ^pkg/bar # exclude package `pkg/bar` diff --git a/tables/dot1x/BUILD.bazel b/tables/dot1x/BUILD.bazel index ff23c9d..57e5788 100644 --- a/tables/dot1x/BUILD.bazel +++ b/tables/dot1x/BUILD.bazel @@ -40,6 +40,7 @@ go_test( srcs = [ "dot1x_test.go", "dot1x_darwin_test.go", + "dot1x_other_test.go", "dot1x_windows_test.go", ], embed = [":dot1x"], diff --git a/tables/dot1x/dot1x_other_test.go b/tables/dot1x/dot1x_other_test.go new file mode 100644 index 0000000..ef9edf6 --- /dev/null +++ b/tables/dot1x/dot1x_other_test.go @@ -0,0 +1,34 @@ +//go:build !darwin && !windows + +package dot1x + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// On platforms without an 802.1X backend (Linux, etc.), newBackend returns a +// noopBackend whose GetStatus reports the systemic ErrBackendUnavailable. +func TestOtherBackendUnavailable(t *testing.T) { + t.Parallel() + + b := newBackend() + _, ok := b.(noopBackend) + require.True(t, ok, "non-darwin/windows newBackend should return noopBackend") + + s, err := b.GetStatus("eth0") + require.Error(t, err) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Equal(t, "eth0", s.Interface) +} + +func TestOtherDefaultInterfaces(t *testing.T) { + t.Parallel() + + ifaces := defaultInterfaces() + require.Len(t, ifaces, 10) + assert.Equal(t, "en0", ifaces[0]) + assert.Equal(t, "en9", ifaces[9]) +} diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index b3bf3a6..eef65c4 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -13,12 +13,22 @@ APP_NAME="macadmins_extension" # (or an unexpected multi-file output) are handled safely rather than being # silently word-split. copy_bazel_output() { - local target="$1" dest="$2" file lines - # `|| true` keeps a failed/empty cquery from tripping `set -e` before the - # emptiness check below can emit a clear error. - file="$(bazel cquery --output=files "$target" 2>/dev/null || true)" + local target="$1" dest="$2" file lines err rc + err="$(mktemp)" + # Capture stderr and the exit code so an actual bazel/cquery failure + # (missing bazel, bad workspace, query error) is reported distinctly from + # a successful-but-empty result. The `&& rc=0 || rc=$?` idiom records the + # exit code without tripping `set -e`. + file="$(bazel cquery --output=files "$target" 2>"$err")" && rc=0 || rc=$? + if [ "$rc" -ne 0 ]; then + echo "error: 'bazel cquery' failed for ${target} (exit ${rc}):" >&2 + cat "$err" >&2 + rm -f "$err" + return 1 + fi + rm -f "$err" if [ -z "$file" ]; then - echo "error: no output file for ${target}" >&2 + echo "error: no output file for ${target} (target produced no outputs)" >&2 return 1 fi # Reject multi-path output (one path per line). wc -l is used instead of From 811d82c1710e70e23e828335149b2a940b4409aa Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 15:52:34 -0400 Subject: [PATCH 20/36] dot1x(windows): reuse WLAN handle + parse profile XML via encoding/xml Address Copilot review on the Windows backend: - Cache the WLAN client handle for the process lifetime (opened once in initWlan, like the darwin framework handle) instead of opening/closing it on every GetStatus. windowsBackend now also snapshots all interfaces once per table generation (sync.Once), so querying N interfaces enumerates once rather than N times. Enumeration failure is reported as ErrBackendUnavailable (systemic) while a missing interface stays a per-interface skip. - Replace the brittle substring-based WLAN profile XML extraction with encoding/xml tokenization matching by local element name. This tolerates namespace prefixes, attributes on elements, and arbitrary indentation (e.g. a prefixed is now matched, where the substring scan missed it). Added test cases for those variations; behavior on the existing fixtures is unchanged. - go.mod: promote golang.org/x/sys to a direct dependency (go mod tidy). Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- tables/dot1x/dot1x_windows.go | 358 ++++++++++++++++------------- tables/dot1x/dot1x_windows_test.go | 4 +- 3 files changed, 208 insertions(+), 156 deletions(-) diff --git a/go.mod b/go.mod index 7fb4b95..ef4ff21 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.17.0 + golang.org/x/sys v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,5 +25,4 @@ require ( go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/sys v0.36.0 // indirect ) diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index dceaef3..98678dd 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -3,6 +3,7 @@ package dot1x import ( + "encoding/xml" "fmt" "strconv" "strings" @@ -89,7 +90,6 @@ var ( modWlanapi = syscall.NewLazyDLL("wlanapi.dll") procWlanOpenHandle = modWlanapi.NewProc("WlanOpenHandle") - procWlanCloseHandle = modWlanapi.NewProc("WlanCloseHandle") procWlanEnumInterfaces = modWlanapi.NewProc("WlanEnumInterfaces") procWlanQueryInterface = modWlanapi.NewProc("WlanQueryInterface") procWlanGetProfile = modWlanapi.NewProc("WlanGetProfile") @@ -97,8 +97,14 @@ var ( ) var ( - wlanOnce sync.Once + wlanOnce sync.Once + // wlanAvail reports whether wlanapi.dll loaded and a client handle opened. wlanAvail bool + // wlanHandle is a process-lifetime WLAN client handle opened once in + // initWlan and reused by every query. Like the darwin framework handle, it + // is intentionally never closed — the OS reclaims it at process exit, and + // reusing one handle avoids an open/close round-trip on every GetStatus. + wlanHandle uintptr ) func initWlan() { @@ -106,25 +112,44 @@ func initWlan() { return } for _, p := range []*syscall.LazyProc{ - procWlanOpenHandle, procWlanCloseHandle, - procWlanEnumInterfaces, procWlanQueryInterface, - procWlanGetProfile, procWlanFreeMemory, + procWlanOpenHandle, procWlanEnumInterfaces, + procWlanQueryInterface, procWlanGetProfile, procWlanFreeMemory, } { if err := p.Find(); err != nil { return } } + h, err := openWlanHandle() + if err != nil { + return + } + wlanHandle = h wlanAvail = true } -type windowsBackend struct{} +// ifaceInfo is the per-interface data captured from a single +// WlanEnumInterfaces call: its GUID (stable) and current state. +type ifaceInfo struct { + guid windowsGUID + state uint32 +} + +// windowsBackend reuses the process-lifetime WLAN handle and, on first use, +// snapshots all interfaces into ifaces so that querying several interfaces in +// one table generation enumerates only once instead of once per interface. +type windowsBackend struct { + handle uintptr + once sync.Once + ifaces map[string]ifaceInfo + enumErr error +} func newBackend() Dot1XBackend { wlanOnce.Do(initWlan) if !wlanAvail { return unavailableBackend{} } - return windowsBackend{} + return &windowsBackend{handle: wlanHandle} } type unavailableBackend struct{} @@ -149,49 +174,52 @@ func openWlanHandle() (uintptr, error) { return handle, nil } -func closeWlanHandle(handle uintptr) { - procWlanCloseHandle.Call(handle, 0) //nolint:errcheck -} - func freeWlanMemory(p uintptr) { procWlanFreeMemory.Call(p) //nolint:errcheck } -// enumerateWlanInterfaces returns the descriptions of all wireless interfaces. -func enumerateWlanInterfaces() []string { - handle, err := openWlanHandle() - if err != nil { - return nil - } - defer closeWlanHandle(handle) - +// enumerateWlanInterfaceInfos performs one WlanEnumInterfaces call and returns +// a description->info map plus the descriptions in enumeration order. +func enumerateWlanInterfaceInfos(handle uintptr) (map[string]ifaceInfo, []string, error) { var listPtr unsafe.Pointer ret, _, _ := procWlanEnumInterfaces.Call(handle, 0, uintptr(unsafe.Pointer(&listPtr))) if ret != 0 || listPtr == nil { - return nil + return nil, nil, fmt.Errorf("WlanEnumInterfaces failed: %w", syscall.Errno(ret)) } defer freeWlanMemory(uintptr(listPtr)) list := (*wlanInterfaceInfoList)(listPtr) - if list.NumberOfItems == 0 { - return nil - } + infos := make(map[string]ifaceInfo, list.NumberOfItems) + names := make([]string, 0, list.NumberOfItems) headerSize := unsafe.Sizeof(*list) itemSize := unsafe.Sizeof(wlanInterfaceInfo{}) - names := make([]string, 0, list.NumberOfItems) for i := uint32(0); i < list.NumberOfItems; i++ { offset := headerSize + uintptr(i)*itemSize info := (*wlanInterfaceInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(list)) + offset)) - names = append(names, utf16ToString(info.StrInterfaceDescription[:])) + desc := utf16ToString(info.StrInterfaceDescription[:]) + if _, dup := infos[desc]; !dup { + names = append(names, desc) + } + infos[desc] = ifaceInfo{guid: info.InterfaceGuid, state: info.IsState} } - return names + return infos, names, nil } -func defaultInterfaces() []string { +// enumerateWlanInterfaces returns the descriptions of all wireless interfaces. +func enumerateWlanInterfaces() []string { + wlanOnce.Do(initWlan) if !wlanAvail { return nil } + _, names, err := enumerateWlanInterfaceInfos(wlanHandle) + if err != nil { + return nil + } + return names +} + +func defaultInterfaces() []string { ifaces := enumerateWlanInterfaces() if len(ifaces) == 0 { return nil @@ -199,18 +227,28 @@ func defaultInterfaces() []string { return ifaces } -func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { - handle, err := openWlanHandle() - if err != nil { - return Dot1XStatus{Interface: ifname}, - fmt.Errorf("%w: %v", ErrBackendUnavailable, err) - } - defer closeWlanHandle(handle) +// snapshot lazily enumerates interfaces once per backend instance (i.e. once +// per table generation) and caches the result. +func (b *windowsBackend) snapshot() (map[string]ifaceInfo, error) { + b.once.Do(func() { + b.ifaces, _, b.enumErr = enumerateWlanInterfaceInfos(b.handle) + }) + return b.ifaces, b.enumErr +} - guid, ifState, err := findWlanInterface(handle, ifname) +func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { + infos, err := b.snapshot() if err != nil { - return Dot1XStatus{Interface: ifname}, err + // Enumeration failing is systemic (affects every interface), so report + // it as backend-unavailable rather than a per-interface miss. + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: %v", ErrBackendUnavailable, err) } + info, ok := infos[ifname] + if !ok { + return Dot1XStatus{Interface: ifname}, fmt.Errorf("wireless interface %q not found", ifname) + } + guid := info.guid + ifState := info.state s := Dot1XStatus{ Interface: ifname, @@ -234,8 +272,8 @@ func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { var dataPtr unsafe.Pointer var opcodeValueType uint32 ret, _, _ := procWlanQueryInterface.Call( - handle, - uintptr(unsafe.Pointer(guid)), + b.handle, + uintptr(unsafe.Pointer(&guid)), uintptr(wlanIntfOpcodeCurrentConnection), 0, uintptr(unsafe.Pointer(&dataSize)), @@ -263,17 +301,17 @@ func (windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { profileName := utf16ToString(conn.ProfileName[:]) if profileName != "" { - if xml, err := getWlanProfileXML(handle, guid, profileName); err == nil { - if eapType := extractEAPTypeFromXML(xml); eapType > 0 { + if xmlStr, err := getWlanProfileXML(b.handle, &guid, profileName); err == nil { + if eapType := extractEAPTypeFromXML(xmlStr); eapType > 0 { s.EAPType = eapType } - if mode := extractAuthModeFromXML(xml); mode >= 0 { + if mode := extractAuthModeFromXML(xmlStr); mode >= 0 { s.Mode = mode } - if innerType := extractInnerEAPTypeFromXML(xml); innerType > 0 { + if innerType := extractInnerEAPTypeFromXML(xmlStr); innerType > 0 { s.InnerEAPType = innerType } - if sha1 := extractTrustedRootCAFromXML(xml); sha1 != "" { + if sha1 := extractTrustedRootCAFromXML(xmlStr); sha1 != "" { s.TLSServerCertificateSHA1 = sha1 } } @@ -306,50 +344,124 @@ func getWlanProfileXML(handle uintptr, guid *windowsGUID, profileName string) (s return utf16PtrToString(xmlPtr), nil } -// extractEAPTypeFromXML parses the EAP method type from a WLAN profile XML. -// The EAP type lives at EapMethod > Type inside the EAPConfig section. -func extractEAPTypeFromXML(xml string) int { - methodIdx := strings.Index(xml, "") - if methodIdx < 0 { - return -1 +// readCharData consumes tokens until the end of the element the decoder is +// currently positioned inside, returning the concatenated direct character +// data (text in nested child elements is ignored). It must be called +// immediately after reading a StartElement. +func readCharData(dec *xml.Decoder) (string, bool) { + var sb strings.Builder + depth := 0 + for { + tok, err := dec.Token() + if err != nil { + return "", false + } + switch t := tok.(type) { + case xml.StartElement: + depth++ + case xml.CharData: + if depth == 0 { + sb.Write(t) + } + case xml.EndElement: + if depth == 0 { + return sb.String(), true + } + depth-- + } } - sub := xml[methodIdx:] - typeStart := strings.Index(sub, "') - if gt < 0 { - return -1 + v, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0, false } - sub = sub[gt+1:] - lt := strings.IndexByte(sub, '<') - if lt < 0 { - return -1 + return v, true +} + +// firstElementText returns the character data of the first element whose local +// name matches local (namespace prefix / xmlns attributes are ignored). +func firstElementText(xmlStr, local string) (string, bool) { + dec := xml.NewDecoder(strings.NewReader(xmlStr)) + for { + tok, err := dec.Token() + if err != nil { + return "", false + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local { + return readCharData(dec) + } } - v, err := strconv.Atoi(strings.TrimSpace(sub[:lt])) - if err != nil { - return -1 +} + +// eapMethodTypeFromXML returns the integer nested inside the +// occurrence-th element (1 = outer EAP method, 2 = inner method +// used by tunneled auth such as PEAP/EAP-TTLS). Matching is by local element +// name, so namespace prefixes and attributes on the elements are tolerated. +// Returns -1 when the requested EapMethod or its Type is absent or malformed. +func eapMethodTypeFromXML(xmlStr string, occurrence int) int { + dec := xml.NewDecoder(strings.NewReader(xmlStr)) + methodCount := 0 + depth := 0 + inTarget := false + for { + tok, err := dec.Token() + if err != nil { + return -1 + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "EapMethod" && !inTarget { + methodCount++ + if methodCount == occurrence { + inTarget = true + depth = 1 + } + continue + } + if inTarget { + depth++ + if t.Name.Local == "Type" { + if v, ok := readIntCharData(dec); ok { + return v + } + return -1 + } + } + case xml.EndElement: + if inTarget { + depth-- + if depth == 0 { + return -1 // left the target EapMethod without a Type + } + } + } } - return v } +// extractEAPTypeFromXML parses the outer EAP method type from a WLAN profile +// XML (the inside the first ). +func extractEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 1) } + +// extractInnerEAPTypeFromXML parses the inner EAP method type used by tunneled +// methods like PEAP (25) or EAP-TTLS (21): the inside the second +// . +func extractInnerEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 2) } + // extractAuthModeFromXML parses from the OneX section of a WLAN // profile XML and maps it to an EAPOLControlMode value. -func extractAuthModeFromXML(xml string) int { - const open = "" - const close = "" - start := strings.Index(xml, open) - if start < 0 { +func extractAuthModeFromXML(xmlStr string) int { + s, ok := firstElementText(xmlStr, "authMode") + if !ok { return -1 } - sub := xml[start+len(open):] - end := strings.Index(sub, close) - if end < 0 { - return -1 - } - switch strings.TrimSpace(sub[:end]) { + switch strings.TrimSpace(s) { case "machine": return 3 // System case "user": @@ -363,65 +475,30 @@ func extractAuthModeFromXML(xml string) int { } } -// extractInnerEAPTypeFromXML parses the inner EAP method type used by -// tunneled methods like PEAP (25) or EAP-TTLS (21). The inner method -// appears as a second inside the outer EAP config. -func extractInnerEAPTypeFromXML(xml string) int { - first := strings.Index(xml, "") - if first < 0 { - return -1 - } - rest := xml[first+len(""):] - second := strings.Index(rest, "") - if second < 0 { - return -1 - } - sub := rest[second:] - typeStart := strings.Index(sub, "') - if gt < 0 { - return -1 - } - sub = sub[gt+1:] - lt := strings.IndexByte(sub, '<') - if lt < 0 { - return -1 - } - v, err := strconv.Atoi(strings.TrimSpace(sub[:lt])) - if err != nil { - return -1 - } - return v -} - // extractTrustedRootCAFromXML parses hex hashes from the // ServerValidation section of a WLAN profile XML. These are the SHA-1 // fingerprints of the CA certificates configured for RADIUS server validation. -// Multiple hashes are comma-separated with colon-delimited hex pairs. -func extractTrustedRootCAFromXML(xml string) string { +// All whitespace (spaces, newlines, tabs from pretty-printed XML) is stripped +// before the length check. Multiple hashes are comma-separated with +// colon-delimited hex pairs. +func extractTrustedRootCAFromXML(xmlStr string) string { + dec := xml.NewDecoder(strings.NewReader(xmlStr)) var hashes []string - remaining := xml for { - const open = "" - const close = "" - start := strings.Index(remaining, open) - if start < 0 { + tok, err := dec.Token() + if err != nil { break } - sub := remaining[start+len(open):] - end := strings.Index(sub, close) - if end < 0 { - break - } - hex := strings.Join(strings.Fields(strings.TrimSpace(sub[:end])), "") - if len(hex) == 40 { - hashes = append(hashes, formatSHA1Hex(hex)) + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "TrustedRootCA" { + text, ok := readCharData(dec) + if !ok { + continue + } + hex := strings.Join(strings.Fields(text), "") + if len(hex) == 40 { + hashes = append(hashes, formatSHA1Hex(hex)) + } } - remaining = sub[end+len(close):] } return strings.Join(hashes, ",") } @@ -448,33 +525,6 @@ func utf16PtrToString(p *uint16) string { return windows.UTF16PtrToString(p) } -func findWlanInterface(handle uintptr, name string) (*windowsGUID, uint32, error) { - var listPtr unsafe.Pointer - ret, _, _ := procWlanEnumInterfaces.Call(handle, 0, uintptr(unsafe.Pointer(&listPtr))) - if ret != 0 || listPtr == nil { - return nil, 0, fmt.Errorf("WlanEnumInterfaces failed: %w", syscall.Errno(ret)) - } - defer freeWlanMemory(uintptr(listPtr)) - - list := (*wlanInterfaceInfoList)(listPtr) - if list.NumberOfItems == 0 { - return nil, 0, fmt.Errorf("no wireless interfaces found") - } - - headerSize := unsafe.Sizeof(*list) - itemSize := unsafe.Sizeof(wlanInterfaceInfo{}) - for i := uint32(0); i < list.NumberOfItems; i++ { - offset := headerSize + uintptr(i)*itemSize - info := (*wlanInterfaceInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(list)) + offset)) - desc := utf16ToString(info.StrInterfaceDescription[:]) - if desc == name { - guid := info.InterfaceGuid - return &guid, info.IsState, nil - } - } - return nil, 0, fmt.Errorf("wireless interface %q not found", name) -} - // mapWlanState maps WLAN_INTERFACE_STATE to (EAPOLControlState, SupplicantState). func mapWlanState(state uint32) (int, int) { switch state { diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index b73e954..819f41f 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -105,8 +105,10 @@ func TestExtractEAPTypeFromXML(t *testing.T) { {"empty", "", -1}, {"EapMethod but no Type", ``, -1}, {"malformed Type value", `abc`, -1}, - {"namespace prefixed (not matched)", `13`, -1}, + {"namespace prefixed Type (matched by local name)", `13`, 13}, {"Type with attributes", `21`, 21}, + {"EapMethod with attributes", `21`, 21}, + {"pretty-printed / indented", "\n\t\n\t\t25\n\t\n", 25}, } for _, tc := range tests { From 207f740440c235a319008333ef1552fda2fbe20d Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 16:03:50 -0400 Subject: [PATCH 21/36] dot1x(windows): skip on query failure, gate live tests, rebind loop var Address Copilot review on the Windows backend/tests: - GetStatus: when WlanQueryInterface fails for a connected/authenticating interface, return a per-interface error (including syscall.Errno) so generateRows skips it, instead of emitting a misleading "successful" row with missing MAC/EAP/profile data. - Gate the live WLAN smoke tests behind DOT1X_LIVE_TESTS=1 so they're opt-in and don't flake in CI; mock-based tests remain the default. - Add explicit `tc := tc` rebinds in the table-driven test loops. Go 1.22+ already scopes the range var per-iteration, but this silences the reviewer's recurring loop-capture flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_windows.go | 11 +++++++++-- tables/dot1x/dot1x_windows_test.go | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 98678dd..b25526e 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -280,8 +280,15 @@ func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { uintptr(unsafe.Pointer(&dataPtr)), uintptr(unsafe.Pointer(&opcodeValueType)), ) - if ret != 0 || dataPtr == nil { - return s, nil + if ret != 0 { + // The interface reports connected/authenticating, so a failed + // current-connection query would leave a misleading "successful" row + // missing MAC/EAP/profile data. Return a per-interface error so + // generateRows skips it rather than emitting a partial row. + return s, fmt.Errorf("WlanQueryInterface(current_connection) failed for %q: %w", ifname, syscall.Errno(ret)) + } + if dataPtr == nil { + return s, fmt.Errorf("WlanQueryInterface(current_connection) returned no data for %q", ifname) } defer freeWlanMemory(uintptr(dataPtr)) diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index 819f41f..e56b2d9 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -4,6 +4,7 @@ package dot1x import ( "errors" + "os" "testing" "github.com/osquery/osquery-go/plugin/table" @@ -112,6 +113,7 @@ func TestExtractEAPTypeFromXML(t *testing.T) { } for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() got := extractEAPTypeFromXML(tc.xml) @@ -139,6 +141,7 @@ func TestExtractAuthModeFromXML(t *testing.T) { } for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() got := extractAuthModeFromXML(tc.xml) @@ -163,6 +166,7 @@ func TestExtractInnerEAPTypeFromXML(t *testing.T) { } for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() got := extractInnerEAPTypeFromXML(tc.xml) @@ -220,6 +224,7 @@ func TestExtractTrustedRootCAFromXML(t *testing.T) { } for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() got := extractTrustedRootCAFromXML(tc.xml) @@ -262,6 +267,7 @@ func TestMapWlanState(t *testing.T) { } for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() gotState, gotSupplicant := mapWlanState(tc.input) @@ -309,6 +315,7 @@ func TestUtf16ToString(t *testing.T) { } for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() got := utf16ToString(tc.input) @@ -493,7 +500,19 @@ func TestWindowsMockBackendUnavailable(t *testing.T) { // --- Live backend smoke test --- +// requireLiveTests gates the live WLAN tests, which depend on host networking +// and the WLAN service and are therefore non-deterministic in CI. They run +// only when DOT1X_LIVE_TESTS is set, keeping the mock-based tests as the +// default coverage. +func requireLiveTests(t *testing.T) { + t.Helper() + if os.Getenv("DOT1X_LIVE_TESTS") == "" { + t.Skip("set DOT1X_LIVE_TESTS=1 to run live WLAN backend tests") + } +} + func TestWindowsLiveBackend(t *testing.T) { + requireLiveTests(t) backend := newBackend() if _, ok := backend.(unavailableBackend); ok { @@ -519,6 +538,7 @@ func TestWindowsLiveBackend(t *testing.T) { } func TestWindowsLiveBackendBogusInterface(t *testing.T) { + requireLiveTests(t) backend := newBackend() if _, ok := backend.(unavailableBackend); ok { @@ -536,6 +556,7 @@ func TestWindowsLiveBackendBogusInterface(t *testing.T) { // --- Real profile XML extraction (end-to-end on live system) --- func TestWindowsLiveProfileXMLExtraction(t *testing.T) { + requireLiveTests(t) backend := newBackend() if _, ok := backend.(unavailableBackend); ok { From ed454706a9d6404c76cecb4a696d270a5d4a88c8 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 16:11:58 -0400 Subject: [PATCH 22/36] Address Copilot review: portable mktemp, dup interface keys, bounded cert alloc - bazel_to_builddir.sh: validate copy_bazel_output gets exactly 2 args (clear usage error under set -u) and use a portable mktemp template ("$TMPDIR"/prefix.XXXXXX) since a bare `mktemp` isn't portable to BSD. - dot1x(windows): disambiguate adapters that report identical interface descriptions by suffixing the GUID (uniqueIfaceKey), so the later one is no longer overwritten in the snapshot map / dropped from results. Added a unit test. - dot1x(darwin): in pack_cert_chain, apply the same len > 0xffffffff skip in the size-calculation pass that the write pass uses, so the allocation is bounded and matches exactly what gets written (no oversized malloc). Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_darwin.go | 8 ++++++-- tables/dot1x/dot1x_windows.go | 21 +++++++++++++++++---- tables/dot1x/dot1x_windows_test.go | 25 +++++++++++++++++++++++++ tools/bazel_to_builddir.sh | 8 +++++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/tables/dot1x/dot1x_darwin.go b/tables/dot1x/dot1x_darwin.go index d88ba59..71721a0 100644 --- a/tables/dot1x/dot1x_darwin.go +++ b/tables/dot1x/dot1x_darwin.go @@ -132,12 +132,16 @@ static uint8_t* pack_cert_chain(CFArrayRef certs, CFIndex* out_len) { CFIndex count = CFArrayGetCount(certs); if (count == 0) return NULL; - // First pass: calculate total buffer size. + // First pass: calculate total buffer size. Apply the same oversized-entry + // skip the write pass uses below, so the allocation stays bounded and + // matches exactly what is written (no oversized malloc, no slack bytes). CFIndex total = 0; for (CFIndex i = 0; i < count; i++) { CFTypeRef item = CFArrayGetValueAtIndex(certs, i); if (!item || CFGetTypeID(item) != CFDataGetTypeID()) continue; - total += 4 + CFDataGetLength((CFDataRef)item); + CFIndex len = CFDataGetLength((CFDataRef)item); + if (len > 0xffffffff) continue; // exceeds 4-byte packed format + total += 4 + len; } if (total == 0) return NULL; diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index b25526e..82a961f 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -198,14 +198,27 @@ func enumerateWlanInterfaceInfos(handle uintptr) (map[string]ifaceInfo, []string offset := headerSize + uintptr(i)*itemSize info := (*wlanInterfaceInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(list)) + offset)) desc := utf16ToString(info.StrInterfaceDescription[:]) - if _, dup := infos[desc]; !dup { - names = append(names, desc) - } - infos[desc] = ifaceInfo{guid: info.InterfaceGuid, state: info.IsState} + key := uniqueIfaceKey(infos, desc, info.InterfaceGuid) + infos[key] = ifaceInfo{guid: info.InterfaceGuid, state: info.IsState} + names = append(names, key) } return infos, names, nil } +// uniqueIfaceKey returns desc, or a GUID-disambiguated key when desc already +// exists in seen. Windows can report two adapters with identical interface +// descriptions (e.g. two identical USB Wi-Fi dongles); without this the later +// one would overwrite the earlier in the snapshot map, dropping it from +// results and making it unqueryable. Suffixing the stable GUID keeps each +// physical adapter individually enumerable and targetable via +// WHERE interface = '...'. +func uniqueIfaceKey(seen map[string]ifaceInfo, desc string, guid windowsGUID) string { + if _, dup := seen[desc]; !dup { + return desc + } + return desc + " " + guid.String() +} + // enumerateWlanInterfaces returns the descriptions of all wireless interfaces. func enumerateWlanInterfaces() []string { wlanOnce.Do(initWlan) diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index e56b2d9..f76378c 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -339,6 +339,31 @@ func TestUtf16PtrToString(t *testing.T) { }) } +// --- duplicate interface description handling --- + +func TestUniqueIfaceKey(t *testing.T) { + t.Parallel() + + g1 := windowsGUID{Data1: 0x11111111} + g2 := windowsGUID{Data1: 0x22222222} + + infos := map[string]ifaceInfo{} + + // First adapter keeps its plain description. + k1 := uniqueIfaceKey(infos, "Intel Wi-Fi 6", g1) + assert.Equal(t, "Intel Wi-Fi 6", k1) + infos[k1] = ifaceInfo{guid: g1} + + // A second adapter with the same description is disambiguated by GUID, so + // it is not dropped and stays individually queryable. + k2 := uniqueIfaceKey(infos, "Intel Wi-Fi 6", g2) + assert.Equal(t, "Intel Wi-Fi 6 "+g2.String(), k2) + assert.NotEqual(t, k1, k2) + + // A distinct description is untouched. + assert.Equal(t, "Realtek Wi-Fi", uniqueIfaceKey(infos, "Realtek Wi-Fi", g2)) +} + // --- unavailableBackend --- func TestUnavailableBackend(t *testing.T) { diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index eef65c4..928159e 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -13,8 +13,14 @@ APP_NAME="macadmins_extension" # (or an unexpected multi-file output) are handled safely rather than being # silently word-split. copy_bazel_output() { + if [ "$#" -ne 2 ]; then + echo "usage: copy_bazel_output TARGET DEST" >&2 + return 2 + fi local target="$1" dest="$2" file lines err rc - err="$(mktemp)" + # Template form works on both GNU and BSD/macOS mktemp (a bare `mktemp` + # with no template is not portable to BSD). + err="$(mktemp "${TMPDIR:-/tmp}/bazel_to_builddir.XXXXXX")" # Capture stderr and the exit code so an actual bazel/cquery failure # (missing bazel, bad workspace, query error) is reported distinctly from # a successful-but-empty result. The `&& rc=0 || rc=$?` idiom records the From 5023badf85ee57f38f2c41322aeafa51220520c5 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 16:21:39 -0400 Subject: [PATCH 23/36] Address Copilot review: cp -- , validate TrustedRootCA hex, guard formatSHA1Hex - bazel_to_builddir.sh: use `cp -- "$file" "$dest"` so an output path beginning with `-` can't be parsed as a cp option. - dot1x(windows): require content to be valid hex (new isHexString) before emitting it as a SHA-1 fingerprint, so malformed profiles don't surface bogus thumbprints. - dot1x(windows): formatSHA1Hex returns "" on odd-length input instead of panicking on the trailing hex[i:i+2] slice. Added tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_windows.go | 23 ++++++++++++++++++++--- tables/dot1x/dot1x_windows_test.go | 11 +++++++++++ tools/bazel_to_builddir.sh | 4 +++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 82a961f..a78a418 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -515,7 +515,9 @@ func extractTrustedRootCAFromXML(xmlStr string) string { continue } hex := strings.Join(strings.Fields(text), "") - if len(hex) == 40 { + // A SHA-1 thumbprint is exactly 40 hex chars; require valid hex so + // malformed profile content isn't emitted as a bogus fingerprint. + if len(hex) == 40 && isHexString(hex) { hashes = append(hashes, formatSHA1Hex(hex)) } } @@ -523,9 +525,24 @@ func extractTrustedRootCAFromXML(xmlStr string) string { return strings.Join(hashes, ",") } -// formatSHA1Hex converts a 40-char hex string to colon-separated pairs -// (e.g. "aabb..." -> "aa:bb:..."). +// isHexString reports whether s consists solely of hexadecimal digits. +func isHexString(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// formatSHA1Hex converts an even-length hex string to colon-separated pairs +// (e.g. "aabb..." -> "aa:bb:..."). Returns "" for odd-length input rather than +// panicking on the trailing 2-char slice. func formatSHA1Hex(hex string) string { + if len(hex)%2 != 0 { + return "" + } hex = strings.ToLower(hex) var buf strings.Builder buf.Grow(59) diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index f76378c..14d6f36 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -216,6 +216,11 @@ func TestExtractTrustedRootCAFromXML(t *testing.T) { ` `, "", }, + { + "40 non-hex chars rejected", + `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`, + "", + }, { "newlines and tabs in hex (pretty-printed XML)", "\n\t\t\t\t23 a6 b1 0a ff bb cc dd ee 11\n\t\t\t\t22 33 44 55 66 77 88 99 aa bb\n\t\t\t", @@ -242,6 +247,12 @@ func TestFormatSHA1Hex(t *testing.T) { assert.Equal(t, "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", formatSHA1Hex("AABBCCDDEEFF00112233445566778899AABBCCDD")) + + // Odd-length / short input must not panic on the trailing 2-char slice. + assert.Equal(t, "", formatSHA1Hex("")) + assert.Equal(t, "", formatSHA1Hex("a")) + assert.Equal(t, "", formatSHA1Hex("abc")) + assert.Equal(t, "aa:bb", formatSHA1Hex("aabb")) } // --- mapWlanState tests --- diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index 928159e..8f9cc01 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -45,7 +45,9 @@ copy_bazel_output() { printf '%s\n' "$file" >&2 return 1 fi - cp "$file" "$dest" + # `--` guards against an output path that begins with `-` being parsed as + # a cp option. + cp -- "$file" "$dest" } # Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). From c3afda4b17cc8ffca01c65f0f20e2ce0bf4ea7c5 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 16:29:41 -0400 Subject: [PATCH 24/36] Address Copilot review: portable cp option-guard, well-formed namespace test XML - bazel_to_builddir.sh: replace `cp -- ` (not universally honored on BSD) with a POSIX `case` that prefixes `./` to a source path beginning with `-`, portable across GNU and BSD/macOS cp. - dot1x(windows) test: declare the xmlns:eapCommon prefix in the local-name-matching test case so it uses well-formed XML instead of relying on the decoder tolerating an unbound prefix. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_windows_test.go | 2 +- tools/bazel_to_builddir.sh | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index 14d6f36..9ad57ae 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -106,7 +106,7 @@ func TestExtractEAPTypeFromXML(t *testing.T) { {"empty", "", -1}, {"EapMethod but no Type", ``, -1}, {"malformed Type value", `abc`, -1}, - {"namespace prefixed Type (matched by local name)", `13`, 13}, + {"namespace prefixed Type (matched by local name)", `13`, 13}, {"Type with attributes", `21`, 21}, {"EapMethod with attributes", `21`, 21}, {"pretty-printed / indented", "\n\t\n\t\t25\n\t\n", 25}, diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index 8f9cc01..b3650a2 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -45,9 +45,14 @@ copy_bazel_output() { printf '%s\n' "$file" >&2 return 1 fi - # `--` guards against an output path that begins with `-` being parsed as - # a cp option. - cp -- "$file" "$dest" + # Guard against an output path that begins with `-` being parsed as a cp + # option by prefixing `./`. This is portable across GNU and BSD/macOS cp, + # whereas `--` end-of-options is not universally honored on BSD. dest is + # always a build/ path, so only the source needs guarding. + case "$file" in + -*) file="./$file" ;; + esac + cp "$file" "$dest" } # Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). From 68c64784a2e5fd269b34f79c9dbde875ab064eb0 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 16:41:37 -0400 Subject: [PATCH 25/36] Address Copilot review: empty-vs-nil defaults, dlerror detail, robust make test - interfacesToQuery / dot1x(windows) defaultInterfaces: treat a non-nil defaultInterfaces() result as authoritative even when empty. A Windows host with no WLAN adapters now returns an empty slice (query nothing) instead of falling through to the macOS-style en0-en9 probe list; only a nil result (defaults unknown) uses that fallback. Updated the shared test. - dot1x(darwin): capture dlerror() in the C loader and include the dlopen/dlsym failure reason in the ErrBackendUnavailable message, so framework/symbol load failures are diagnosable in the field. - Makefile: pass the queried test targets via --target_pattern_file (with a temp file cleaned up by a trap) instead of expanding them onto the command line, so a growing test set can't hit argv length limits. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 10 ++++++++-- tables/dot1x/dot1x.go | 6 +++++- tables/dot1x/dot1x_darwin.go | 24 ++++++++++++++++++++++-- tables/dot1x/dot1x_test.go | 11 +++++++---- tables/dot1x/dot1x_windows.go | 10 +++++----- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index e467ab4..cc30792 100644 --- a/Makefile +++ b/Makefile @@ -55,8 +55,14 @@ test: # cgo=True in root BUILD.bazel) and fail on Linux CI where no darwin # C++ toolchain exists. # Matching all *_test kinds (not just go_test) keeps non-Go tests - # (sh_test, py_test, etc.) in scope if any are added later. - targets=$$(bazel query 'kind(".*_test", //...)') && bazel test --test_output=errors $$targets + # (sh_test, py_test, etc.) in scope if any are added later. The target + # list is passed via --target_pattern_file rather than expanded onto the + # command line, so a growing test set can't hit argv length limits. + @set -e; \ + targets="$$(mktemp "$${TMPDIR:-/tmp}/dot1x-test-targets.XXXXXX")"; \ + trap 'rm -f "$$targets"' EXIT; \ + bazel query 'kind(".*_test", //...)' > "$$targets"; \ + bazel test --test_output=errors --target_pattern_file="$$targets" build: .pre-build ifeq ($(shell uname),Darwin) diff --git a/tables/dot1x/dot1x.go b/tables/dot1x/dot1x.go index 83b0bfe..67d9d37 100644 --- a/tables/dot1x/dot1x.go +++ b/tables/dot1x/dot1x.go @@ -184,7 +184,11 @@ func interfacesToQuery(queryContext table.QueryContext) []string { } } - if ifaces := defaultInterfaces(); len(ifaces) > 0 { + // A non-nil result from defaultInterfaces() is authoritative, even when + // empty: e.g. a Windows host with no WLAN adapters returns an empty slice + // (query nothing) rather than falling through to the macOS-style en0-en9 + // probe list. Only a nil result (defaults unknown) uses that fallback. + if ifaces := defaultInterfaces(); ifaces != nil { return ifaces } fallback := make([]string, 10) diff --git a/tables/dot1x/dot1x_darwin.go b/tables/dot1x/dot1x_darwin.go index 71721a0..3358a6d 100644 --- a/tables/dot1x/dot1x_darwin.go +++ b/tables/dot1x/dot1x_darwin.go @@ -5,6 +5,7 @@ package dot1x /* #include #include +#include #include #include #include @@ -21,20 +22,35 @@ typedef int (*EAPOLControlCopyStateAndStatusFn)(const char*, uint32_t*, CFDictio static EAPOLControlCopyStateAndStatusFn copy_state_fn = NULL; +// load_error holds the dlopen/dlsym failure reason (from dlerror) so the Go +// layer can surface it for diagnosis; empty when load succeeded. +static char load_error[256] = {0}; + static int load_dot1x(void) { if (copy_state_fn) return 1; // Handle intentionally held for process lifetime (sync.Once); the // framework must stay loaded for copy_state_fn to remain valid. void* h = dlopen("/System/Library/PrivateFrameworks/EAP8021X.framework/EAP8021X", RTLD_LAZY); - if (!h) return 0; + if (!h) { + const char* e = dlerror(); + snprintf(load_error, sizeof(load_error), "dlopen: %s", e ? e : "unknown error"); + return 0; + } copy_state_fn = (EAPOLControlCopyStateAndStatusFn)dlsym(h, "EAPOLControlCopyStateAndStatus"); if (!copy_state_fn) { + const char* e = dlerror(); + snprintf(load_error, sizeof(load_error), "dlsym: %s", e ? e : "unknown error"); dlclose(h); return 0; } return 1; } +// dot1x_load_error returns the captured load failure reason, or NULL if none. +static const char* dot1x_load_error(void) { + return load_error[0] ? load_error : NULL; +} + // cfstring_go creates a Go-owned copy of a CFString as a malloc'd C string. static char* cfstring_go(CFStringRef s) { if (!s) return NULL; @@ -432,7 +448,11 @@ func (productionBackend) GetStatus(ifname string) (Dot1XStatus, error) { if ret != 0 { if ret == -1 { - return s, fmt.Errorf("%w: could not load EAPOLControlCopyStateAndStatus for %s", ErrBackendUnavailable, ifname) + reason := "unknown error" + if cerr := C.dot1x_load_error(); cerr != nil { + reason = C.GoString(cerr) + } + return s, fmt.Errorf("%w: could not load EAPOLControlCopyStateAndStatus for %s: %s", ErrBackendUnavailable, ifname, reason) } return s, fmt.Errorf("EAPOLControlCopyStateAndStatus returned %d for %s", int(ret), ifname) } diff --git a/tables/dot1x/dot1x_test.go b/tables/dot1x/dot1x_test.go index df8c909..0aefb1c 100644 --- a/tables/dot1x/dot1x_test.go +++ b/tables/dot1x/dot1x_test.go @@ -70,8 +70,10 @@ func TestInterfacesToQuery(t *testing.T) { t.Parallel() qc := table.QueryContext{} ifaces := interfacesToQuery(qc) - assert.NotEmpty(t, ifaces) - if di := defaultInterfaces(); len(di) > 0 { + // A non-nil platform default (incl. an empty slice, e.g. Windows with + // no WLAN adapters) is authoritative; only a nil default falls back to + // the generic en0-en9 probe list. + if di := defaultInterfaces(); di != nil { assert.Equal(t, di, ifaces) } else { assert.Len(t, ifaces, 10) @@ -106,8 +108,9 @@ func TestInterfacesToQuery(t *testing.T) { }, }, } - ifaces := interfacesToQuery(qc) - assert.NotEmpty(t, ifaces) + // LIKE is not exact-match, so it falls back to the same platform + // defaults an unconstrained query would use. + assert.Equal(t, interfacesToQuery(table.QueryContext{}), interfacesToQuery(qc)) }) t.Run("duplicate constraints deduplicated", func(t *testing.T) { diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index a78a418..bf42e12 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -233,11 +233,11 @@ func enumerateWlanInterfaces() []string { } func defaultInterfaces() []string { - ifaces := enumerateWlanInterfaces() - if len(ifaces) == 0 { - return nil - } - return ifaces + // Return enumerateWlanInterfaces' result as-is so the nil/empty distinction + // is preserved: nil means WLAN is unavailable or enumeration failed + // (defaults unknown -> caller's generic fallback), while a non-nil empty + // slice means "successfully enumerated, no wireless adapters" (query none). + return enumerateWlanInterfaces() } // snapshot lazily enumerates interfaces once per backend instance (i.e. once From fcb3142bd975742fa17dae205954c72cd379ec11 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 16:53:22 -0400 Subject: [PATCH 26/36] dot1x(windows): harden syscall error paths; move WLAN XML parsing to shared file Address Copilot review on the Windows backend: - GetStatus: bounds-check dataSize against sizeof(wlanConnectionAttributes) before dereferencing the WlanQueryInterface buffer, returning a per-interface error on a short buffer instead of risking an OOB read. - getWlanProfileXML / enumerateWlanInterfaceInfos: split the ret != 0 and nil-pointer cases so a "succeeded but returned no data" result no longer surfaces a misleading "errno 0" message. - Move the pure-Go WLAN profile XML parsing (eapMethod/EAPType/authMode/ TrustedRootCA extractors, isHexString, formatSHA1Hex, xml token helpers) and their tests out of the //go:build windows files into build-tag-free dot1x_wlanprofile.go / _test.go, so the parsing logic compiles, runs, and is coverage-counted on every platform (it now executes on the Linux CI / darwin test runs, not just Windows). Registered both files in BUILD.bazel. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/BUILD.bazel | 2 + tables/dot1x/dot1x_windows.go | 210 ++------------------- tables/dot1x/dot1x_windows_test.go | 243 ------------------------ tables/dot1x/dot1x_wlanprofile.go | 202 ++++++++++++++++++++ tables/dot1x/dot1x_wlanprofile_test.go | 252 +++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 439 deletions(-) create mode 100644 tables/dot1x/dot1x_wlanprofile.go create mode 100644 tables/dot1x/dot1x_wlanprofile_test.go diff --git a/tables/dot1x/BUILD.bazel b/tables/dot1x/BUILD.bazel index 57e5788..1f61ec9 100644 --- a/tables/dot1x/BUILD.bazel +++ b/tables/dot1x/BUILD.bazel @@ -6,6 +6,7 @@ go_library( "dot1x.go", "dot1x_darwin.go", "dot1x_other.go", + "dot1x_wlanprofile.go", "dot1x_windows.go", ], # cgo is only needed on darwin: dot1x_darwin.go (//go:build darwin) calls @@ -41,6 +42,7 @@ go_test( "dot1x_test.go", "dot1x_darwin_test.go", "dot1x_other_test.go", + "dot1x_wlanprofile_test.go", "dot1x_windows_test.go", ], embed = [":dot1x"], diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index bf42e12..c636ba6 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -3,10 +3,7 @@ package dot1x import ( - "encoding/xml" "fmt" - "strconv" - "strings" "sync" "syscall" "unsafe" @@ -183,9 +180,12 @@ func freeWlanMemory(p uintptr) { func enumerateWlanInterfaceInfos(handle uintptr) (map[string]ifaceInfo, []string, error) { var listPtr unsafe.Pointer ret, _, _ := procWlanEnumInterfaces.Call(handle, 0, uintptr(unsafe.Pointer(&listPtr))) - if ret != 0 || listPtr == nil { + if ret != 0 { return nil, nil, fmt.Errorf("WlanEnumInterfaces failed: %w", syscall.Errno(ret)) } + if listPtr == nil { + return nil, nil, fmt.Errorf("WlanEnumInterfaces succeeded but returned no interface list") + } defer freeWlanMemory(uintptr(listPtr)) list := (*wlanInterfaceInfoList)(listPtr) @@ -305,6 +305,12 @@ func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { } defer freeWlanMemory(uintptr(dataPtr)) + // Guard against a short buffer (version differences / unexpected value + // type / corrupt response) before dereferencing, to avoid an OOB read. + if want := unsafe.Sizeof(wlanConnectionAttributes{}); uintptr(dataSize) < want { + return s, fmt.Errorf("WlanQueryInterface(current_connection) returned %d bytes for %q, want >= %d", dataSize, ifname, want) + } + conn := (*wlanConnectionAttributes)(dataPtr) s.AuthenticatorMACAddress = macAddrString(conn.AssociationAttributes.Dot11Bssid[:]) @@ -357,204 +363,16 @@ func getWlanProfileXML(handle uintptr, guid *windowsGUID, profileName string) (s uintptr(unsafe.Pointer(&flags)), 0, ) - if ret != 0 || xmlPtr == nil { + if ret != 0 { return "", fmt.Errorf("WlanGetProfile failed: %w", syscall.Errno(ret)) } + if xmlPtr == nil { + return "", fmt.Errorf("WlanGetProfile succeeded but returned no profile XML") + } defer freeWlanMemory(uintptr(unsafe.Pointer(xmlPtr))) return utf16PtrToString(xmlPtr), nil } -// readCharData consumes tokens until the end of the element the decoder is -// currently positioned inside, returning the concatenated direct character -// data (text in nested child elements is ignored). It must be called -// immediately after reading a StartElement. -func readCharData(dec *xml.Decoder) (string, bool) { - var sb strings.Builder - depth := 0 - for { - tok, err := dec.Token() - if err != nil { - return "", false - } - switch t := tok.(type) { - case xml.StartElement: - depth++ - case xml.CharData: - if depth == 0 { - sb.Write(t) - } - case xml.EndElement: - if depth == 0 { - return sb.String(), true - } - depth-- - } - } -} - -// readIntCharData is readCharData parsed as a base-10 int. -func readIntCharData(dec *xml.Decoder) (int, bool) { - s, ok := readCharData(dec) - if !ok { - return 0, false - } - v, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil { - return 0, false - } - return v, true -} - -// firstElementText returns the character data of the first element whose local -// name matches local (namespace prefix / xmlns attributes are ignored). -func firstElementText(xmlStr, local string) (string, bool) { - dec := xml.NewDecoder(strings.NewReader(xmlStr)) - for { - tok, err := dec.Token() - if err != nil { - return "", false - } - if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local { - return readCharData(dec) - } - } -} - -// eapMethodTypeFromXML returns the integer nested inside the -// occurrence-th element (1 = outer EAP method, 2 = inner method -// used by tunneled auth such as PEAP/EAP-TTLS). Matching is by local element -// name, so namespace prefixes and attributes on the elements are tolerated. -// Returns -1 when the requested EapMethod or its Type is absent or malformed. -func eapMethodTypeFromXML(xmlStr string, occurrence int) int { - dec := xml.NewDecoder(strings.NewReader(xmlStr)) - methodCount := 0 - depth := 0 - inTarget := false - for { - tok, err := dec.Token() - if err != nil { - return -1 - } - switch t := tok.(type) { - case xml.StartElement: - if t.Name.Local == "EapMethod" && !inTarget { - methodCount++ - if methodCount == occurrence { - inTarget = true - depth = 1 - } - continue - } - if inTarget { - depth++ - if t.Name.Local == "Type" { - if v, ok := readIntCharData(dec); ok { - return v - } - return -1 - } - } - case xml.EndElement: - if inTarget { - depth-- - if depth == 0 { - return -1 // left the target EapMethod without a Type - } - } - } - } -} - -// extractEAPTypeFromXML parses the outer EAP method type from a WLAN profile -// XML (the inside the first ). -func extractEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 1) } - -// extractInnerEAPTypeFromXML parses the inner EAP method type used by tunneled -// methods like PEAP (25) or EAP-TTLS (21): the inside the second -// . -func extractInnerEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 2) } - -// extractAuthModeFromXML parses from the OneX section of a WLAN -// profile XML and maps it to an EAPOLControlMode value. -func extractAuthModeFromXML(xmlStr string) int { - s, ok := firstElementText(xmlStr, "authMode") - if !ok { - return -1 - } - switch strings.TrimSpace(s) { - case "machine": - return 3 // System - case "user": - return 1 // User - case "machineOrUser": - return 2 // LoginWindow - case "guest": - return 0 // None - default: - return -1 - } -} - -// extractTrustedRootCAFromXML parses hex hashes from the -// ServerValidation section of a WLAN profile XML. These are the SHA-1 -// fingerprints of the CA certificates configured for RADIUS server validation. -// All whitespace (spaces, newlines, tabs from pretty-printed XML) is stripped -// before the length check. Multiple hashes are comma-separated with -// colon-delimited hex pairs. -func extractTrustedRootCAFromXML(xmlStr string) string { - dec := xml.NewDecoder(strings.NewReader(xmlStr)) - var hashes []string - for { - tok, err := dec.Token() - if err != nil { - break - } - if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "TrustedRootCA" { - text, ok := readCharData(dec) - if !ok { - continue - } - hex := strings.Join(strings.Fields(text), "") - // A SHA-1 thumbprint is exactly 40 hex chars; require valid hex so - // malformed profile content isn't emitted as a bogus fingerprint. - if len(hex) == 40 && isHexString(hex) { - hashes = append(hashes, formatSHA1Hex(hex)) - } - } - } - return strings.Join(hashes, ",") -} - -// isHexString reports whether s consists solely of hexadecimal digits. -func isHexString(s string) bool { - for i := 0; i < len(s); i++ { - c := s[i] - if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { - return false - } - } - return true -} - -// formatSHA1Hex converts an even-length hex string to colon-separated pairs -// (e.g. "aabb..." -> "aa:bb:..."). Returns "" for odd-length input rather than -// panicking on the trailing 2-char slice. -func formatSHA1Hex(hex string) string { - if len(hex)%2 != 0 { - return "" - } - hex = strings.ToLower(hex) - var buf strings.Builder - buf.Grow(59) - for i := 0; i < len(hex); i += 2 { - if i > 0 { - buf.WriteByte(':') - } - buf.WriteString(hex[i : i+2]) - } - return buf.String() -} - func utf16PtrToString(p *uint16) string { if p == nil { return "" diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index 9ad57ae..11eebc6 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -12,249 +12,6 @@ import ( "github.com/stretchr/testify/require" ) -// --- XML extraction tests --- - -const sampleProfileXML = ` - - Campus - - - - WPA2 - AES - true - - - machine - - - - 13 - 0 - 0 - 0 - - - - 13 - - - true - - 23 a6 b1 0a be 8a 4a 37 72 11 e2 f4 2c 36 67 f1 36 e9 08 bf - - - - - - - - - -` - -const peapProfileXML = ` - - PEAPNetwork - - - - user - - - - 25 - - - - 25 - - - aa bb cc dd ee ff 00 11 22 33 44 55 66 77 88 99 aa bb cc dd - 11 22 33 44 55 66 77 88 99 00 aa bb cc dd ee ff 11 22 33 44 - - false - - 26 - - - 26 - - - - - - - - - - - -` - -func TestExtractEAPTypeFromXML(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - xml string - want int - }{ - {"EAP-TLS", sampleProfileXML, 13}, - {"PEAP", peapProfileXML, 25}, - {"no EapMethod", `open`, -1}, - {"empty", "", -1}, - {"EapMethod but no Type", ``, -1}, - {"malformed Type value", `abc`, -1}, - {"namespace prefixed Type (matched by local name)", `13`, 13}, - {"Type with attributes", `21`, 21}, - {"EapMethod with attributes", `21`, 21}, - {"pretty-printed / indented", "\n\t\n\t\t25\n\t\n", 25}, - } - - for _, tc := range tests { - tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := extractEAPTypeFromXML(tc.xml) - assert.Equal(t, tc.want, got) - }) - } -} - -func TestExtractAuthModeFromXML(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - xml string - want int - }{ - {"machine", sampleProfileXML, 3}, - {"user", peapProfileXML, 1}, - {"machineOrUser", `machineOrUser`, 2}, - {"guest", `guest`, 0}, - {"unknown value", `somethingElse`, -1}, - {"no authMode", ``, -1}, - {"empty", "", -1}, - {"whitespace around value", ` machine `, 3}, - } - - for _, tc := range tests { - tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := extractAuthModeFromXML(tc.xml) - assert.Equal(t, tc.want, got) - }) - } -} - -func TestExtractInnerEAPTypeFromXML(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - xml string - want int - }{ - {"PEAP with MSCHAPv2 inner", peapProfileXML, 26}, - {"EAP-TLS no inner", sampleProfileXML, -1}, - {"no EapMethod at all", ``, -1}, - {"single EapMethod only", `13`, -1}, - {"empty", "", -1}, - } - - for _, tc := range tests { - tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := extractInnerEAPTypeFromXML(tc.xml) - assert.Equal(t, tc.want, got) - }) - } -} - -func TestExtractTrustedRootCAFromXML(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - xml string - want string - }{ - { - "single CA with spaces", - sampleProfileXML, - "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", - }, - { - "multiple CAs", - peapProfileXML, - "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd," + - "11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff:11:22:33:44", - }, - { - "contiguous hex (no spaces)", - `aabbccddeeff00112233445566778899aabbccdd`, - "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", - }, - { - "uppercase hex", - `AABBCCDDEEFF00112233445566778899AABBCCDD`, - "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", - }, - {"no TrustedRootCA", ``, ""}, - {"empty", "", ""}, - { - "wrong length ignored", - `aabb`, - "", - }, - { - "whitespace only", - ` `, - "", - }, - { - "40 non-hex chars rejected", - `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`, - "", - }, - { - "newlines and tabs in hex (pretty-printed XML)", - "\n\t\t\t\t23 a6 b1 0a ff bb cc dd ee 11\n\t\t\t\t22 33 44 55 66 77 88 99 aa bb\n\t\t\t", - "23:a6:b1:0a:ff:bb:cc:dd:ee:11:22:33:44:55:66:77:88:99:aa:bb", - }, - } - - for _, tc := range tests { - tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := extractTrustedRootCAFromXML(tc.xml) - assert.Equal(t, tc.want, got) - }) - } -} - -func TestFormatSHA1Hex(t *testing.T) { - t.Parallel() - - assert.Equal(t, - "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", - formatSHA1Hex("aabbccddeeff00112233445566778899aabbccdd")) - assert.Equal(t, - "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", - formatSHA1Hex("AABBCCDDEEFF00112233445566778899AABBCCDD")) - - // Odd-length / short input must not panic on the trailing 2-char slice. - assert.Equal(t, "", formatSHA1Hex("")) - assert.Equal(t, "", formatSHA1Hex("a")) - assert.Equal(t, "", formatSHA1Hex("abc")) - assert.Equal(t, "aa:bb", formatSHA1Hex("aabb")) -} - // --- mapWlanState tests --- func TestMapWlanState(t *testing.T) { diff --git a/tables/dot1x/dot1x_wlanprofile.go b/tables/dot1x/dot1x_wlanprofile.go new file mode 100644 index 0000000..7f82de4 --- /dev/null +++ b/tables/dot1x/dot1x_wlanprofile.go @@ -0,0 +1,202 @@ +package dot1x + +// WLAN profile XML parsing for the Windows backend. This logic is pure Go +// (no syscalls), so it lives outside the //go:build windows file and is +// compiled, tested, and coverage-counted on every platform. + +import ( + "encoding/xml" + "strconv" + "strings" +) + +// readCharData consumes tokens until the end of the element the decoder is +// currently positioned inside, returning the concatenated direct character +// data (text in nested child elements is ignored). It must be called +// immediately after reading a StartElement. +func readCharData(dec *xml.Decoder) (string, bool) { + var sb strings.Builder + depth := 0 + for { + tok, err := dec.Token() + if err != nil { + return "", false + } + switch t := tok.(type) { + case xml.StartElement: + depth++ + case xml.CharData: + if depth == 0 { + sb.Write(t) + } + case xml.EndElement: + if depth == 0 { + return sb.String(), true + } + depth-- + } + } +} + +// readIntCharData is readCharData parsed as a base-10 int. +func readIntCharData(dec *xml.Decoder) (int, bool) { + s, ok := readCharData(dec) + if !ok { + return 0, false + } + v, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0, false + } + return v, true +} + +// firstElementText returns the character data of the first element whose local +// name matches local (namespace prefix / xmlns attributes are ignored). +func firstElementText(xmlStr, local string) (string, bool) { + dec := xml.NewDecoder(strings.NewReader(xmlStr)) + for { + tok, err := dec.Token() + if err != nil { + return "", false + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local { + return readCharData(dec) + } + } +} + +// eapMethodTypeFromXML returns the integer nested inside the +// occurrence-th element (1 = outer EAP method, 2 = inner method +// used by tunneled auth such as PEAP/EAP-TTLS). Matching is by local element +// name, so namespace prefixes and attributes on the elements are tolerated. +// Returns -1 when the requested EapMethod or its Type is absent or malformed. +func eapMethodTypeFromXML(xmlStr string, occurrence int) int { + dec := xml.NewDecoder(strings.NewReader(xmlStr)) + methodCount := 0 + depth := 0 + inTarget := false + for { + tok, err := dec.Token() + if err != nil { + return -1 + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "EapMethod" && !inTarget { + methodCount++ + if methodCount == occurrence { + inTarget = true + depth = 1 + } + continue + } + if inTarget { + depth++ + if t.Name.Local == "Type" { + if v, ok := readIntCharData(dec); ok { + return v + } + return -1 + } + } + case xml.EndElement: + if inTarget { + depth-- + if depth == 0 { + return -1 // left the target EapMethod without a Type + } + } + } + } +} + +// extractEAPTypeFromXML parses the outer EAP method type from a WLAN profile +// XML (the inside the first ). +func extractEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 1) } + +// extractInnerEAPTypeFromXML parses the inner EAP method type used by tunneled +// methods like PEAP (25) or EAP-TTLS (21): the inside the second +// . +func extractInnerEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 2) } + +// extractAuthModeFromXML parses from the OneX section of a WLAN +// profile XML and maps it to an EAPOLControlMode value. +func extractAuthModeFromXML(xmlStr string) int { + s, ok := firstElementText(xmlStr, "authMode") + if !ok { + return -1 + } + switch strings.TrimSpace(s) { + case "machine": + return 3 // System + case "user": + return 1 // User + case "machineOrUser": + return 2 // LoginWindow + case "guest": + return 0 // None + default: + return -1 + } +} + +// extractTrustedRootCAFromXML parses hex hashes from the +// ServerValidation section of a WLAN profile XML. These are the SHA-1 +// fingerprints of the CA certificates configured for RADIUS server validation. +// All whitespace (spaces, newlines, tabs from pretty-printed XML) is stripped +// before the length check. Multiple hashes are comma-separated with +// colon-delimited hex pairs. +func extractTrustedRootCAFromXML(xmlStr string) string { + dec := xml.NewDecoder(strings.NewReader(xmlStr)) + var hashes []string + for { + tok, err := dec.Token() + if err != nil { + break + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "TrustedRootCA" { + text, ok := readCharData(dec) + if !ok { + continue + } + hex := strings.Join(strings.Fields(text), "") + // A SHA-1 thumbprint is exactly 40 hex chars; require valid hex so + // malformed profile content isn't emitted as a bogus fingerprint. + if len(hex) == 40 && isHexString(hex) { + hashes = append(hashes, formatSHA1Hex(hex)) + } + } + } + return strings.Join(hashes, ",") +} + +// isHexString reports whether s consists solely of hexadecimal digits. +func isHexString(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// formatSHA1Hex converts an even-length hex string to colon-separated pairs +// (e.g. "aabb..." -> "aa:bb:..."). Returns "" for odd-length input rather than +// panicking on the trailing 2-char slice. +func formatSHA1Hex(hex string) string { + if len(hex)%2 != 0 { + return "" + } + hex = strings.ToLower(hex) + var buf strings.Builder + buf.Grow(59) + for i := 0; i < len(hex); i += 2 { + if i > 0 { + buf.WriteByte(':') + } + buf.WriteString(hex[i : i+2]) + } + return buf.String() +} diff --git a/tables/dot1x/dot1x_wlanprofile_test.go b/tables/dot1x/dot1x_wlanprofile_test.go new file mode 100644 index 0000000..80594bf --- /dev/null +++ b/tables/dot1x/dot1x_wlanprofile_test.go @@ -0,0 +1,252 @@ +package dot1x + +// Tests for the pure-Go WLAN profile XML parsing. These have no build tag, so +// the parsing logic is exercised and coverage-counted on every platform (not +// only Windows). + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const sampleProfileXML = ` + + Campus + + + + WPA2 + AES + true + + + machine + + + + 13 + 0 + 0 + 0 + + + + 13 + + + true + + 23 a6 b1 0a be 8a 4a 37 72 11 e2 f4 2c 36 67 f1 36 e9 08 bf + + + + + + + + + +` + +const peapProfileXML = ` + + PEAPNetwork + + + + user + + + + 25 + + + + 25 + + + aa bb cc dd ee ff 00 11 22 33 44 55 66 77 88 99 aa bb cc dd + 11 22 33 44 55 66 77 88 99 00 aa bb cc dd ee ff 11 22 33 44 + + false + + 26 + + + 26 + + + + + + + + + + + +` + +func TestExtractEAPTypeFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want int + }{ + {"EAP-TLS", sampleProfileXML, 13}, + {"PEAP", peapProfileXML, 25}, + {"no EapMethod", `open`, -1}, + {"empty", "", -1}, + {"EapMethod but no Type", ``, -1}, + {"malformed Type value", `abc`, -1}, + {"namespace prefixed Type (matched by local name)", `13`, 13}, + {"Type with attributes", `21`, 21}, + {"EapMethod with attributes", `21`, 21}, + {"pretty-printed / indented", "\n\t\n\t\t25\n\t\n", 25}, + } + + for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractEAPTypeFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractAuthModeFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want int + }{ + {"machine", sampleProfileXML, 3}, + {"user", peapProfileXML, 1}, + {"machineOrUser", `machineOrUser`, 2}, + {"guest", `guest`, 0}, + {"unknown value", `somethingElse`, -1}, + {"no authMode", ``, -1}, + {"empty", "", -1}, + {"whitespace around value", ` machine `, 3}, + } + + for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractAuthModeFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractInnerEAPTypeFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want int + }{ + {"PEAP with MSCHAPv2 inner", peapProfileXML, 26}, + {"EAP-TLS no inner", sampleProfileXML, -1}, + {"no EapMethod at all", ``, -1}, + {"single EapMethod only", `13`, -1}, + {"empty", "", -1}, + } + + for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractInnerEAPTypeFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestExtractTrustedRootCAFromXML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + xml string + want string + }{ + { + "single CA with spaces", + sampleProfileXML, + "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", + }, + { + "multiple CAs", + peapProfileXML, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd," + + "11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff:11:22:33:44", + }, + { + "contiguous hex (no spaces)", + `aabbccddeeff00112233445566778899aabbccdd`, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + }, + { + "uppercase hex", + `AABBCCDDEEFF00112233445566778899AABBCCDD`, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + }, + {"no TrustedRootCA", ``, ""}, + {"empty", "", ""}, + { + "wrong length ignored", + `aabb`, + "", + }, + { + "whitespace only", + ` `, + "", + }, + { + "40 non-hex chars rejected", + `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz`, + "", + }, + { + "newlines and tabs in hex (pretty-printed XML)", + "\n\t\t\t\t23 a6 b1 0a ff bb cc dd ee 11\n\t\t\t\t22 33 44 55 66 77 88 99 aa bb\n\t\t\t", + "23:a6:b1:0a:ff:bb:cc:dd:ee:11:22:33:44:55:66:77:88:99:aa:bb", + }, + } + + for _, tc := range tests { + tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractTrustedRootCAFromXML(tc.xml) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFormatSHA1Hex(t *testing.T) { + t.Parallel() + + assert.Equal(t, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + formatSHA1Hex("aabbccddeeff00112233445566778899aabbccdd")) + assert.Equal(t, + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd", + formatSHA1Hex("AABBCCDDEEFF00112233445566778899AABBCCDD")) + + // Odd-length / short input must not panic on the trailing 2-char slice. + assert.Equal(t, "", formatSHA1Hex("")) + assert.Equal(t, "", formatSHA1Hex("a")) + assert.Equal(t, "", formatSHA1Hex("abc")) + assert.Equal(t, "aa:bb", formatSHA1Hex("aabb")) +} From cfe7a1d5cab9d274dbdab869a3290ff393245ca3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 17:03:23 -0400 Subject: [PATCH 27/36] dot1x: error on NULL status (darwin), wrap underlying enum error (windows) - dot1x(darwin): dot1x_query now returns -2 when copy_state_fn reports success (ret==0) but yields a NULL status dictionary, and GetStatus maps that to a per-interface "returned no status" error. Previously this emitted a misleading "successful" row of sentinel values; now generateRows skips the interface. Documented the return codes. - dot1x(windows): wrap the underlying enumeration error with %w (now "%w: %w") so it stays introspectable in the chain while errors.Is(ErrBackendUnavailable) still holds. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_darwin.go | 13 +++++++++++-- tables/dot1x/dot1x_windows.go | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tables/dot1x/dot1x_darwin.go b/tables/dot1x/dot1x_darwin.go index 3358a6d..d301939 100644 --- a/tables/dot1x/dot1x_darwin.go +++ b/tables/dot1x/dot1x_darwin.go @@ -194,7 +194,9 @@ static uint8_t* pack_cert_chain(CFArrayRef certs, CFIndex* out_len) { } // dot1x_query calls EAPOLControlCopyStateAndStatus for the given interface -// and fills the provided Go-accessible fields. Returns 0 on success. +// and fills the provided Go-accessible fields. Returns 0 on success, -1 if the +// framework/symbol could not be loaded, -2 if the call reported success but +// returned no status dictionary, otherwise the EAPOLControl error code. // All string/buffer outputs are malloc'd and must be freed by the caller. int dot1x_query( const char* ifname, @@ -247,7 +249,11 @@ int dot1x_query( if (ret != 0 || status == NULL) { if (status) CFRelease(status); - return ret; + // status == NULL with ret == 0 means the call "succeeded" but provided + // no status dictionary (no usable per-interface data). Return a + // distinct code (-2) so the Go layer raises a per-interface error and + // the interface is skipped, rather than emitting a row of sentinels. + return ret != 0 ? ret : -2; } *out_state = (int)state; @@ -454,6 +460,9 @@ func (productionBackend) GetStatus(ifname string) (Dot1XStatus, error) { } return s, fmt.Errorf("%w: could not load EAPOLControlCopyStateAndStatus for %s: %s", ErrBackendUnavailable, ifname, reason) } + if ret == -2 { + return s, fmt.Errorf("EAPOLControlCopyStateAndStatus returned no status for %s", ifname) + } return s, fmt.Errorf("EAPOLControlCopyStateAndStatus returned %d for %s", int(ret), ifname) } diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index c636ba6..29bd60c 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -253,8 +253,10 @@ func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { infos, err := b.snapshot() if err != nil { // Enumeration failing is systemic (affects every interface), so report - // it as backend-unavailable rather than a per-interface miss. - return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: %v", ErrBackendUnavailable, err) + // it as backend-unavailable rather than a per-interface miss. Both are + // wrapped (%w) so errors.Is(ErrBackendUnavailable) holds and the + // underlying WlanEnumInterfaces error stays introspectable. + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: %w", ErrBackendUnavailable, err) } info, ok := infos[ifname] if !ok { From 7e1e2a2482feadbab19342360717873eee8cffe5 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 17:11:54 -0400 Subject: [PATCH 28/36] dot1x tests: use context.Background(); BUILD: tag mac cgo binaries manual - Tests: replace t.Context() with context.Background() in the darwin and windows test files, matching the shared tests and avoiding a dependency on the newer (*testing.T).Context() helper. - Root BUILD.bazel: tag the darwin cgo go_binary targets "manual" so they're excluded from //... / :all wildcard expansion. `bazel build //...` / `bazel test //...` on a Linux/Windows host no longer fail trying to analyze them (they need a darwin C++ toolchain); explicit builds via the Makefile / bazel_to_builddir.sh still work. target_compatible_with can't gate these: the goos="darwin" transition makes the target platform macOS, so a macos constraint would always be satisfied and never skip on a Linux host. Co-Authored-By: Claude Opus 4.8 (1M context) --- BUILD.bazel | 15 +++++++++++++-- tables/dot1x/dot1x_darwin_test.go | 15 ++++++++------- tables/dot1x/dot1x_windows_test.go | 11 ++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 1f0adc4..88043da 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -67,8 +67,17 @@ go_library( # toolchain on darwin. These are darwin-only binaries (goos = "darwin"), # so cgo=True does not affect Linux/Windows builds. The apple_support # toolchain in WORKSPACE satisfies the CC requirement on macOS. -# Note: these targets require a darwin C++ toolchain, so they only build -# on macOS hosts (the Makefile gates them behind `ifeq ($(shell uname),Darwin)`). +# +# These targets require a darwin C++ toolchain (only available on macOS +# hosts), so they are tagged "manual" to keep them out of `//...` / `:all` +# wildcard expansion. That way `bazel build //...` / `bazel test //...` on a +# Linux/Windows host won't try to analyze them and fail; they still build when +# named explicitly (the Makefile and tools/bazel_to_builddir.sh do so, gated +# behind `ifeq ($(shell uname),Darwin)`). +# +# target_compatible_with is not used here: the goos="darwin" platform +# transition makes the resolved target platform macOS, so a macos constraint +# would always be satisfied and would never skip the target on a Linux host. go_binary( name = "osquery-extension-mac-arm", cgo = True, @@ -76,6 +85,7 @@ go_binary( goarch = "arm64", goos = "darwin", pure = "off", + tags = ["manual"], visibility = ["//visibility:public"], ) @@ -86,6 +96,7 @@ go_binary( goarch = "amd64", goos = "darwin", pure = "off", + tags = ["manual"], visibility = ["//visibility:public"], ) diff --git a/tables/dot1x/dot1x_darwin_test.go b/tables/dot1x/dot1x_darwin_test.go index 826bfb4..70fe592 100644 --- a/tables/dot1x/dot1x_darwin_test.go +++ b/tables/dot1x/dot1x_darwin_test.go @@ -3,6 +3,7 @@ package dot1x import ( + "context" "testing" "github.com/osquery/osquery-go/plugin/table" @@ -78,7 +79,7 @@ func TestDarwinMockBackendSystemEAPTLS(t *testing.T) { }, } - rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + rows, err := generateRows(context.Background(), backend, constraintFor("en0")) require.NoError(t, err) require.Len(t, rows, 1) @@ -132,7 +133,7 @@ func TestDarwinMockBackendIdle(t *testing.T) { }, } - rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + rows, err := generateRows(context.Background(), backend, constraintFor("en0")) require.NoError(t, err) require.Len(t, rows, 1) @@ -178,7 +179,7 @@ func TestDarwinMockBackendPEAP(t *testing.T) { }, } - rows, err := generateRows(t.Context(), backend, constraintFor("en1")) + rows, err := generateRows(context.Background(), backend, constraintFor("en1")) require.NoError(t, err) require.Len(t, rows, 1) @@ -213,7 +214,7 @@ func TestDarwinMockBackendUnknownEAPType(t *testing.T) { }, } - rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + rows, err := generateRows(context.Background(), backend, constraintFor("en0")) require.NoError(t, err) require.Len(t, rows, 1) assert.Equal(t, "99", rows[0]["eap_type"]) @@ -242,7 +243,7 @@ func TestDarwinMockBackendMultipleInterfaces(t *testing.T) { }, } - rows, err := generateRows(t.Context(), backend, qc) + rows, err := generateRows(context.Background(), backend, qc) require.NoError(t, err) require.Len(t, rows, 2) assert.Equal(t, "en0", rows[0]["interface"]) @@ -257,7 +258,7 @@ func TestDarwinMockBackendNotFound(t *testing.T) { // An interface the backend doesn't know about errors per-interface, and // generateRows skips it, yielding zero rows. backend := fakeBackend{statuses: map[string]Dot1XStatus{}} - rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + rows, err := generateRows(context.Background(), backend, constraintFor("en0")) require.NoError(t, err) assert.Empty(t, rows) } @@ -268,7 +269,7 @@ func TestDarwinMockBackendUnavailable(t *testing.T) { // A systemic ErrBackendUnavailable (e.g. EAP8021X.framework failed to // load) propagates rather than being swallowed as a per-interface miss. backend := errBackend{err: ErrBackendUnavailable} - rows, err := generateRows(t.Context(), backend, constraintFor("en0")) + rows, err := generateRows(context.Background(), backend, constraintFor("en0")) assert.ErrorIs(t, err, ErrBackendUnavailable) assert.Empty(t, rows) } diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index 11eebc6..fb5fef0 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -3,6 +3,7 @@ package dot1x import ( + "context" "errors" "os" "testing" @@ -170,7 +171,7 @@ func TestWindowsMockBackendConnected(t *testing.T) { } qc := constraintFor("RZ616 Wi-Fi 6E 160MHz") - rows, err := generateRows(t.Context(), backend, qc) + rows, err := generateRows(context.Background(), backend, qc) require.NoError(t, err) require.Len(t, rows, 1) @@ -217,7 +218,7 @@ func TestWindowsMockBackendDisconnected(t *testing.T) { } qc := constraintFor("Intel Wi-Fi 6") - rows, err := generateRows(t.Context(), backend, qc) + rows, err := generateRows(context.Background(), backend, qc) require.NoError(t, err) require.Len(t, rows, 1) @@ -258,7 +259,7 @@ func TestWindowsMockBackendPEAP(t *testing.T) { } qc := constraintFor("Realtek Wi-Fi") - rows, err := generateRows(t.Context(), backend, qc) + rows, err := generateRows(context.Background(), backend, qc) require.NoError(t, err) require.Len(t, rows, 1) @@ -276,7 +277,7 @@ func TestWindowsMockBackendNotFound(t *testing.T) { backend := fakeBackend{statuses: map[string]Dot1XStatus{}} qc := constraintFor("nonexistent adapter") - rows, err := generateRows(t.Context(), backend, qc) + rows, err := generateRows(context.Background(), backend, qc) require.NoError(t, err) assert.Empty(t, rows) } @@ -286,7 +287,7 @@ func TestWindowsMockBackendUnavailable(t *testing.T) { backend := errBackend{err: ErrBackendUnavailable} qc := constraintFor("any") - rows, err := generateRows(t.Context(), backend, qc) + rows, err := generateRows(context.Background(), backend, qc) assert.ErrorIs(t, err, ErrBackendUnavailable) assert.Empty(t, rows) } From 7e63e2dd9c3f45bbdf8492bf333efa7a5424f026 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 17:25:22 -0400 Subject: [PATCH 29/36] dot1x: accurate Dot1XBackend doc; de-dup constraintFor test helper - Dot1XBackend doc comment now describes all three production backends (macOS cgo / EAP8021X, Windows wlanapi, other noop) instead of only darwin. - Move the duplicated constraintFor test helper out of the darwin and windows test files into dot1x_helpers_test.go. It carries a `darwin || windows` build tag (not untagged) because it's only used by those two backends' tests; an untagged helper would be flagged unused on Linux. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/BUILD.bazel | 1 + tables/dot1x/dot1x.go | 8 +++++--- tables/dot1x/dot1x_darwin_test.go | 14 -------------- tables/dot1x/dot1x_helpers_test.go | 20 ++++++++++++++++++++ tables/dot1x/dot1x_windows_test.go | 15 --------------- 5 files changed, 26 insertions(+), 32 deletions(-) create mode 100644 tables/dot1x/dot1x_helpers_test.go diff --git a/tables/dot1x/BUILD.bazel b/tables/dot1x/BUILD.bazel index 1f61ec9..e33e7ff 100644 --- a/tables/dot1x/BUILD.bazel +++ b/tables/dot1x/BUILD.bazel @@ -41,6 +41,7 @@ go_test( srcs = [ "dot1x_test.go", "dot1x_darwin_test.go", + "dot1x_helpers_test.go", "dot1x_other_test.go", "dot1x_wlanprofile_test.go", "dot1x_windows_test.go", diff --git a/tables/dot1x/dot1x.go b/tables/dot1x/dot1x.go index 67d9d37..a84a330 100644 --- a/tables/dot1x/dot1x.go +++ b/tables/dot1x/dot1x.go @@ -40,9 +40,11 @@ type Dot1XStatus struct { UniqueIdentifier string } -// Dot1XBackend fetches 802.1X status for a named interface. -// Production implementation calls EAPOLControlCopyStateAndStatus -// via cgo (dot1x_darwin.go); tests inject a fake. +// Dot1XBackend fetches 802.1X status for a named interface. The production +// implementation is platform-specific: macOS calls EAPOLControlCopyStateAndStatus +// from EAP8021X.framework via cgo (dot1x_darwin.go); Windows queries wlanapi.dll +// and parses WLAN profile XML (dot1x_windows.go); other platforms use a noop +// backend that reports ErrBackendUnavailable (dot1x_other.go). Tests inject a fake. type Dot1XBackend interface { GetStatus(ifname string) (Dot1XStatus, error) } diff --git a/tables/dot1x/dot1x_darwin_test.go b/tables/dot1x/dot1x_darwin_test.go index 70fe592..b7ab6e2 100644 --- a/tables/dot1x/dot1x_darwin_test.go +++ b/tables/dot1x/dot1x_darwin_test.go @@ -273,17 +273,3 @@ func TestDarwinMockBackendUnavailable(t *testing.T) { assert.ErrorIs(t, err, ErrBackendUnavailable) assert.Empty(t, rows) } - -// --- helpers --- - -func constraintFor(ifname string) table.QueryContext { - return table.QueryContext{ - Constraints: map[string]table.ConstraintList{ - "interface": { - Constraints: []table.Constraint{ - {Operator: table.OperatorEquals, Expression: ifname}, - }, - }, - }, - } -} diff --git a/tables/dot1x/dot1x_helpers_test.go b/tables/dot1x/dot1x_helpers_test.go new file mode 100644 index 0000000..74bfaa5 --- /dev/null +++ b/tables/dot1x/dot1x_helpers_test.go @@ -0,0 +1,20 @@ +//go:build darwin || windows + +package dot1x + +import "github.com/osquery/osquery-go/plugin/table" + +// constraintFor builds a QueryContext with a single exact-match `interface` +// constraint. Shared by the darwin and windows backend tests (it is unused on +// other platforms, hence the build tag rather than an untagged helper file). +func constraintFor(ifname string) table.QueryContext { + return table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: ifname}, + }, + }, + }, + } +} diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index fb5fef0..e7a6bd3 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -8,7 +8,6 @@ import ( "os" "testing" - "github.com/osquery/osquery-go/plugin/table" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -379,17 +378,3 @@ func TestWindowsLiveProfileXMLExtraction(t *testing.T) { } t.Skip("no connected wireless interface found") } - -// --- helpers --- - -func constraintFor(ifname string) table.QueryContext { - return table.QueryContext{ - Constraints: map[string]table.ConstraintList{ - "interface": { - Constraints: []table.Constraint{ - {Operator: table.OperatorEquals, Expression: ifname}, - }, - }, - }, - } -} From d70376d437d6543a8a499b989c7bacfa728157ef Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 17:36:58 -0400 Subject: [PATCH 30/36] dot1x(windows): system DLL load, separate trusted-root-CA column, single enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review: - Load wlanapi.dll via windows.NewLazySystemDLL (system directory) instead of syscall.NewLazyDLL (default search order), preventing DLL search-order hijacking when the process runs from a writable location. - Add a distinct tls_trusted_root_ca_sha1 column. Windows now stores the configured trusted-root-CA thumbprints there instead of overloading tls_server_certificate_sha1 (which macOS fills with the actual presented server-cert chain) — the two had different meanings across platforms. - Avoid the double WlanEnumInterfaces on unconstrained queries: windowsBackend caches both the info map and ordered names in its per-generation snapshot and exposes interfaceNames() via a new optional interfaceLister; interfacesToQuery sources the default list from the backend's snapshot when available, falling back to the package defaultInterfaces() otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x.go | 32 +++++++++++++++++------ tables/dot1x/dot1x_test.go | 9 ++++--- tables/dot1x/dot1x_windows.go | 42 +++++++++++++++++++++++------- tables/dot1x/dot1x_windows_test.go | 13 +++++---- 4 files changed, 69 insertions(+), 27 deletions(-) diff --git a/tables/dot1x/dot1x.go b/tables/dot1x/dot1x.go index a84a330..ad9f07d 100644 --- a/tables/dot1x/dot1x.go +++ b/tables/dot1x/dot1x.go @@ -31,6 +31,7 @@ type Dot1XStatus struct { TLSServerCertificateChain string // pipe-separated subject DNs in LDAP notation TLSServerCertificateSHA1 string // comma-separated colon-separated SHA-1 fingerprints TLSServerCertificateSerials string // comma-separated hex serial numbers + TLSTrustedRootCASHA1 string // comma-separated SHA-1 thumbprints of the trusted root CAs configured for server validation (Windows profile) TLSTrustClientStatus int // trust evaluation error code (0=ok) TLSNegotiatedProtocolVersion string // "1.2" or "1.3" TLSNegotiatedCipher int // TLS cipher suite code @@ -124,6 +125,7 @@ func Dot1XStatusColumns() []table.ColumnDefinition { table.TextColumn("tls_server_certificate_chain"), table.TextColumn("tls_server_certificate_sha1"), table.TextColumn("tls_server_certificate_serials"), + table.TextColumn("tls_trusted_root_ca_sha1"), table.IntegerColumn("tls_trust_client_status"), table.TextColumn("tls_negotiated_protocol_version"), table.IntegerColumn("tls_negotiated_cipher"), @@ -144,7 +146,7 @@ func Dot1XStatusGenerate(ctx context.Context, queryContext table.QueryContext) ( // otherwise en0 through en9 are probed. The context is checked before each // backend call to support cancellation. func generateRows(ctx context.Context, backend Dot1XBackend, queryContext table.QueryContext) ([]map[string]string, error) { - ifaces := interfacesToQuery(queryContext) + ifaces := interfacesToQuery(backend, queryContext) var rows []map[string]string for _, ifname := range ifaces { @@ -164,11 +166,18 @@ func generateRows(ctx context.Context, backend Dot1XBackend, queryContext table. return rows, nil } +// interfaceLister is an optional backend capability: a backend that already +// enumerates interfaces (e.g. the Windows WLAN backend) can supply the default +// interface list from its own cached snapshot, avoiding a second enumeration. +type interfaceLister interface { + interfaceNames() []string +} + // interfacesToQuery returns the list of interfaces to query. If a WHERE // constraint "interface" is present, only that interface is returned; // otherwise the platform-specific default list is used (e.g. real // wireless adapter names on Windows, en0-en9 on macOS). -func interfacesToQuery(queryContext table.QueryContext) []string { +func interfacesToQuery(backend Dot1XBackend, queryContext table.QueryContext) []string { if constraints, ok := queryContext.Constraints["interface"]; ok { seen := make(map[string]struct{}) var ifaces []string @@ -186,12 +195,18 @@ func interfacesToQuery(queryContext table.QueryContext) []string { } } - // A non-nil result from defaultInterfaces() is authoritative, even when - // empty: e.g. a Windows host with no WLAN adapters returns an empty slice - // (query nothing) rather than falling through to the macOS-style en0-en9 - // probe list. Only a nil result (defaults unknown) uses that fallback. - if ifaces := defaultInterfaces(); ifaces != nil { - return ifaces + // Prefer the backend's own enumeration when it provides one (the Windows + // backend shares its per-generation snapshot), otherwise the package + // default. A non-nil result is authoritative even when empty: a Windows + // host with no WLAN adapters returns an empty slice (query nothing) rather + // than falling through to the macOS-style en0-en9 probe list. Only a nil + // result (defaults unknown) uses that fallback. + defaults := defaultInterfaces() + if l, ok := backend.(interfaceLister); ok { + defaults = l.interfaceNames() + } + if defaults != nil { + return defaults } fallback := make([]string, 10) for i := range fallback { @@ -217,6 +232,7 @@ func rowFromStatus(s Dot1XStatus) map[string]string { "tls_server_certificate_chain": s.TLSServerCertificateChain, "tls_server_certificate_sha1": s.TLSServerCertificateSHA1, "tls_server_certificate_serials": s.TLSServerCertificateSerials, + "tls_trusted_root_ca_sha1": s.TLSTrustedRootCASHA1, "tls_trust_client_status": itoa(s.TLSTrustClientStatus), "tls_negotiated_protocol_version": s.TLSNegotiatedProtocolVersion, "tls_negotiated_cipher": itoa(s.TLSNegotiatedCipher), diff --git a/tables/dot1x/dot1x_test.go b/tables/dot1x/dot1x_test.go index 0aefb1c..c80034a 100644 --- a/tables/dot1x/dot1x_test.go +++ b/tables/dot1x/dot1x_test.go @@ -48,6 +48,7 @@ func TestDot1XStatusColumns(t *testing.T) { "tls_server_certificate_chain", "tls_server_certificate_sha1", "tls_server_certificate_serials", + "tls_trusted_root_ca_sha1", "tls_trust_client_status", "tls_negotiated_protocol_version", "tls_negotiated_cipher", @@ -69,7 +70,7 @@ func TestInterfacesToQuery(t *testing.T) { t.Run("no constraint returns default interfaces", func(t *testing.T) { t.Parallel() qc := table.QueryContext{} - ifaces := interfacesToQuery(qc) + ifaces := interfacesToQuery(fakeBackend{}, qc) // A non-nil platform default (incl. an empty slice, e.g. Windows with // no WLAN adapters) is authoritative; only a nil default falls back to // the generic en0-en9 probe list. @@ -93,7 +94,7 @@ func TestInterfacesToQuery(t *testing.T) { }, }, } - ifaces := interfacesToQuery(qc) + ifaces := interfacesToQuery(fakeBackend{}, qc) assert.Equal(t, []string{"en0"}, ifaces) }) @@ -110,7 +111,7 @@ func TestInterfacesToQuery(t *testing.T) { } // LIKE is not exact-match, so it falls back to the same platform // defaults an unconstrained query would use. - assert.Equal(t, interfacesToQuery(table.QueryContext{}), interfacesToQuery(qc)) + assert.Equal(t, interfacesToQuery(fakeBackend{}, table.QueryContext{}), interfacesToQuery(fakeBackend{}, qc)) }) t.Run("duplicate constraints deduplicated", func(t *testing.T) { @@ -126,7 +127,7 @@ func TestInterfacesToQuery(t *testing.T) { }, }, } - ifaces := interfacesToQuery(qc) + ifaces := interfacesToQuery(fakeBackend{}, qc) assert.Equal(t, []string{"en0", "en1"}, ifaces) }) } diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 29bd60c..4a56841 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -84,7 +84,10 @@ type wlanConnectionAttributes struct { } var ( - modWlanapi = syscall.NewLazyDLL("wlanapi.dll") + // NewLazySystemDLL (not NewLazyDLL) forces loading from the Windows system + // directory, avoiding DLL search-order hijacking if the process runs from a + // writable location. + modWlanapi = windows.NewLazySystemDLL("wlanapi.dll") procWlanOpenHandle = modWlanapi.NewProc("WlanOpenHandle") procWlanEnumInterfaces = modWlanapi.NewProc("WlanEnumInterfaces") @@ -108,7 +111,7 @@ func initWlan() { if err := modWlanapi.Load(); err != nil { return } - for _, p := range []*syscall.LazyProc{ + for _, p := range []*windows.LazyProc{ procWlanOpenHandle, procWlanEnumInterfaces, procWlanQueryInterface, procWlanGetProfile, procWlanFreeMemory, } { @@ -132,12 +135,14 @@ type ifaceInfo struct { } // windowsBackend reuses the process-lifetime WLAN handle and, on first use, -// snapshots all interfaces into ifaces so that querying several interfaces in -// one table generation enumerates only once instead of once per interface. +// snapshots all interfaces (both the description->info map and the ordered +// names) so one table generation enumerates only once — shared between the +// default interface list and every GetStatus. type windowsBackend struct { handle uintptr once sync.Once ifaces map[string]ifaceInfo + names []string enumErr error } @@ -241,16 +246,29 @@ func defaultInterfaces() []string { } // snapshot lazily enumerates interfaces once per backend instance (i.e. once -// per table generation) and caches the result. -func (b *windowsBackend) snapshot() (map[string]ifaceInfo, error) { +// per table generation) and caches both the info map and ordered names. +func (b *windowsBackend) snapshot() (map[string]ifaceInfo, []string, error) { b.once.Do(func() { - b.ifaces, _, b.enumErr = enumerateWlanInterfaceInfos(b.handle) + b.ifaces, b.names, b.enumErr = enumerateWlanInterfaceInfos(b.handle) }) - return b.ifaces, b.enumErr + return b.ifaces, b.names, b.enumErr +} + +// interfaceNames satisfies the shared interfaceLister optional interface so the +// default interface list for an unconstrained query is sourced from the same +// snapshot GetStatus uses, avoiding a second WlanEnumInterfaces call. Returns +// nil when WLAN is unavailable / enumeration failed (caller's generic +// fallback), or a possibly-empty slice of adapter names otherwise. +func (b *windowsBackend) interfaceNames() []string { + _, names, err := b.snapshot() + if err != nil { + return nil + } + return names } func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { - infos, err := b.snapshot() + infos, _, err := b.snapshot() if err != nil { // Enumeration failing is systemic (affects every interface), so report // it as backend-unavailable rather than a per-interface miss. Both are @@ -339,8 +357,12 @@ func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { if innerType := extractInnerEAPTypeFromXML(xmlStr); innerType > 0 { s.InnerEAPType = innerType } + // These are the configured trusted root CA thumbprints (server + // validation), not the presented server certificate's fingerprint, + // so they go in TLSTrustedRootCASHA1 rather than + // TLSServerCertificateSHA1 (which macOS fills with the actual chain). if sha1 := extractTrustedRootCAFromXML(xmlStr); sha1 != "" { - s.TLSServerCertificateSHA1 = sha1 + s.TLSTrustedRootCASHA1 = sha1 } } } diff --git a/tables/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go index e7a6bd3..8ca8616 100644 --- a/tables/dot1x/dot1x_windows_test.go +++ b/tables/dot1x/dot1x_windows_test.go @@ -157,10 +157,10 @@ func TestWindowsMockBackendConnected(t *testing.T) { SupplicantState: 4, EAPType: 13, ClientStatus: 0, - AuthenticatorMACAddress: "26:0b:8b:00:f2:34", - Mode: 3, - TLSServerCertificateSHA1: "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", - UniqueIdentifier: "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", + AuthenticatorMACAddress: "26:0b:8b:00:f2:34", + Mode: 3, + TLSTrustedRootCASHA1: "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", + UniqueIdentifier: "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", DomainSpecificError: -1, TLSTrustClientStatus: -1, TLSNegotiatedCipher: -1, @@ -186,7 +186,10 @@ func TestWindowsMockBackendConnected(t *testing.T) { assert.Equal(t, "26:0b:8b:00:f2:34", row["authenticator_mac_address"]) assert.Equal(t, "3", row["mode"]) assert.Equal(t, "System", row["mode_name"]) - assert.Equal(t, "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", row["tls_server_certificate_sha1"]) + // Windows surfaces the configured trusted-root-CA thumbprints, not the + // presented server cert fingerprint, so they land in tls_trusted_root_ca_sha1. + assert.Equal(t, "23:a6:b1:0a:be:8a:4a:37:72:11:e2:f4:2c:36:67:f1:36:e9:08:bf", row["tls_trusted_root_ca_sha1"]) + assert.Equal(t, "", row["tls_server_certificate_sha1"]) assert.Equal(t, "{9A82D898-7B57-40AA-A330-E2B99D10BD77}", row["unique_identifier"]) assert.Equal(t, "", row["domain_specific_error"]) assert.Equal(t, "", row["tls_trust_client_status"]) From 6891d451af519405a55d749cd125301ba8562d9b Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 17:43:35 -0400 Subject: [PATCH 31/36] dot1x(windows): report specific init failure; Makefile: robust uname gate - Capture the WLAN init failure reason in wlanInitErr (DLL load / missing proc / WlanOpenHandle) and surface it from unavailableBackend.GetStatus, instead of the misleading fixed "wlanapi.dll not available" message. errors.Is( ErrBackendUnavailable) still holds and the underlying cause is wrapped. - Makefile: compute UNAME_S := $(shell uname -s 2>/dev/null) once with a safe empty default and gate the darwin binary builds on it, rather than calling $(shell uname) inline (which fails where uname is unavailable). Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 6 +++++- tables/dot1x/dot1x_windows.go | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index cc30792..d29ec2f 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ current_dir = $(shell pwd) SHELL = /bin/sh +# Computed once, with a safe empty default if `uname` is unavailable (some +# Windows shells / CI images). Used to gate the darwin-only binary builds. +UNAME_S := $(shell uname -s 2>/dev/null) + BAZEL_OUTPUT_PATH := $(shell bazel info output_path) APP_NAME = macadmins_extension @@ -65,7 +69,7 @@ test: bazel test --test_output=errors --target_pattern_file="$$targets" build: .pre-build -ifeq ($(shell uname),Darwin) +ifeq ($(UNAME_S),Darwin) bazel build --verbose_failures //:osquery-extension-mac-amd bazel build --verbose_failures //:osquery-extension-mac-arm endif diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 4a56841..0cdb04b 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -100,6 +100,9 @@ var ( wlanOnce sync.Once // wlanAvail reports whether wlanapi.dll loaded and a client handle opened. wlanAvail bool + // wlanInitErr records why initWlan failed (DLL load, missing proc, or + // WlanOpenHandle), so unavailableBackend can report a specific reason. + wlanInitErr error // wlanHandle is a process-lifetime WLAN client handle opened once in // initWlan and reused by every query. Like the darwin framework handle, it // is intentionally never closed — the OS reclaims it at process exit, and @@ -109,6 +112,7 @@ var ( func initWlan() { if err := modWlanapi.Load(); err != nil { + wlanInitErr = fmt.Errorf("loading wlanapi.dll: %w", err) return } for _, p := range []*windows.LazyProc{ @@ -116,11 +120,13 @@ func initWlan() { procWlanQueryInterface, procWlanGetProfile, procWlanFreeMemory, } { if err := p.Find(); err != nil { + wlanInitErr = fmt.Errorf("resolving wlanapi.dll proc %s: %w", p.Name, err) return } } h, err := openWlanHandle() if err != nil { + wlanInitErr = fmt.Errorf("opening WLAN client handle: %w", err) return } wlanHandle = h @@ -157,8 +163,12 @@ func newBackend() Dot1XBackend { type unavailableBackend struct{} func (unavailableBackend) GetStatus(ifname string) (Dot1XStatus, error) { + if wlanInitErr != nil { + return Dot1XStatus{Interface: ifname}, + fmt.Errorf("%w: Windows WLAN backend unavailable: %w", ErrBackendUnavailable, wlanInitErr) + } return Dot1XStatus{Interface: ifname}, - fmt.Errorf("%w: wlanapi.dll not available on this system", ErrBackendUnavailable) + fmt.Errorf("%w: Windows WLAN backend unavailable", ErrBackendUnavailable) } func openWlanHandle() (uintptr, error) { From 3dfd9431c93dcf60267c34ce2955e15945073197 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 18:05:32 -0400 Subject: [PATCH 32/36] dot1x: make TestInterfacesToQuery deterministic via interfaceLister stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "no constraint" / LIKE cases called the package defaultInterfaces(), which on Windows performs real WLAN enumeration — tying the unit test to host networking state. Add a stubLister backend (implements interfaceLister with a fixed name slice) and assert interfacesToQuery prefers backend-provided defaults, plus separate deterministic cases for empty (query nothing) and nil (en0-en9 fallback) defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_test.go | 55 +++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/tables/dot1x/dot1x_test.go b/tables/dot1x/dot1x_test.go index c80034a..50293ae 100644 --- a/tables/dot1x/dot1x_test.go +++ b/tables/dot1x/dot1x_test.go @@ -35,6 +35,19 @@ func (f fakeBackend) GetStatus(ifname string) (Dot1XStatus, error) { return Dot1XStatus{Interface: ifname}, errors.New("not found") } +// stubLister is a Dot1XBackend that also implements interfaceLister, returning +// a fixed interface list. It keeps interfacesToQuery tests deterministic +// (independent of host WLAN state) when exercising the default-list path. +type stubLister struct { + names []string +} + +func (stubLister) GetStatus(ifname string) (Dot1XStatus, error) { + return Dot1XStatus{Interface: ifname}, errors.New("not found") +} + +func (s stubLister) interfaceNames() []string { return s.names } + func TestDot1XStatusColumns(t *testing.T) { t.Parallel() want := []string{ @@ -67,20 +80,29 @@ func TestDot1XStatusColumns(t *testing.T) { func TestInterfacesToQuery(t *testing.T) { t.Parallel() - t.Run("no constraint returns default interfaces", func(t *testing.T) { + t.Run("no constraint uses backend-provided defaults", func(t *testing.T) { t.Parallel() - qc := table.QueryContext{} - ifaces := interfacesToQuery(fakeBackend{}, qc) - // A non-nil platform default (incl. an empty slice, e.g. Windows with - // no WLAN adapters) is authoritative; only a nil default falls back to - // the generic en0-en9 probe list. - if di := defaultInterfaces(); di != nil { - assert.Equal(t, di, ifaces) - } else { - assert.Len(t, ifaces, 10) - assert.Equal(t, "en0", ifaces[0]) - assert.Equal(t, "en9", ifaces[9]) - } + backend := stubLister{names: []string{"wlan0", "wlan1"}} + ifaces := interfacesToQuery(backend, table.QueryContext{}) + assert.Equal(t, []string{"wlan0", "wlan1"}, ifaces) + }) + + t.Run("empty backend defaults query nothing", func(t *testing.T) { + t.Parallel() + // Non-nil empty (e.g. enumerated, no WLAN adapters) is authoritative. + backend := stubLister{names: []string{}} + ifaces := interfacesToQuery(backend, table.QueryContext{}) + assert.Empty(t, ifaces) + }) + + t.Run("nil backend defaults fall back to en0-en9", func(t *testing.T) { + t.Parallel() + // nil means defaults unknown -> generic probe list. + backend := stubLister{names: nil} + ifaces := interfacesToQuery(backend, table.QueryContext{}) + require.Len(t, ifaces, 10) + assert.Equal(t, "en0", ifaces[0]) + assert.Equal(t, "en9", ifaces[9]) }) t.Run("with equals constraint returns specified interface", func(t *testing.T) { @@ -109,9 +131,10 @@ func TestInterfacesToQuery(t *testing.T) { }, }, } - // LIKE is not exact-match, so it falls back to the same platform - // defaults an unconstrained query would use. - assert.Equal(t, interfacesToQuery(fakeBackend{}, table.QueryContext{}), interfacesToQuery(fakeBackend{}, qc)) + // LIKE is not exact-match, so it falls back to the backend defaults an + // unconstrained query would use. + backend := stubLister{names: []string{"wlan0"}} + assert.Equal(t, []string{"wlan0"}, interfacesToQuery(backend, qc)) }) t.Run("duplicate constraints deduplicated", func(t *testing.T) { From da811c5549ce656d66c53cb66d36d4c11c032ddf Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 18:16:59 -0400 Subject: [PATCH 33/36] dot1x: fix double-enum in interfacesToQuery; parse WLAN profile in one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interfacesToQuery: consult the backend's interfaceLister FIRST and only call the package defaultInterfaces() when the backend doesn't implement it. Previously defaultInterfaces() was always called before the lister check, so on Windows it still enumerated twice — defeating the round's earlier goal. - WLAN profile XML: replace the four separate extractors (each of which built a new decoder and scanned the whole document) with a single-pass parseWLANProfile that collects EAP type, inner EAP type, authMode, and trusted-root-CA thumbprints in one token pass. GetStatus now parses once. Renamed the tests accordingly; same cases, verified identical results. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x.go | 15 +- tables/dot1x/dot1x_windows.go | 17 +- tables/dot1x/dot1x_wlanprofile.go | 213 ++++++++++++------------- tables/dot1x/dot1x_wlanprofile_test.go | 16 +- 4 files changed, 125 insertions(+), 136 deletions(-) diff --git a/tables/dot1x/dot1x.go b/tables/dot1x/dot1x.go index ad9f07d..dc99254 100644 --- a/tables/dot1x/dot1x.go +++ b/tables/dot1x/dot1x.go @@ -196,14 +196,17 @@ func interfacesToQuery(backend Dot1XBackend, queryContext table.QueryContext) [] } // Prefer the backend's own enumeration when it provides one (the Windows - // backend shares its per-generation snapshot), otherwise the package - // default. A non-nil result is authoritative even when empty: a Windows - // host with no WLAN adapters returns an empty slice (query nothing) rather - // than falling through to the macOS-style en0-en9 probe list. Only a nil - // result (defaults unknown) uses that fallback. - defaults := defaultInterfaces() + // backend shares its per-generation snapshot, so we must NOT also call the + // package defaultInterfaces() — that would enumerate a second time); + // otherwise use the package default. A non-nil result is authoritative even + // when empty: a Windows host with no WLAN adapters returns an empty slice + // (query nothing) rather than falling through to the macOS-style en0-en9 + // probe list. Only a nil result (defaults unknown) uses that fallback. + var defaults []string if l, ok := backend.(interfaceLister); ok { defaults = l.interfaceNames() + } else { + defaults = defaultInterfaces() } if defaults != nil { return defaults diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go index 0cdb04b..50caa3c 100644 --- a/tables/dot1x/dot1x_windows.go +++ b/tables/dot1x/dot1x_windows.go @@ -358,21 +358,22 @@ func (b *windowsBackend) GetStatus(ifname string) (Dot1XStatus, error) { profileName := utf16ToString(conn.ProfileName[:]) if profileName != "" { if xmlStr, err := getWlanProfileXML(b.handle, &guid, profileName); err == nil { - if eapType := extractEAPTypeFromXML(xmlStr); eapType > 0 { - s.EAPType = eapType + profile := parseWLANProfile(xmlStr) // single pass over the XML + if profile.eapType > 0 { + s.EAPType = profile.eapType } - if mode := extractAuthModeFromXML(xmlStr); mode >= 0 { - s.Mode = mode + if profile.authMode >= 0 { + s.Mode = profile.authMode } - if innerType := extractInnerEAPTypeFromXML(xmlStr); innerType > 0 { - s.InnerEAPType = innerType + if profile.innerEAPType > 0 { + s.InnerEAPType = profile.innerEAPType } // These are the configured trusted root CA thumbprints (server // validation), not the presented server certificate's fingerprint, // so they go in TLSTrustedRootCASHA1 rather than // TLSServerCertificateSHA1 (which macOS fills with the actual chain). - if sha1 := extractTrustedRootCAFromXML(xmlStr); sha1 != "" { - s.TLSTrustedRootCASHA1 = sha1 + if profile.trustedRootCASHA1 != "" { + s.TLSTrustedRootCASHA1 = profile.trustedRootCASHA1 } } } diff --git a/tables/dot1x/dot1x_wlanprofile.go b/tables/dot1x/dot1x_wlanprofile.go index 7f82de4..6a6956b 100644 --- a/tables/dot1x/dot1x_wlanprofile.go +++ b/tables/dot1x/dot1x_wlanprofile.go @@ -10,124 +10,98 @@ import ( "strings" ) -// readCharData consumes tokens until the end of the element the decoder is -// currently positioned inside, returning the concatenated direct character -// data (text in nested child elements is ignored). It must be called -// immediately after reading a StartElement. -func readCharData(dec *xml.Decoder) (string, bool) { - var sb strings.Builder - depth := 0 - for { - tok, err := dec.Token() - if err != nil { - return "", false - } - switch t := tok.(type) { - case xml.StartElement: - depth++ - case xml.CharData: - if depth == 0 { - sb.Write(t) - } - case xml.EndElement: - if depth == 0 { - return sb.String(), true - } - depth-- - } - } -} - -// readIntCharData is readCharData parsed as a base-10 int. -func readIntCharData(dec *xml.Decoder) (int, bool) { - s, ok := readCharData(dec) - if !ok { - return 0, false - } - v, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil { - return 0, false - } - return v, true +// wlanProfileInfo holds the 802.1X-relevant fields parsed from a Windows WLAN +// profile XML. Numeric fields are -1 when absent/invalid. +type wlanProfileInfo struct { + eapType int // outer EAP method type (first ) + innerEAPType int // inner/tunneled EAP method type (second ) + authMode int // EAPOLControlMode mapped from + trustedRootCASHA1 string // comma-separated colon-delimited SHA-1 thumbprints } -// firstElementText returns the character data of the first element whose local -// name matches local (namespace prefix / xmlns attributes are ignored). -func firstElementText(xmlStr, local string) (string, bool) { +// parseWLANProfile extracts every 802.1X field from a WLAN profile XML in a +// single token pass. Matching is by local element name, so namespace prefixes +// and attributes on elements are tolerated. The outer EAP type is the +// inside the first ; the inner type is the inside the second +// (tunneled methods like PEAP/EAP-TTLS); authMode is the first +// ; trusted root CA thumbprints are every valid 40-hex-char +// (comma-joined). +func parseWLANProfile(xmlStr string) wlanProfileInfo { + info := wlanProfileInfo{eapType: -1, innerEAPType: -1, authMode: -1} dec := xml.NewDecoder(strings.NewReader(xmlStr)) + eapMethodCount := 0 + gotAuthMode := false + var caHashes []string for { tok, err := dec.Token() if err != nil { - return "", false + break + } + se, ok := tok.(xml.StartElement) + if !ok { + continue } - if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local { - return readCharData(dec) + switch se.Name.Local { + case "EapMethod": + eapMethodCount++ + if t, ok := readEapMethodType(dec); ok { + switch eapMethodCount { + case 1: + info.eapType = t + case 2: + info.innerEAPType = t + } + } + case "authMode": + if !gotAuthMode { + if s, ok := readCharData(dec); ok { + info.authMode = mapAuthMode(strings.TrimSpace(s)) + gotAuthMode = true + } + } + case "TrustedRootCA": + if s, ok := readCharData(dec); ok { + // A SHA-1 thumbprint is exactly 40 hex chars; require valid hex + // so malformed content isn't emitted as a bogus fingerprint. + hex := strings.Join(strings.Fields(s), "") + if len(hex) == 40 && isHexString(hex) { + caHashes = append(caHashes, formatSHA1Hex(hex)) + } + } } } + info.trustedRootCASHA1 = strings.Join(caHashes, ",") + return info } -// eapMethodTypeFromXML returns the integer nested inside the -// occurrence-th element (1 = outer EAP method, 2 = inner method -// used by tunneled auth such as PEAP/EAP-TTLS). Matching is by local element -// name, so namespace prefixes and attributes on the elements are tolerated. -// Returns -1 when the requested EapMethod or its Type is absent or malformed. -func eapMethodTypeFromXML(xmlStr string, occurrence int) int { - dec := xml.NewDecoder(strings.NewReader(xmlStr)) - methodCount := 0 - depth := 0 - inTarget := false +// readEapMethodType reads forward from just after an StartElement +// and returns the int value of the first nested within it, or +// (0, false) if the element closes first or the value is non-numeric. +func readEapMethodType(dec *xml.Decoder) (int, bool) { + depth := 1 // we are inside the EapMethod element for { tok, err := dec.Token() if err != nil { - return -1 + return 0, false } switch t := tok.(type) { case xml.StartElement: - if t.Name.Local == "EapMethod" && !inTarget { - methodCount++ - if methodCount == occurrence { - inTarget = true - depth = 1 - } - continue - } - if inTarget { - depth++ - if t.Name.Local == "Type" { - if v, ok := readIntCharData(dec); ok { - return v - } - return -1 - } + if t.Name.Local == "Type" { + return readIntCharData(dec) } + depth++ case xml.EndElement: - if inTarget { - depth-- - if depth == 0 { - return -1 // left the target EapMethod without a Type - } + depth-- + if depth == 0 { + return 0, false // left the EapMethod without a Type } } } } -// extractEAPTypeFromXML parses the outer EAP method type from a WLAN profile -// XML (the inside the first ). -func extractEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 1) } - -// extractInnerEAPTypeFromXML parses the inner EAP method type used by tunneled -// methods like PEAP (25) or EAP-TTLS (21): the inside the second -// . -func extractInnerEAPTypeFromXML(xmlStr string) int { return eapMethodTypeFromXML(xmlStr, 2) } - -// extractAuthModeFromXML parses from the OneX section of a WLAN -// profile XML and maps it to an EAPOLControlMode value. -func extractAuthModeFromXML(xmlStr string) int { - s, ok := firstElementText(xmlStr, "authMode") - if !ok { - return -1 - } - switch strings.TrimSpace(s) { +// mapAuthMode maps a WLAN profile value to an EAPOLControlMode. +func mapAuthMode(s string) int { + switch s { case "machine": return 3 // System case "user": @@ -141,34 +115,45 @@ func extractAuthModeFromXML(xmlStr string) int { } } -// extractTrustedRootCAFromXML parses hex hashes from the -// ServerValidation section of a WLAN profile XML. These are the SHA-1 -// fingerprints of the CA certificates configured for RADIUS server validation. -// All whitespace (spaces, newlines, tabs from pretty-printed XML) is stripped -// before the length check. Multiple hashes are comma-separated with -// colon-delimited hex pairs. -func extractTrustedRootCAFromXML(xmlStr string) string { - dec := xml.NewDecoder(strings.NewReader(xmlStr)) - var hashes []string +// readCharData consumes tokens until the end of the element the decoder is +// currently positioned inside, returning the concatenated direct character +// data (text in nested child elements is ignored). It must be called +// immediately after reading a StartElement. +func readCharData(dec *xml.Decoder) (string, bool) { + var sb strings.Builder + depth := 0 for { tok, err := dec.Token() if err != nil { - break + return "", false } - if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "TrustedRootCA" { - text, ok := readCharData(dec) - if !ok { - continue + switch t := tok.(type) { + case xml.StartElement: + depth++ + case xml.CharData: + if depth == 0 { + sb.Write(t) } - hex := strings.Join(strings.Fields(text), "") - // A SHA-1 thumbprint is exactly 40 hex chars; require valid hex so - // malformed profile content isn't emitted as a bogus fingerprint. - if len(hex) == 40 && isHexString(hex) { - hashes = append(hashes, formatSHA1Hex(hex)) + case xml.EndElement: + if depth == 0 { + return sb.String(), true } + depth-- } } - return strings.Join(hashes, ",") +} + +// readIntCharData is readCharData parsed as a base-10 int. +func readIntCharData(dec *xml.Decoder) (int, bool) { + s, ok := readCharData(dec) + if !ok { + return 0, false + } + v, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0, false + } + return v, true } // isHexString reports whether s consists solely of hexadecimal digits. diff --git a/tables/dot1x/dot1x_wlanprofile_test.go b/tables/dot1x/dot1x_wlanprofile_test.go index 80594bf..f225ce6 100644 --- a/tables/dot1x/dot1x_wlanprofile_test.go +++ b/tables/dot1x/dot1x_wlanprofile_test.go @@ -88,7 +88,7 @@ const peapProfileXML = ` ` -func TestExtractEAPTypeFromXML(t *testing.T) { +func TestParseWLANProfileEAPType(t *testing.T) { t.Parallel() tests := []struct { @@ -112,13 +112,13 @@ func TestExtractEAPTypeFromXML(t *testing.T) { tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := extractEAPTypeFromXML(tc.xml) + got := parseWLANProfile(tc.xml).eapType assert.Equal(t, tc.want, got) }) } } -func TestExtractAuthModeFromXML(t *testing.T) { +func TestParseWLANProfileAuthMode(t *testing.T) { t.Parallel() tests := []struct { @@ -140,13 +140,13 @@ func TestExtractAuthModeFromXML(t *testing.T) { tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := extractAuthModeFromXML(tc.xml) + got := parseWLANProfile(tc.xml).authMode assert.Equal(t, tc.want, got) }) } } -func TestExtractInnerEAPTypeFromXML(t *testing.T) { +func TestParseWLANProfileInnerEAPType(t *testing.T) { t.Parallel() tests := []struct { @@ -165,13 +165,13 @@ func TestExtractInnerEAPTypeFromXML(t *testing.T) { tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := extractInnerEAPTypeFromXML(tc.xml) + got := parseWLANProfile(tc.xml).innerEAPType assert.Equal(t, tc.want, got) }) } } -func TestExtractTrustedRootCAFromXML(t *testing.T) { +func TestParseWLANProfileTrustedRootCA(t *testing.T) { t.Parallel() tests := []struct { @@ -228,7 +228,7 @@ func TestExtractTrustedRootCAFromXML(t *testing.T) { tc := tc // Go 1.22+ scopes this per-iteration; explicit for the linter. t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := extractTrustedRootCAFromXML(tc.xml) + got := parseWLANProfile(tc.xml).trustedRootCASHA1 assert.Equal(t, tc.want, got) }) } From e968c8edc0affb42666b7a2abdf9a8669e1f90ef Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 19:07:17 -0400 Subject: [PATCH 34/36] dot1x/build: robust EapMethod scan, right-sized hex buf, trap temp-file cleanup - readEapMethodType now consumes through the matching (capturing the first ) instead of returning mid-element, so a nested can't be miscounted as the second method and the single-pass scan stays aligned. Verified the parser tests are unchanged. - formatSHA1Hex returns "" early for empty input and sizes Grow() to the actual input length instead of a fixed 59 bytes, avoiding allocations on the empty/short/invalid cases. - bazel_to_builddir.sh: install a `trap 'rm -f "$err"' RETURN` so the temp stderr file is removed on every return path (including errors added later / abrupt exit under set -e), replacing the scattered manual rm -f calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- tables/dot1x/dot1x_wlanprofile.go | 25 +++++++++++++++++-------- tools/bazel_to_builddir.sh | 5 +++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tables/dot1x/dot1x_wlanprofile.go b/tables/dot1x/dot1x_wlanprofile.go index 6a6956b..426b9d5 100644 --- a/tables/dot1x/dot1x_wlanprofile.go +++ b/tables/dot1x/dot1x_wlanprofile.go @@ -75,25 +75,34 @@ func parseWLANProfile(xmlStr string) wlanProfileInfo { } // readEapMethodType reads forward from just after an StartElement -// and returns the int value of the first nested within it, or -// (0, false) if the element closes first or the value is non-numeric. +// and returns the int value of the first nested within it. It always +// consumes through the matching before returning, so the caller's +// scan stays aligned and any nested is swallowed here rather than +// being miscounted as a separate method. Returns (0, false) if no numeric +// was found. func readEapMethodType(dec *xml.Decoder) (int, bool) { depth := 1 // we are inside the EapMethod element + value, found := 0, false for { tok, err := dec.Token() if err != nil { - return 0, false + return value, found } switch t := tok.(type) { case xml.StartElement: - if t.Name.Local == "Type" { - return readIntCharData(dec) + if !found && t.Name.Local == "Type" { + if v, ok := readIntCharData(dec); ok { + value, found = v, true + } + // readIntCharData consumed this element through its , + // so depth is unchanged; keep scanning to the EapMethod's end. + continue } depth++ case xml.EndElement: depth-- if depth == 0 { - return 0, false // left the EapMethod without a Type + return value, found // consumed the whole EapMethod } } } @@ -171,12 +180,12 @@ func isHexString(s string) bool { // (e.g. "aabb..." -> "aa:bb:..."). Returns "" for odd-length input rather than // panicking on the trailing 2-char slice. func formatSHA1Hex(hex string) string { - if len(hex)%2 != 0 { + if len(hex) == 0 || len(hex)%2 != 0 { return "" } hex = strings.ToLower(hex) var buf strings.Builder - buf.Grow(59) + buf.Grow(len(hex) + len(hex)/2) // hex chars + colon separators for i := 0; i < len(hex); i += 2 { if i > 0 { buf.WriteByte(':') diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index b3650a2..b74c909 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -21,6 +21,9 @@ copy_bazel_output() { # Template form works on both GNU and BSD/macOS mktemp (a bare `mktemp` # with no template is not portable to BSD). err="$(mktemp "${TMPDIR:-/tmp}/bazel_to_builddir.XXXXXX")" + # Remove the temp file on every return path (including errors added later + # or an abrupt exit), so it can't leak under `set -e`. + trap 'rm -f "$err"' RETURN # Capture stderr and the exit code so an actual bazel/cquery failure # (missing bazel, bad workspace, query error) is reported distinctly from # a successful-but-empty result. The `&& rc=0 || rc=$?` idiom records the @@ -29,10 +32,8 @@ copy_bazel_output() { if [ "$rc" -ne 0 ]; then echo "error: 'bazel cquery' failed for ${target} (exit ${rc}):" >&2 cat "$err" >&2 - rm -f "$err" return 1 fi - rm -f "$err" if [ -z "$file" ]; then echo "error: no output file for ${target} (target produced no outputs)" >&2 return 1 From c47befc314c4627e86ecf78df2a3d65a49cd1ade Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 19:14:33 -0400 Subject: [PATCH 35/36] bazel_to_builddir.sh: scope temp-file cleanup to a subshell EXIT trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the function-level RETURN trap (which is global in bash, persists after the function returns, and doesn't fire on a set -e abort) with a subshell that owns its own EXIT trap. The trap fires on every exit path — normal, set -e, or signal — removes the temp file, and never touches the parent shell's global traps. Verified end-to-end: the script copies all five binaries with no leftover temp files and no global trap left installed. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/bazel_to_builddir.sh | 79 ++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index b74c909..8172546 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -17,43 +17,48 @@ copy_bazel_output() { echo "usage: copy_bazel_output TARGET DEST" >&2 return 2 fi - local target="$1" dest="$2" file lines err rc - # Template form works on both GNU and BSD/macOS mktemp (a bare `mktemp` - # with no template is not portable to BSD). - err="$(mktemp "${TMPDIR:-/tmp}/bazel_to_builddir.XXXXXX")" - # Remove the temp file on every return path (including errors added later - # or an abrupt exit), so it can't leak under `set -e`. - trap 'rm -f "$err"' RETURN - # Capture stderr and the exit code so an actual bazel/cquery failure - # (missing bazel, bad workspace, query error) is reported distinctly from - # a successful-but-empty result. The `&& rc=0 || rc=$?` idiom records the - # exit code without tripping `set -e`. - file="$(bazel cquery --output=files "$target" 2>"$err")" && rc=0 || rc=$? - if [ "$rc" -ne 0 ]; then - echo "error: 'bazel cquery' failed for ${target} (exit ${rc}):" >&2 - cat "$err" >&2 - return 1 - fi - if [ -z "$file" ]; then - echo "error: no output file for ${target} (target produced no outputs)" >&2 - return 1 - fi - # Reject multi-path output (one path per line). wc -l is used instead of - # `grep -c`, which exits non-zero on zero matches and would trip `set -e`. - lines="$(printf '%s\n' "$file" | wc -l | tr -d '[:space:]')" - if [ "$lines" -ne 1 ]; then - echo "error: expected exactly one output file for ${target}, got ${lines}:" >&2 - printf '%s\n' "$file" >&2 - return 1 - fi - # Guard against an output path that begins with `-` being parsed as a cp - # option by prefixing `./`. This is portable across GNU and BSD/macOS cp, - # whereas `--` end-of-options is not universally honored on BSD. dest is - # always a build/ path, so only the source needs guarding. - case "$file" in - -*) file="./$file" ;; - esac - cp "$file" "$dest" + # Run the body in a subshell with an EXIT trap, so the temp file is removed + # on every exit path (normal, error under set -e, or signal) and the trap + # is scoped to this invocation — it never alters the parent shell's global + # traps (unlike a RETURN trap, which would persist after the function). + ( + set -euo pipefail + # Template form works on both GNU and BSD/macOS mktemp (a bare `mktemp` + # with no template is not portable to BSD). + err="$(mktemp "${TMPDIR:-/tmp}/bazel_to_builddir.XXXXXX")" + trap 'rm -f "$err"' EXIT + + # Capture stderr and the exit code so an actual bazel/cquery failure + # (missing bazel, bad workspace, query error) is reported distinctly + # from a successful-but-empty result. The `&& rc=0 || rc=$?` idiom + # records the exit code without tripping `set -e`. + file="$(bazel cquery --output=files "$1" 2>"$err")" && rc=0 || rc=$? + if [ "$rc" -ne 0 ]; then + echo "error: 'bazel cquery' failed for $1 (exit ${rc}):" >&2 + cat "$err" >&2 + exit 1 + fi + if [ -z "$file" ]; then + echo "error: no output file for $1 (target produced no outputs)" >&2 + exit 1 + fi + # Reject multi-path output (one path per line). wc -l is used instead + # of `grep -c`, which exits non-zero on zero matches and trips set -e. + lines="$(printf '%s\n' "$file" | wc -l | tr -d '[:space:]')" + if [ "$lines" -ne 1 ]; then + echo "error: expected exactly one output file for $1, got ${lines}:" >&2 + printf '%s\n' "$file" >&2 + exit 1 + fi + # Guard against an output path that begins with `-` being parsed as a + # cp option by prefixing `./`. Portable across GNU and BSD/macOS cp, + # whereas `--` end-of-options is not universally honored on BSD. dest + # is always a build/ path, so only the source needs guarding. + case "$file" in + -*) file="./$file" ;; + esac + cp "$file" "$2" + ) } # Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). From f9087b9cdba554fff25861c81ac327fb0aa0e90e Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 10 Jun 2026 19:20:42 -0400 Subject: [PATCH 36/36] bazel_to_builddir.sh: tolerate missing uname; BUILD: fix stale gating comment - Compute host_os := uname -s (stderr suppressed, failure tolerated via `|| true`) once and gate the Darwin copy on it, so `set -euo pipefail` doesn't abort the Linux/Windows artifact copies in environments where uname is unavailable (matching the Makefile's UNAME_S approach). - Update the root BUILD.bazel comment that still referenced the old `ifeq ($(shell uname),Darwin)` gating to reflect the current `ifeq ($(UNAME_S),Darwin)` / `[ "$host_os" = "Darwin" ]` checks. Co-Authored-By: Claude Opus 4.8 (1M context) --- BUILD.bazel | 4 ++-- tools/bazel_to_builddir.sh | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 88043da..4f10636 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -72,8 +72,8 @@ go_library( # hosts), so they are tagged "manual" to keep them out of `//...` / `:all` # wildcard expansion. That way `bazel build //...` / `bazel test //...` on a # Linux/Windows host won't try to analyze them and fail; they still build when -# named explicitly (the Makefile and tools/bazel_to_builddir.sh do so, gated -# behind `ifeq ($(shell uname),Darwin)`). +# named explicitly (the Makefile and tools/bazel_to_builddir.sh do so, gated on +# the host OS via `ifeq ($(UNAME_S),Darwin)` and `[ "$host_os" = "Darwin" ]`). # # target_compatible_with is not used here: the goos="darwin" platform # transition makes the resolved target platform macOS, so a macos constraint diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index 8172546..241e2cc 100755 --- a/tools/bazel_to_builddir.sh +++ b/tools/bazel_to_builddir.sh @@ -7,6 +7,11 @@ mkdir -p build/windows APP_NAME="macadmins_extension" +# Captured once with stderr suppressed and failure tolerated (some Windows +# shells / CI images lack uname), so `set -e` can't abort the Linux/Windows +# copies below when uname is unavailable. Empty value => not Darwin. +host_os="$(uname -s 2>/dev/null || true)" + # copy_bazel_output TARGET DEST copies the single output file of a bazel # target to DEST. It captures the cquery result, validates that exactly one # non-empty path was returned, and quotes the path so files with whitespace @@ -62,7 +67,7 @@ copy_bazel_output() { } # Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). -if [ "$(uname)" = "Darwin" ]; then +if [ "$host_os" = "Darwin" ]; then copy_bazel_output //:osquery-extension-mac-amd "build/darwin/${APP_NAME}.amd64.ext" copy_bazel_output //:osquery-extension-mac-arm "build/darwin/${APP_NAME}.arm64.ext" fi