From 5a6bbed361deffdb1aa54a4fbe4e497bd99c552e Mon Sep 17 00:00:00 2001 From: Kain Zhou <26943220+kaincenteno@users.noreply.github.com> Date: Wed, 13 May 2026 15:13:06 -0700 Subject: [PATCH 1/2] Adds support for Netskope on MacOS Runs `nsdiag -f` and parses the output. nsdiag is installed with netskope --- BUILD.bazel | 1 + README.md | 1 + VERSION | 2 +- main.go | 2 + tables/netskope/BUILD.bazel | 25 +++++ tables/netskope/netskope.go | 105 +++++++++++++++++++ tables/netskope/netskope_test.go | 173 +++++++++++++++++++++++++++++++ 7 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 tables/netskope/BUILD.bazel create mode 100644 tables/netskope/netskope.go create mode 100644 tables/netskope/netskope_test.go diff --git a/BUILD.bazel b/BUILD.bazel index c3b8b2e..b1695e0 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -49,6 +49,7 @@ go_library( "//tables/macosrsr", "//tables/mdm", "//tables/munki", + "//tables/netskope", "//tables/networkquality", "//tables/pendingappleupdates", "//tables/puppet", diff --git a/README.md b/README.md index ba6ee57..83f02ce 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ For production deployment, you should refer to the [osquery documentation](https | `macos_thermal_pressure` | Reports whether macOS is [thermally throttling](https://developer.apple.com/documentation/foundation/processinfo/thermalstate) the device, via `powermetrics`. Returns `thermal_pressure` (Nominal/Light/Moderate/Heavy/Sleeping) and a derived `is_throttling` integer (1 if not Nominal). | macOS | Use the `interval` constraint to specify sampling duration in milliseconds (default: 1000). Requires root. | | `mdm` | Information on the device's MDM enrollment | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher). Due to changes in macOS 12.3, the output of `profiles show -type enrollment` can only be generated once a day. If you are running this command with another tool, you should set the `PROFILES_SHOW_ENROLLMENT_CACHE_PATH` environment variable to the path you are caching this. The cache file should be `json` with the keys `dep_capable` and `rate_limited` present, both booleans representing whether the device is capable of DEP enrollment and whether the response from `profiles show -type enrollment` is being rate limited or not. | | `munki_info` | Information from the last [Munki](https://github.com/munki/munki) run | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher) | +| `netskope` | Status and configuration information from the Netskope client via `nsdiag` | macOS | | `munki_installs` | Items [Munki](https://github.com/munki/munki) is managing | macOS | Code based on work by [Kolide](https://github.com/kolide/launcher) | | `network_quality` | Output from the `networkQuality` binary | macOS | This binary is only present on macOS 12 | | `puppet_facts` | [Puppet](https://puppetlabs.com) facts | Linux / macOS / Windows | | diff --git a/VERSION b/VERSION index 347f583..9df886c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.4.2 diff --git a/main.go b/main.go index 7e9f7b5..2cfac2d 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/macadmins/osquery-extension/tables/macosrsr" "github.com/macadmins/osquery-extension/tables/mdm" "github.com/macadmins/osquery-extension/tables/munki" + "github.com/macadmins/osquery-extension/tables/netskope" "github.com/macadmins/osquery-extension/tables/networkquality" "github.com/macadmins/osquery-extension/tables/pendingappleupdates" "github.com/macadmins/osquery-extension/tables/puppet" @@ -102,6 +103,7 @@ func main() { table.NewPlugin("mdm", mdm.MDMInfoColumns(), mdm.MDMInfoGenerate), table.NewPlugin("munki_info", munki.MunkiInfoColumns(), munki.MunkiInfoGenerate), table.NewPlugin("munki_installs", munki.MunkiInstallsColumns(), munki.MunkiInstallsGenerate), + table.NewPlugin("netskope", netskope.NetskopeColumns(), netskope.NetskopeGenerate), table.NewPlugin("network_quality", networkquality.NetworkQualityColumns(), networkquality.NetworkQualityGenerate), table.NewPlugin("pending_apple_updates", pendingappleupdates.PendingAppleUpdatesColumns(), pendingappleupdates.PendingAppleUpdatesGenerate), table.NewPlugin("macadmins_unified_log", unifiedlog.UnifiedLogColumns(), unifiedlog.UnifiedLogGenerate), diff --git a/tables/netskope/BUILD.bazel b/tables/netskope/BUILD.bazel new file mode 100644 index 0000000..3f520b5 --- /dev/null +++ b/tables/netskope/BUILD.bazel @@ -0,0 +1,25 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "netskope", + srcs = ["netskope.go"], + importpath = "github.com/macadmins/osquery-extension/tables/netskope", + visibility = ["//visibility:public"], + deps = [ + "//pkg/utils", + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_pkg_errors//:errors", + ], +) + +go_test( + name = "netskope_test", + srcs = ["netskope_test.go"], + embed = [":netskope"], + deps = [ + "//pkg/utils", + "@com_github_osquery_osquery_go//plugin/table", + "@com_github_pkg_errors//:errors", + "@com_github_stretchr_testify//assert", + ], +) diff --git a/tables/netskope/netskope.go b/tables/netskope/netskope.go new file mode 100644 index 0000000..8ad2c5a --- /dev/null +++ b/tables/netskope/netskope.go @@ -0,0 +1,105 @@ +package netskope + +import ( + "context" + "os" + "regexp" + "strings" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/osquery/osquery-go/plugin/table" + "github.com/pkg/errors" +) + +const nsdiag = "/Library/Application Support/Netskope/STAgent/nsdiag" + +var camelSplit = regexp.MustCompile(`([a-z])([A-Z])`) + +func NetskopeColumns() []table.ColumnDefinition { + return []table.ColumnDefinition{ + table.TextColumn("orgname"), + table.TextColumn("tenant_url"), + table.TextColumn("addon_host"), + table.TextColumn("addon_checker_host"), + table.TextColumn("gateway"), + table.TextColumn("gateway_ip"), + table.TextColumn("config"), + table.TextColumn("steering_config"), + table.TextColumn("email"), + table.TextColumn("peruser_config"), + table.TextColumn("tunnel_status"), + table.TextColumn("client_status"), + table.TextColumn("dynamic_steering"), + table.TextColumn("on_prem_detection"), + table.TextColumn("explicit_proxy"), + table.TextColumn("tunnel_protocol"), + table.TextColumn("sni_enable"), + table.TextColumn("traffic_mode"), + } +} + +func NetskopeGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { + r := utils.NewRunner() + fs := utils.OSFileSystem{} + return runNsdiag(r, fs) +} + +func runNsdiag(r utils.Runner, fs utils.FileSystem) ([]map[string]string, error) { + row := emptyRow() + + _, err := fs.Stat(nsdiag) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []map[string]string{row}, nil + } + return nil, errors.Wrap(err, "stat nsdiag binary") + } + + out, err := r.Runner.RunCmd(nsdiag, "-f") + if err != nil { + return nil, errors.Wrap(err, "run nsdiag") + } + parsed := parseNsdiagOutput(string(out)) + + for col, val := range parsed { + if _, known := row[col]; known { + row[col] = val + } + } + + return []map[string]string{row}, nil +} + +func parseNsdiagOutput(output string) map[string]string { + result := make(map[string]string) + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "::", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.TrimSuffix(val, ".") + val = strings.TrimSpace(val) + result[keyToColumn(key)] = val + } + return result +} + +func keyToColumn(key string) string { + key = camelSplit.ReplaceAllString(key, "${1}_${2}") + key = strings.ReplaceAll(key, " ", "_") + return strings.ToLower(key) +} + +func emptyRow() map[string]string { + row := make(map[string]string) + for _, col := range NetskopeColumns() { + row[col.Name] = "" + } + return row +} diff --git a/tables/netskope/netskope_test.go b/tables/netskope/netskope_test.go new file mode 100644 index 0000000..a9c2f5f --- /dev/null +++ b/tables/netskope/netskope_test.go @@ -0,0 +1,173 @@ +package netskope + +import ( + "testing" + + "github.com/macadmins/osquery-extension/pkg/utils" + "github.com/osquery/osquery-go/plugin/table" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +const sampleNsdiagOutput = `Orgname:: Talkaloid. +Tenant URL :: talkaloid-prod.goskope.com. +AddonHost:: addon-talkaloid-prod.goskope.com. +AddonCheckerHost:: achecker-talkaloid-prod.goskope.com. +Gateway:: gateway-talkaloid-prod.goskope.com. +Gateway IP:: 139.139.39.39. +Config:: All-Talkaloid_Tech. +Steering Config:: Talkaloid. +Email:: natsune.miku@talkaloid.com. +Peruser config:: FALSE. +Tunnel status:: NSTUNNEL_CONNECTED. +Client status:: enable. +Dynamic Steering:: FALSE. +OnPremDetection:: Not Configured. +Explicit Proxy:: false. +Tunnel Protocol:: TLS. +SNI Enable:: FALSE. +Traffic Mode:: All Web Traffic. +` + +func TestNetskopeColumns(t *testing.T) { + columns := NetskopeColumns() + expected := []table.ColumnDefinition{ + table.TextColumn("orgname"), + table.TextColumn("tenant_url"), + table.TextColumn("addon_host"), + table.TextColumn("addon_checker_host"), + table.TextColumn("gateway"), + table.TextColumn("gateway_ip"), + table.TextColumn("config"), + table.TextColumn("steering_config"), + table.TextColumn("email"), + table.TextColumn("peruser_config"), + table.TextColumn("tunnel_status"), + table.TextColumn("client_status"), + table.TextColumn("dynamic_steering"), + table.TextColumn("on_prem_detection"), + table.TextColumn("explicit_proxy"), + table.TextColumn("tunnel_protocol"), + table.TextColumn("sni_enable"), + table.TextColumn("traffic_mode"), + } + assert.Equal(t, expected, columns) +} + +func TestKeyToColumn(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Orgname", "orgname"}, + {"Tenant URL", "tenant_url"}, + {"AddonHost", "addon_host"}, + {"AddonCheckerHost", "addon_checker_host"}, + {"Gateway IP", "gateway_ip"}, + {"Steering Config", "steering_config"}, + {"Peruser config", "peruser_config"}, + {"Tunnel status", "tunnel_status"}, + {"Client status", "client_status"}, + {"OnPremDetection", "on_prem_detection"}, + {"Dynamic Steering", "dynamic_steering"}, + {"Explicit Proxy", "explicit_proxy"}, + {"Tunnel Protocol", "tunnel_protocol"}, + {"SNI Enable", "sni_enable"}, + {"Traffic Mode", "traffic_mode"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, keyToColumn(tt.input)) + }) + } +} + +func TestParseNsdiagOutput(t *testing.T) { + result := parseNsdiagOutput(sampleNsdiagOutput) + + assert.Equal(t, "Talkaloid", result["orgname"]) + assert.Equal(t, "talkaloid-prod.goskope.com", result["tenant_url"]) + assert.Equal(t, "addon-talkaloid-prod.goskope.com", result["addon_host"]) + assert.Equal(t, "achecker-talkaloid-prod.goskope.com", result["addon_checker_host"]) + assert.Equal(t, "gateway-talkaloid-prod.goskope.com", result["gateway"]) + assert.Equal(t, "139.139.39.39", result["gateway_ip"]) + assert.Equal(t, "TLS", result["tunnel_protocol"]) + assert.Equal(t, "NSTUNNEL_CONNECTED", result["tunnel_status"]) + assert.Equal(t, "enable", result["client_status"]) + assert.Equal(t, "Not Configured", result["on_prem_detection"]) + assert.Equal(t, "All Web Traffic", result["traffic_mode"]) +} + +func TestRunNsdiag(t *testing.T) { + + tests := []struct { + name string + fileExists bool + cmdOutput string + cmdErr error + wantErr bool + wantRow map[string]string + }{ + { + name: "Netskope not installed — returns empty row", + fileExists: false, + wantRow: map[string]string{ + "orgname": "", "tenant_url": "", "addon_host": "", + "addon_checker_host": "", "gateway": "", "gateway_ip": "", + "config": "", "steering_config": "", "email": "", + "peruser_config": "", "tunnel_status": "", "client_status": "", + "dynamic_steering": "", "on_prem_detection": "", "explicit_proxy": "", + "tunnel_protocol": "", "sni_enable": "", "traffic_mode": "", + }, + }, + { + name: "Netskope installed and healthy — returns parsed values", + fileExists: true, + cmdOutput: sampleNsdiagOutput, + wantRow: map[string]string{ + "orgname": "Talkaloid", + "tenant_url": "talkaloid-prod.goskope.com", + "addon_host": "addon-talkaloid-prod.goskope.com", + "addon_checker_host": "achecker-talkaloid-prod.goskope.com", + "gateway": "gateway-talkaloid-prod.goskope.com", + "gateway_ip": "139.139.39.39", + "config": "All-Talkaloid_Tech", + "steering_config": "Talkaloid", + "email": "natsune.miku@talkaloid.com", + "peruser_config": "FALSE", + "tunnel_status": "NSTUNNEL_CONNECTED", + "client_status": "enable", + "dynamic_steering": "FALSE", + "on_prem_detection": "Not Configured", + "explicit_proxy": "false", + "tunnel_protocol": "TLS", + "sni_enable": "FALSE", + "traffic_mode": "All Web Traffic", + }, + }, + { + name: "Netskope installed but nsdiag fails — returns error", + fileExists: true, + cmdErr: errors.New("exit status 1"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := utils.Runner{Runner: utils.MockCmdRunner{Output: tt.cmdOutput, Err: tt.cmdErr}} + fs := utils.MockFileSystem{FileExists: tt.fileExists} + + results, err := runNsdiag(runner, fs) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, results) + } else { + assert.NoError(t, err) + assert.Len(t, results, 1) + assert.Equal(t, tt.wantRow, results[0]) + } + }) + } +} From 1a1b15ed91aff082a25f26bc9dbdcb4883434ae8 Mon Sep 17 00:00:00 2001 From: Kain Zhou <26943220+kaincenteno@users.noreply.github.com> Date: Wed, 13 May 2026 22:39:09 -0700 Subject: [PATCH 2/2] Refactor Netskope tests for improved clarity and coverage --- tables/netskope/netskope_test.go | 152 ++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 45 deletions(-) diff --git a/tables/netskope/netskope_test.go b/tables/netskope/netskope_test.go index a9c2f5f..3a28f8f 100644 --- a/tables/netskope/netskope_test.go +++ b/tables/netskope/netskope_test.go @@ -1,10 +1,10 @@ package netskope import ( + "strings" "testing" "github.com/macadmins/osquery-extension/pkg/utils" - "github.com/osquery/osquery-go/plugin/table" "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) @@ -31,27 +31,14 @@ Traffic Mode:: All Web Traffic. func TestNetskopeColumns(t *testing.T) { columns := NetskopeColumns() - expected := []table.ColumnDefinition{ - table.TextColumn("orgname"), - table.TextColumn("tenant_url"), - table.TextColumn("addon_host"), - table.TextColumn("addon_checker_host"), - table.TextColumn("gateway"), - table.TextColumn("gateway_ip"), - table.TextColumn("config"), - table.TextColumn("steering_config"), - table.TextColumn("email"), - table.TextColumn("peruser_config"), - table.TextColumn("tunnel_status"), - table.TextColumn("client_status"), - table.TextColumn("dynamic_steering"), - table.TextColumn("on_prem_detection"), - table.TextColumn("explicit_proxy"), - table.TextColumn("tunnel_protocol"), - table.TextColumn("sni_enable"), - table.TextColumn("traffic_mode"), + + names := make(map[string]bool) + for _, col := range columns { + assert.NotEmpty(t, col.Name, "column name must not be empty") + assert.Equal(t, strings.ToLower(col.Name), col.Name, "column name must be lowercase") + assert.False(t, names[col.Name], "duplicate column name: %s", col.Name) + names[col.Name] = true } - assert.Equal(t, expected, columns) } func TestKeyToColumn(t *testing.T) { @@ -59,21 +46,22 @@ func TestKeyToColumn(t *testing.T) { input string want string }{ - {"Orgname", "orgname"}, - {"Tenant URL", "tenant_url"}, + // CamelCase splitting {"AddonHost", "addon_host"}, {"AddonCheckerHost", "addon_checker_host"}, - {"Gateway IP", "gateway_ip"}, - {"Steering Config", "steering_config"}, - {"Peruser config", "peruser_config"}, - {"Tunnel status", "tunnel_status"}, - {"Client status", "client_status"}, {"OnPremDetection", "on_prem_detection"}, - {"Dynamic Steering", "dynamic_steering"}, - {"Explicit Proxy", "explicit_proxy"}, - {"Tunnel Protocol", "tunnel_protocol"}, - {"SNI Enable", "sni_enable"}, + // Space to underscore + {"Tenant URL", "tenant_url"}, + {"Gateway IP", "gateway_ip"}, {"Traffic Mode", "traffic_mode"}, + // All-uppercase abbreviation at start + {"SNI Enable", "sni_enable"}, + // Already lowercase / single word + {"orgname", "orgname"}, + {"config", "config"}, + // Edge cases + {"", ""}, + {"multiple spaces", "multiple__spaces"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { @@ -83,19 +71,93 @@ func TestKeyToColumn(t *testing.T) { } func TestParseNsdiagOutput(t *testing.T) { - result := parseNsdiagOutput(sampleNsdiagOutput) - - assert.Equal(t, "Talkaloid", result["orgname"]) - assert.Equal(t, "talkaloid-prod.goskope.com", result["tenant_url"]) - assert.Equal(t, "addon-talkaloid-prod.goskope.com", result["addon_host"]) - assert.Equal(t, "achecker-talkaloid-prod.goskope.com", result["addon_checker_host"]) - assert.Equal(t, "gateway-talkaloid-prod.goskope.com", result["gateway"]) - assert.Equal(t, "139.139.39.39", result["gateway_ip"]) - assert.Equal(t, "TLS", result["tunnel_protocol"]) - assert.Equal(t, "NSTUNNEL_CONNECTED", result["tunnel_status"]) - assert.Equal(t, "enable", result["client_status"]) - assert.Equal(t, "Not Configured", result["on_prem_detection"]) - assert.Equal(t, "All Web Traffic", result["traffic_mode"]) + t.Run("well-formed output", func(t *testing.T) { + result := parseNsdiagOutput(sampleNsdiagOutput) + assert.Equal(t, "Talkaloid", result["orgname"]) + assert.Equal(t, "talkaloid-prod.goskope.com", result["tenant_url"]) + assert.Equal(t, "addon-talkaloid-prod.goskope.com", result["addon_host"]) + assert.Equal(t, "achecker-talkaloid-prod.goskope.com", result["addon_checker_host"]) + assert.Equal(t, "gateway-talkaloid-prod.goskope.com", result["gateway"]) + assert.Equal(t, "139.139.39.39", result["gateway_ip"]) + assert.Equal(t, "TLS", result["tunnel_protocol"]) + assert.Equal(t, "NSTUNNEL_CONNECTED", result["tunnel_status"]) + assert.Equal(t, "enable", result["client_status"]) + assert.Equal(t, "Not Configured", result["on_prem_detection"]) + assert.Equal(t, "All Web Traffic", result["traffic_mode"]) + }) + + t.Run("empty input returns empty map", func(t *testing.T) { + result := parseNsdiagOutput("") + assert.Empty(t, result) + }) + + t.Run("malformed lines without separator are skipped", func(t *testing.T) { + input := "this line has no separator\nOrgname:: Talkaloid.\njust text\n" + result := parseNsdiagOutput(input) + assert.Equal(t, map[string]string{"orgname": "Talkaloid"}, result) + }) + + t.Run("single-colon lines are skipped", func(t *testing.T) { + input := "Orgname: Talkaloid\nTenant URL: talkaloid-prod.goskope.com\n" + result := parseNsdiagOutput(input) + assert.Empty(t, result) + }) + + t.Run("duplicate keys - last value wins", func(t *testing.T) { + input := "Orgname:: First.\nOrgname:: Second.\n" + result := parseNsdiagOutput(input) + assert.Equal(t, "Second", result["orgname"]) + }) + + t.Run("empty value is preserved", func(t *testing.T) { + input := "Orgname::\n" + result := parseNsdiagOutput(input) + assert.Equal(t, "", result["orgname"]) + }) + + t.Run("extra whitespace around key and value is trimmed", func(t *testing.T) { + input := " Orgname :: Talkaloid \n" + result := parseNsdiagOutput(input) + assert.Equal(t, "Talkaloid", result["orgname"]) + }) + + t.Run("value containing :: is preserved intact", func(t *testing.T) { + input := "Config:: foo::bar.\n" + result := parseNsdiagOutput(input) + assert.Equal(t, "foo::bar", result["config"]) + }) + + t.Run("trailing dot is stripped from value", func(t *testing.T) { + input := "Orgname:: Talkaloid.\n" + result := parseNsdiagOutput(input) + assert.Equal(t, "Talkaloid", result["orgname"]) + }) + + t.Run("value without trailing dot is unchanged", func(t *testing.T) { + input := "Orgname:: Talkaloid\n" + result := parseNsdiagOutput(input) + assert.Equal(t, "Talkaloid", result["orgname"]) + }) + + t.Run("unknown and future fields are parsed", func(t *testing.T) { + input := "Orgname:: Talkaloid.\nNewFeatureXyz:: some value.\n" + result := parseNsdiagOutput(input) + assert.Equal(t, "Talkaloid", result["orgname"]) + assert.Equal(t, "some value", result["new_feature_xyz"]) + }) + + t.Run("partial output with no trailing newline", func(t *testing.T) { + input := "Orgname:: Talkaloid.\nTenant URL :: talkaloid-prod.goskope.com" + result := parseNsdiagOutput(input) + assert.Equal(t, "Talkaloid", result["orgname"]) + assert.Equal(t, "talkaloid-prod.goskope.com", result["tenant_url"]) + }) + + t.Run("mixed warning and debug lines are skipped", func(t *testing.T) { + input := "[WARN] something went wrong\nOrgname:: Talkaloid.\nDEBUG: internal state\n-- separator --\n" + result := parseNsdiagOutput(input) + assert.Equal(t, map[string]string{"orgname": "Talkaloid"}, result) + }) } func TestRunNsdiag(t *testing.T) {