diff --git a/.testcoverage.yml b/.testcoverage.yml index 6082a01..4dc90ab 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -60,6 +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 # - \.pb\.go$ # excludes all protobuf generated files # - ^pkg/bar # exclude package `pkg/bar` diff --git a/BUILD.bazel b/BUILD.bazel index c3b8b2e..4f10636 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -41,6 +41,7 @@ go_library( "//tables/authdb", "//tables/chromeuserprofiles", "//tables/crowdstrike_falcon", + "//tables/dot1x", "//tables/energyimpact", "//tables/fileline", "//tables/filevaultusers", @@ -62,19 +63,40 @@ go_library( ], ) +# 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. +# +# 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 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 +# would always be satisfied and would never skip the target on a Linux host. go_binary( name = "osquery-extension-mac-arm", + cgo = True, embed = [":osquery-extension_lib"], goarch = "arm64", goos = "darwin", + pure = "off", + tags = ["manual"], visibility = ["//visibility:public"], ) go_binary( name = "osquery-extension-mac-amd", + cgo = True, embed = [":osquery-extension_lib"], goarch = "amd64", goos = "darwin", + pure = "off", + tags = ["manual"], visibility = ["//visibility:public"], ) diff --git a/Makefile b/Makefile index 54ca489..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 @@ -50,11 +54,25 @@ update-repos: bazel run //:gazelle-update-repos -- -from_file=go.mod test: - bazel test --test_output=errors //... + # Query only test targets (any *_test kind) rather than `bazel test //...`, + # 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. 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 ($(UNAME_S),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 diff --git a/README.md b/README.md index ba6ee57..2f4c020 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. | +| `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 a9287a8..8b29a95 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 dot1x table calls EAP8021X.framework (CoreFoundation) via cgo, +# so the macOS go_binary targets are built with cgo enabled (see +# 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. +# +# 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/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/main.go b/main.go index 7e9f7b5..345ed2a 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/dot1x" "github.com/macadmins/osquery-extension/tables/energyimpact" "github.com/macadmins/osquery-extension/tables/fileline" "github.com/macadmins/osquery-extension/tables/filevaultusers" @@ -77,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" { + dot1xPlugins := []osquery.OsqueryPlugin{ + table.NewPlugin("dot1x", dot1x.Dot1XStatusColumns(), dot1x.Dot1XStatusGenerate), + } + plugins = append(plugins, dot1xPlugins...) + } if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { linuxPlugins := []osquery.OsqueryPlugin{ diff --git a/tables/dot1x/BUILD.bazel b/tables/dot1x/BUILD.bazel new file mode 100644 index 0000000..e33e7ff --- /dev/null +++ b/tables/dot1x/BUILD.bazel @@ -0,0 +1,55 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "dot1x", + srcs = [ + "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 + # 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/dot1x", + 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( + name = "dot1x_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", + ], + embed = [":dot1x"], + deps = [ + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/tables/dot1x/dot1x.go b/tables/dot1x/dot1x.go new file mode 100644 index 0000000..dc99254 --- /dev/null +++ b/tables/dot1x/dot1x.go @@ -0,0 +1,476 @@ +package dot1x + +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" +) + +// 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 + 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 + InnerEAPType int // inner EAP method for tunneled auth (PEAP/TTLS) + InnerEAPTypeName string // human-readable inner EAP method + LastStatusTimestamp string // ISO 8601 + UniqueIdentifier string +} + +// 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) +} + +// 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{ + 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", +} + +// Dot1XStatusColumns returns the column definitions. +func Dot1XStatusColumns() []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.TextColumn("tls_trusted_root_ca_sha1"), + 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"), + } +} + +// 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) +} + +// 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 Dot1XBackend, queryContext table.QueryContext) ([]map[string]string, error) { + ifaces := interfacesToQuery(backend, 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 +} + +// 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(backend Dot1XBackend, 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 + } + } + + // Prefer the backend's own enumeration when it provides one (the Windows + // 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 + } + fallback := make([]string, 10) + for i := range fallback { + fallback[i] = "en" + strconv.Itoa(i) + } + return fallback +} + +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_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), + "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/dot1x/dot1x_darwin.go b/tables/dot1x/dot1x_darwin.go new file mode 100644 index 0000000..d301939 --- /dev/null +++ b/tables/dot1x/dot1x_darwin.go @@ -0,0 +1,478 @@ +//go:build darwin + +package dot1x + +/* +#include +#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; + +// 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) { + 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; + 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. 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; + CFIndex len = CFDataGetLength((CFDataRef)item); + if (len > 0xffffffff) continue; // exceeds 4-byte packed format + total += 4 + len; + } + 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; +} + +// dot1x_query calls EAPOLControlCopyStateAndStatus for the given interface +// 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, + 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); + // 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; + + // 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" + "strconv" + "sync" + "unsafe" +) + +// productionBackend calls EAPOLControlCopyStateAndStatus via cgo. +type productionBackend struct{} + +var loadOnce sync.Once + +func newBackend() Dot1XBackend { + loadOnce.Do(func() { C.load_dot1x() }) + return productionBackend{} +} + +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 + ) + + ret := C.dot1x_query( + cName, + &cState, + &cSupplicantState, + &cEAPType, + &cEAPTypeName, + &cClientStatus, + &cDomainError, + &cAuthMAC, + &cAuthMACLen, + &cMode, + &cTLSResumed, + &cCertChainData, + &cCertChainLen, + &cTLSTrustStatus, + &cTLSCipher, + &cTLSProtoVersion, + &cInnerEAPType, + &cInnerEAPTypeName, + &cLastTimestamp, + &cUniqueID, + ) + + 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, + } + + 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 { + 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) + } + 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) + } + + 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/dot1x/dot1x_darwin_test.go b/tables/dot1x/dot1x_darwin_test.go new file mode 100644 index 0000000..b7ab6e2 --- /dev/null +++ b/tables/dot1x/dot1x_darwin_test.go @@ -0,0 +1,275 @@ +//go:build darwin + +package dot1x + +import ( + "context" + "testing" + + "github.com/osquery/osquery-go/plugin/table" + "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_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. + // 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") +} + +// --- Mock-based integration tests using the shared Dot1XBackend interface --- +// +// These exercise the full generateRows -> rowFromStatus path with canned +// 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 +// backend populates but the Windows backend does not. + +func TestDarwinMockBackendSystemEAPTLS(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + statuses: map[string]Dot1XStatus{ + "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(context.Background(), 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 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]Dot1XStatus{ + "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(context.Background(), 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]Dot1XStatus{ + "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(context.Background(), 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]Dot1XStatus{ + "en0": { + Interface: "en0", + State: 2, + SupplicantState: 4, + EAPType: 99, + InnerEAPType: -1, + Mode: 3, + }, + }, + } + + 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"]) + 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]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}, + }, + } + + 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) + 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]Dot1XStatus{}} + rows, err := generateRows(context.Background(), 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(context.Background(), backend, constraintFor("en0")) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Empty(t, rows) +} 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_other.go b/tables/dot1x/dot1x_other.go new file mode 100644 index 0000000..2c9a854 --- /dev/null +++ b/tables/dot1x/dot1x_other.go @@ -0,0 +1,26 @@ +//go:build !darwin && !windows + +package dot1x + +import ( + "fmt" + "strconv" +) + +func newBackend() Dot1XBackend { + return noopBackend{} +} + +type noopBackend struct{} + +func (noopBackend) GetStatus(ifname string) (Dot1XStatus, error) { + return Dot1XStatus{Interface: ifname}, fmt.Errorf("%w: not supported 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/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/tables/dot1x/dot1x_test.go b/tables/dot1x/dot1x_test.go new file mode 100644 index 0000000..50293ae --- /dev/null +++ b/tables/dot1x/dot1x_test.go @@ -0,0 +1,704 @@ +package dot1x + +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 Dot1XBackend for tests. +type fakeBackend struct { + statuses map[string]Dot1XStatus +} + +func (f fakeBackend) GetStatus(ifname string) (Dot1XStatus, error) { + s, ok := f.statuses[ifname] + if ok { + return s, nil + } + 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{ + "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_trusted_root_ca_sha1", + "tls_trust_client_status", + "tls_negotiated_protocol_version", + "tls_negotiated_cipher", + "inner_eap_type", + "inner_eap_type_name", + "last_status_timestamp", + "unique_identifier", + } + cols := Dot1XStatusColumns() + 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 uses backend-provided defaults", func(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorEquals, Expression: "en0"}, + }, + }, + }, + } + ifaces := interfacesToQuery(fakeBackend{}, qc) + assert.Equal(t, []string{"en0"}, ifaces) + }) + + t.Run("with LIKE constraint falls back to defaults", func(t *testing.T) { + t.Parallel() + qc := table.QueryContext{ + Constraints: map[string]table.ConstraintList{ + "interface": { + Constraints: []table.Constraint{ + {Operator: table.OperatorLike, Expression: "en%"}, + }, + }, + }, + } + // 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) { + 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(fakeBackend{}, qc) + assert.Equal(t, []string{"en0", "en1"}, ifaces) + }) +} + +func TestRowFromStatus(t *testing.T) { + t.Parallel() + + 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", + } + + 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 := Dot1XStatus{ + 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 := Dot1XStatus{ + 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]Dot1XStatus{ + "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]Dot1XStatus{}} + 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]Dot1XStatus{ + "en0": { + Interface: "en0", + State: 2, + SupplicantState: 4, + }, + }, + } + + 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) + 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) (Dot1XStatus, error) { + return Dot1XStatus{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 TestDot1XStatusGenerate(t *testing.T) { + t.Parallel() + + rows, err := generateRows(context.Background(), fakeBackend{statuses: map[string]Dot1XStatus{}}, 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 := Dot1XStatus{Interface: "en0", EAPType: 13} + row := rowFromStatus(s) + assert.Equal(t, "EAP-TLS", row["eap_type_name"]) + + // EAPTypeName set explicitly takes precedence + s2 := Dot1XStatus{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) + }) + } + }) +} diff --git a/tables/dot1x/dot1x_windows.go b/tables/dot1x/dot1x_windows.go new file mode 100644 index 0000000..50caa3c --- /dev/null +++ b/tables/dot1x/dot1x_windows.go @@ -0,0 +1,447 @@ +//go:build windows + +package dot1x + +import ( + "fmt" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +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 ( + // 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") + procWlanQueryInterface = modWlanapi.NewProc("WlanQueryInterface") + procWlanGetProfile = modWlanapi.NewProc("WlanGetProfile") + procWlanFreeMemory = modWlanapi.NewProc("WlanFreeMemory") +) + +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 + // reusing one handle avoids an open/close round-trip on every GetStatus. + wlanHandle uintptr +) + +func initWlan() { + if err := modWlanapi.Load(); err != nil { + wlanInitErr = fmt.Errorf("loading wlanapi.dll: %w", err) + return + } + for _, p := range []*windows.LazyProc{ + procWlanOpenHandle, procWlanEnumInterfaces, + 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 + wlanAvail = true +} + +// 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 (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 +} + +func newBackend() Dot1XBackend { + wlanOnce.Do(initWlan) + if !wlanAvail { + return unavailableBackend{} + } + return &windowsBackend{handle: wlanHandle} +} + +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: Windows WLAN backend unavailable", 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: %w", syscall.Errno(ret)) + } + return handle, nil +} + +func freeWlanMemory(p uintptr) { + procWlanFreeMemory.Call(p) //nolint:errcheck +} + +// 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 { + 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) + infos := make(map[string]ifaceInfo, list.NumberOfItems) + names := make([]string, 0, list.NumberOfItems) + + 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[:]) + 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) + if !wlanAvail { + return nil + } + _, names, err := enumerateWlanInterfaceInfos(wlanHandle) + if err != nil { + return nil + } + return names +} + +func defaultInterfaces() []string { + // 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 +// 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.names, b.enumErr = enumerateWlanInterfaceInfos(b.handle) + }) + 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() + if err != nil { + // Enumeration failing is systemic (affects every interface), so report + // 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 { + return Dot1XStatus{Interface: ifname}, fmt.Errorf("wireless interface %q not found", ifname) + } + guid := info.guid + ifState := info.state + + s := Dot1XStatus{ + 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 + var opcodeValueType uint32 + ret, _, _ := procWlanQueryInterface.Call( + b.handle, + uintptr(unsafe.Pointer(&guid)), + uintptr(wlanIntfOpcodeCurrentConnection), + 0, + uintptr(unsafe.Pointer(&dataSize)), + uintptr(unsafe.Pointer(&dataPtr)), + uintptr(unsafe.Pointer(&opcodeValueType)), + ) + 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)) + + // 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[:]) + + sec := conn.SecurityAttributes + if sec.OneXEnabled != 0 { + if ifState == wlanIfaceStateConnected { + s.SupplicantState = 4 // Authenticated + s.ClientStatus = 0 + } + } else { + s.SupplicantState = 0 // not an 802.1X network + } + + profileName := utf16ToString(conn.ProfileName[:]) + if profileName != "" { + if xmlStr, err := getWlanProfileXML(b.handle, &guid, profileName); err == nil { + profile := parseWLANProfile(xmlStr) // single pass over the XML + if profile.eapType > 0 { + s.EAPType = profile.eapType + } + if profile.authMode >= 0 { + s.Mode = profile.authMode + } + 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 profile.trustedRootCASHA1 != "" { + s.TLSTrustedRootCASHA1 = profile.trustedRootCASHA1 + } + } + } + + 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 { + 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 +} + +func utf16PtrToString(p *uint16) string { + if p == nil { + return "" + } + return windows.UTF16PtrToString(p) +} + +// 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/dot1x/dot1x_windows_test.go b/tables/dot1x/dot1x_windows_test.go new file mode 100644 index 0000000..8ca8616 --- /dev/null +++ b/tables/dot1x/dot1x_windows_test.go @@ -0,0 +1,383 @@ +//go:build windows + +package dot1x + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- 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 { + 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) + 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 { + 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) + 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])) + }) +} + +// --- 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) { + 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 Dot1XBackend interface --- + +func TestWindowsMockBackendConnected(t *testing.T) { + t.Parallel() + + backend := fakeBackend{ + 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, + 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, + InnerEAPType: -1, + }, + }, + } + + qc := constraintFor("RZ616 Wi-Fi 6E 160MHz") + rows, err := generateRows(context.Background(), 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"]) + // 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"]) + 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]Dot1XStatus{ + "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(context.Background(), 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]Dot1XStatus{ + "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(context.Background(), 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]Dot1XStatus{}} + qc := constraintFor("nonexistent adapter") + rows, err := generateRows(context.Background(), 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(context.Background(), backend, qc) + assert.ErrorIs(t, err, ErrBackendUnavailable) + assert.Empty(t, rows) +} + +// --- 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 { + 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 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") + assert.GreaterOrEqual(t, s.State, 0) + assert.LessOrEqual(t, s.State, 3) + } +} + +func TestWindowsLiveBackendBogusInterface(t *testing.T) { + requireLiveTests(t) + backend := newBackend() + + if _, ok := backend.(unavailableBackend); ok { + t.Skip("wlanapi.dll not available on this system") + } + + _, err := backend.GetStatus("nonexistent_adapter_999") + if errors.Is(err, ErrBackendUnavailable) { + t.Skipf("WLAN service unavailable: %v", err) + } + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent_adapter_999") +} + +// --- Real profile XML extraction (end-to-end on live system) --- + +func TestWindowsLiveProfileXMLExtraction(t *testing.T) { + requireLiveTests(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") +} diff --git a/tables/dot1x/dot1x_wlanprofile.go b/tables/dot1x/dot1x_wlanprofile.go new file mode 100644 index 0000000..426b9d5 --- /dev/null +++ b/tables/dot1x/dot1x_wlanprofile.go @@ -0,0 +1,196 @@ +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" +) + +// 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 +} + +// 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 { + break + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + 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 +} + +// readEapMethodType reads forward from just after an StartElement +// 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 value, found + } + switch t := tok.(type) { + case xml.StartElement: + 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 value, found // consumed the whole EapMethod + } + } + } +} + +// mapAuthMode maps a WLAN profile value to an EAPOLControlMode. +func mapAuthMode(s string) int { + switch s { + case "machine": + return 3 // System + case "user": + return 1 // User + case "machineOrUser": + return 2 // LoginWindow + case "guest": + return 0 // None + default: + 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-- + } + } +} + +// 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. +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) == 0 || len(hex)%2 != 0 { + return "" + } + hex = strings.ToLower(hex) + var buf strings.Builder + buf.Grow(len(hex) + len(hex)/2) // hex chars + colon separators + 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..f225ce6 --- /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 TestParseWLANProfileEAPType(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 := parseWLANProfile(tc.xml).eapType + assert.Equal(t, tc.want, got) + }) + } +} + +func TestParseWLANProfileAuthMode(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 := parseWLANProfile(tc.xml).authMode + assert.Equal(t, tc.want, got) + }) + } +} + +func TestParseWLANProfileInnerEAPType(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 := parseWLANProfile(tc.xml).innerEAPType + assert.Equal(t, tc.want, got) + }) + } +} + +func TestParseWLANProfileTrustedRootCA(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 := parseWLANProfile(tc.xml).trustedRootCASHA1 + 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")) +} diff --git a/tools/bazel_to_builddir.sh b/tools/bazel_to_builddir.sh index ea71341..241e2cc 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,14 +7,73 @@ 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 +# 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)" -cp $(bazel cquery --output=files //:osquery-extension-mac-arm 2>/dev/null) build/darwin/${APP_NAME}.arm64.ext +# 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() { + if [ "$#" -ne 2 ]; then + echo "usage: copy_bazel_output TARGET DEST" >&2 + return 2 + fi + # 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 -cp $(bazel cquery --output=files //:osquery-extension-linux-amd 2>/dev/null) build/linux/${APP_NAME}.amd64.ext + # 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" + ) +} -cp $(bazel cquery --output=files //:osquery-extension-linux-arm 2>/dev/null) build/linux/${APP_NAME}.arm64.ext +# Mac binaries only build on macOS hosts (require Apple C++ toolchain + cgo). +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 -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"