From 2e4cb2d02ec2d584fd05c09c2a3390359ee86a91 Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Thu, 20 Aug 2026 19:59:18 -0400 Subject: [PATCH 1/6] fix(winrm): reconstruct the command that was validated Two cases from the 2026-08-20 windows-dev live audit where the string we execute was not the pipeline the validator approved: - Comma-lists mixing identifiers with a hashtable (Select-Object -Property Name, Id, @{...}) were single-quoted as one token, so PowerShell received a single string property name and returned a literal-named empty column with exit 0. Split at top-level commas and quote per element. - Bare curl resolves to PowerShell 5.1's Invoke-WebRequest alias, whose flags are incompatible with the validated curl manifest. Rewrite validated curl pipelines to curl.exe (in System32 since Server 2016 / Win10 1803). Co-Authored-By: Claude Fable 5 --- winrm/reconstruct.go | 67 ++++++++++++++++++++++++++++++++++++++- winrm/reconstruct_test.go | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/winrm/reconstruct.go b/winrm/reconstruct.go index 331d6f9..855456d 100644 --- a/winrm/reconstruct.go +++ b/winrm/reconstruct.go @@ -24,6 +24,18 @@ func PowerShellQuote(token string) string { if strings.HasPrefix(token, "@{") { return token } + // Comma-lists that mix identifiers with hashtable literals (e.g. + // `Name,Id,@{N='MB';E={...}}` from Select-Object -Property) must be + // quoted per element: quoting the joined token turns the whole list + // into a single string property name, which PowerShell resolves to an + // empty column instead of an array of properties. + if parts, ok := splitTopLevelCommaList(token); ok { + quoted := make([]string, len(parts)) + for i, p := range parts { + quoted[i] = PowerShellQuote(p) + } + return strings.Join(quoted, ",") + } // Env refs pass through unquoted so PowerShell resolves the variable at // execution time. The parser's EnvRef token already validated the shape. if isPSEnvRef(token) { @@ -37,6 +49,46 @@ func PowerShellQuote(token string) string { return "'" + strings.ReplaceAll(token, "'", "''") + "'" } +// splitTopLevelCommaList splits token at commas that sit outside any brace +// nesting and outside single quotes. It reports ok only when the token is a +// genuine mixed comma-list — at least one top-level comma AND at least one +// element that is a hashtable literal. Plain identifier lists (`Name,Id`) and +// string arguments that merely contain commas keep their existing quoting. +func splitTopLevelCommaList(token string) ([]string, bool) { + var parts []string + depth := 0 + inQuote := false + start := 0 + for i, r := range token { + switch { + case r == '\'': + inQuote = !inQuote + case inQuote: + case r == '{': + depth++ + case r == '}': + if depth > 0 { + depth-- + } + case r == ',' && depth == 0: + parts = append(parts, token[start:i]) + start = i + 1 + } + } + parts = append(parts, token[start:]) + if len(parts) < 2 { + return nil, false + } + hasHashtable := false + for _, p := range parts { + if strings.HasPrefix(p, "@{") { + hasHashtable = true + break + } + } + return parts, hasHashtable +} + // isPSEnvRef returns true if the token matches the parser's EnvRef shape: // $env: followed by a legal identifier. Case-insensitive "env". func isPSEnvRef(token string) bool { @@ -63,6 +115,15 @@ func isPSEnvRef(token string) bool { return true } +// psAliasBypass maps command names that PowerShell 5.1 shadows with cmdlet +// aliases to their explicit .exe spellings. A validated `curl -k -m 10 ` +// otherwise resolves to the Invoke-WebRequest alias, which is a denied cmdlet +// with incompatible flags — the validated command is not the command that runs. +// curl.exe ships in System32 on Windows Server 2016+/Windows 10 1803+. +var psAliasBypass = map[string]string{ + "curl": "curl.exe", +} + // ReconstructPowerShellCommand rebuilds a validated pipeline into a PowerShell // command string with proper quoting. func ReconstructPowerShellCommand(pipeline *parser.Pipeline) string { @@ -75,8 +136,12 @@ func ReconstructPowerShellCommand(pipeline *parser.Pipeline) string { if seg.Operator != "" { parts = append(parts, seg.Operator) } + name := seg.Command + if exe, ok := psAliasBypass[name]; ok { + name = exe + } tokens := make([]string, 0, len(seg.Args)+1) - tokens = append(tokens, seg.Command) // command name unquoted + tokens = append(tokens, name) // command name unquoted for _, arg := range seg.Args { tokens = append(tokens, PowerShellQuote(arg)) } diff --git a/winrm/reconstruct_test.go b/winrm/reconstruct_test.go index b933304..bcb6a30 100644 --- a/winrm/reconstruct_test.go +++ b/winrm/reconstruct_test.go @@ -137,3 +137,59 @@ func TestReconstructPowerShellCommand_QuoteEscaping(t *testing.T) { t.Errorf("expected escaped single quotes, got %q", got) } } + +// Regression: a Select-Object -Property list mixing identifiers with a +// calculated property must not be quoted as one token. Quoting the joined +// list turns it into a single string property name, which PowerShell resolves +// to a literal-named empty column (2026-08-20 windows-dev audit, row 8). +func TestReconstructPowerShellCommand_CommaListWithHashtable(t *testing.T) { + cmd := `Get-Process | Sort-Object -Property WorkingSet64 -Descending | Select-Object -First 5 -Property Name, Id, @{N='MB';E={[math]::Round($_.WorkingSet64/1MB,1)}}` + pipeline, err := parser.ParsePowerShell(cmd) + if err != nil { + t.Fatalf("parse: %v", err) + } + got := ReconstructPowerShellCommand(pipeline) + if strings.Contains(got, "'Name") { + t.Errorf("property list was quoted as a single string: %q", got) + } + if !strings.Contains(got, "Name,Id,@{") { + t.Errorf("expected unquoted property array, got %q", got) + } +} + +func TestPowerShellQuote_CommaLists(t *testing.T) { + tests := []struct { + input string + want string + }{ + // Mixed list with hashtable: per-element quoting, hashtable verbatim. + {"Name,Id,@{N='MB'; E={[math]::Round($_.WorkingSet64/1MB,1)}}", + "Name,Id,@{N='MB'; E={[math]::Round($_.WorkingSet64/1MB,1)}}"}, + // Commas inside the hashtable are not split points. + {"Name,@{N='a,b'; E={[math]::Round($_.CPU,2)}}", + "Name,@{N='a,b'; E={[math]::Round($_.CPU,2)}}"}, + // Plain identifier lists keep existing pass-through behavior. + {"Name,Id", "Name,Id"}, + // A string arg containing commas (no hashtable) keeps whole-token quoting. + {"foo, bar", "'foo, bar'"}, + } + for _, tc := range tests { + if got := PowerShellQuote(tc.input); got != tc.want { + t.Errorf("PowerShellQuote(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +// PowerShell 5.1 shadows `curl` with an Invoke-WebRequest alias whose flags +// are incompatible with real curl — the 2026-08-20 customer-demo failure. +// Validated curl pipelines must execute as curl.exe. +func TestReconstructPowerShellCommand_CurlAliasBypass(t *testing.T) { + pipeline, err := parser.ParsePowerShell("curl -sS -k -m 10 -I 'https://example.com'") + if err != nil { + t.Fatalf("parse: %v", err) + } + got := ReconstructPowerShellCommand(pipeline) + if !strings.HasPrefix(got, "curl.exe ") { + t.Errorf("expected curl to be rewritten to curl.exe, got %q", got) + } +} From b32a94cee557b9abb16a569ba6fe41ef4aeef789 Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Thu, 20 Aug 2026 19:59:29 -0400 Subject: [PATCH 2/6] feat(manifests): admit curl.exe on powershell, scope JVM tools universally curl.exe mirrors curl.yaml's GET-only policy (with allows_flag_bundling, since it is a native binary, not a cmdlet) so the explicit Windows spelling validates alongside the alias-bypassed bare curl. jcmd/jmap/jstat lose their shell: bash marking: the CLIs are identical on Windows and the WinRM hint already advertised them. Co-Authored-By: Claude Fable 5 --- manifest/manifest_test.go | 2 +- manifest/manifests/jcmd.yaml | 1 - manifest/manifests/jmap.yaml | 1 - manifest/manifests/jstat.yaml | 1 - manifest/manifests/powershell/curl.exe.yaml | 82 +++++++++++++++++++++ validator/curl_test.go | 38 ++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 manifest/manifests/powershell/curl.exe.yaml diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go index 04b38d6..edd647d 100644 --- a/manifest/manifest_test.go +++ b/manifest/manifest_test.go @@ -20,7 +20,7 @@ func mustLoadEmbedded(t *testing.T) map[string]*Manifest { func TestLoadEmbeddedCountAndNameMatch(t *testing.T) { registry := mustLoadEmbedded(t) - if got, want := len(registry), 364; got != want { + if got, want := len(registry), 365; got != want { t.Fatalf("len(registry) = %d, want %d", got, want) } diff --git a/manifest/manifests/jcmd.yaml b/manifest/manifests/jcmd.yaml index 84d365b..97b0413 100644 --- a/manifest/manifests/jcmd.yaml +++ b/manifest/manifests/jcmd.yaml @@ -1,7 +1,6 @@ name: jcmd description: Send read-only diagnostic commands to a running JVM category: java -shell: bash # Source: JDK 21 jcmd man page (docs.oracle.com/en/java/javase/21/docs/specs/man/jcmd.html). # # jcmd dispatches on a positional word, not a flag, so flag validation cannot diff --git a/manifest/manifests/jmap.yaml b/manifest/manifests/jmap.yaml index dba3e20..a7c884e 100644 --- a/manifest/manifests/jmap.yaml +++ b/manifest/manifests/jmap.yaml @@ -1,7 +1,6 @@ name: jmap description: Print heap, class loader, and finalizer statistics for a running JVM category: java -shell: bash # Source: JDK 21 jmap man page. It documents exactly four options. flags: - flag: "-clstats" diff --git a/manifest/manifests/jstat.yaml b/manifest/manifests/jstat.yaml index f0d8475..a1eb94f 100644 --- a/manifest/manifests/jstat.yaml +++ b/manifest/manifests/jstat.yaml @@ -1,7 +1,6 @@ name: jstat description: Display JVM performance statistics (GC, class loading, JIT) category: java -shell: bash # Source: JDK 21 jstat man page. jstat is documented purely as a monitoring # tool: it attaches to a JVM and reads instrumentation counters. flags: diff --git a/manifest/manifests/powershell/curl.exe.yaml b/manifest/manifests/powershell/curl.exe.yaml new file mode 100644 index 0000000..da7d8ab --- /dev/null +++ b/manifest/manifests/powershell/curl.exe.yaml @@ -0,0 +1,82 @@ +name: curl.exe +description: Transfer data from or to a server (GET-only policy, explicit Windows spelling) +category: network +shell: powershell +# Native Windows executable rather than a cmdlet, hence allows_flag_bundling: +# `curl.exe -sS` is -s -S, exactly as on the universal curl manifest. +allows_flag_bundling: true +# PowerShell 5.1 aliases bare `curl` to Invoke-WebRequest, so the reconstructor +# rewrites validated `curl` pipelines to `curl.exe` (winrm/reconstruct.go +# psAliasBypass) — the same alias-collision fix as sc.exe.yaml. This manifest +# admits the explicit spelling too, so agents that write `curl.exe` directly are +# not rejected while bare `curl` works. Policy mirrors curl.yaml exactly: +# GET-only, no bodies, no file output, no arbitrary headers. +regex_arg_position: 0 +flags: + - flag: "-I" + - flag: "-s" + - flag: "-S" + - flag: "-m" + takes_value: true + - flag: "-k" + - flag: "-L" + - flag: "-v" + - flag: "-w" + takes_value: true + description: "Format what is written to stdout after the transfer" + - flag: "--write-out" + takes_value: true + - flag: "-X" + deny: true + reason: "Only GET requests are allowed" + - flag: "--request" + deny: true + reason: "Only GET requests are allowed" + - flag: "-d" + deny: true + reason: "Request bodies are not allowed" + - flag: "--data" + deny: true + reason: "Request bodies are not allowed" + - flag: "--data-binary" + deny: true + reason: "Request bodies are not allowed" + - flag: "--data-urlencode" + deny: true + reason: "Request bodies are not allowed" + - flag: "-o" + deny: true + reason: "Writing output to files is not allowed" + - flag: "--output" + deny: true + reason: "Writing output to files is not allowed" + - flag: "-O" + deny: true + reason: "Writing output to files is not allowed" + - flag: "--remote-name" + deny: true + reason: "Writing output to files is not allowed" + - flag: "-T" + deny: true + reason: "Uploading files is not allowed" + - flag: "--upload-file" + deny: true + reason: "Uploading files is not allowed" + # Kept in lockstep with curl.yaml: -H/--header stay denied because an + # arbitrary request header can forge Authorization or Cookie against an + # internal service even on a GET. + - flag: "-H" + takes_value: true + deny: true + reason: + "An arbitrary request header can forge Authorization or Cookie against an + internal service, so it is refused even though the method stays GET. If a + Host-header probe is genuinely needed, hand the operator the line." + - flag: "--header" + takes_value: true + deny: true + reason: + "An arbitrary request header can forge Authorization or Cookie against an + internal service, so it is refused even though the method stays GET." +stdin: false +stdout: true diff --git a/validator/curl_test.go b/validator/curl_test.go index 1d391cf..df2f39f 100644 --- a/validator/curl_test.go +++ b/validator/curl_test.go @@ -2,6 +2,8 @@ package validator import ( "testing" + + "github.com/fawdyinc/shellguard/parser" ) func TestCurlAllowsVerboseFlag(t *testing.T) { @@ -15,3 +17,39 @@ func TestCurlAllowsVerboseFlagCombined(t *testing.T) { t.Fatalf("curl -vL should be allowed: %v", err) } } + +// curl.exe is the explicit Windows spelling admitted on powershell scope +// (manifest/manifests/powershell/curl.exe.yaml); policy must mirror curl. +func TestCurlExePowerShellScope(t *testing.T) { + registry := testRegistry(t) + scoped := ScopeRegistry(registry, "powershell") + + accept := []string{ + "curl.exe -I -k -m 10 'https://example.com'", + "curl.exe -sS -k -m 10 -w '%{http_code}' 'https://example.com'", + } + for _, cmd := range accept { + p, err := parser.ParsePowerShell(cmd) + if err != nil { + t.Fatalf("parse %q: %v", cmd, err) + } + if err := ValidatePipeline(p, scoped); err != nil { + t.Errorf("expected accept for %q, got: %v", cmd, err) + } + } + + reject := []string{ + "curl.exe -X POST 'https://example.com'", + "curl.exe -o C:\\out.bin 'https://example.com'", + "curl.exe -H 'Authorization: Bearer x' 'https://example.com'", + } + for _, cmd := range reject { + p, err := parser.ParsePowerShell(cmd) + if err != nil { + continue // parse-level rejection is fine too + } + if err := ValidatePipeline(p, scoped); err == nil { + t.Errorf("expected reject for %q", cmd) + } + } +} From 46fdd64f038a9ce38a74796eb7623e80ff4212d8 Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Thu, 20 Aug 2026 19:59:29 -0400 Subject: [PATCH 3/6] fix(server): advertise network checks and attempt-don't-assume in the WinRM hint The 2026-08-05 and 2026-08-20 incidents were both hint-inventory lies: agents treat omitted tools as unavailable and never attempt them. Name Test-NetConnection and curl explicitly, and tell the agent that a 'not recognized' error is an installation gap to report, never a policy block to assume. Co-Authored-By: Claude Fable 5 --- server/hint_test.go | 2 +- server/server.go | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/server/hint_test.go b/server/hint_test.go index 8312dee..3844fef 100644 --- a/server/hint_test.go +++ b/server/hint_test.go @@ -11,7 +11,7 @@ import ( // "no $ or {} or ;" - a claim that stopped being true when the safe-expression // grammar landed. The hint must advertise what the dialect supports. func TestWinRMConnectHintAdvertisesSafeExpressions(t *testing.T) { - for _, want := range []string{"$_", "[math]::", "jps", "jstack", "FilterHashtable"} { + for _, want := range []string{"$_", "[math]::", "jps", "jstack", "FilterHashtable", "Test-NetConnection", "curl", "DSCheckLS.exe", "not recognized"} { if !strings.Contains(winrmConnectHint, want) { t.Errorf("winrm connect hint should mention %q\nhint: %s", want, winrmConnectHint) } diff --git a/server/server.go b/server/server.go index 1a3e275..12317cd 100644 --- a/server/server.go +++ b/server/server.go @@ -374,13 +374,18 @@ func (c *Core) connectWinRM(ctx context.Context, in ConnectInput, start time.Tim } // winrmConnectHint describes the PowerShell dialect to the connecting agent. -// Keep it in sync with the parser's safe-expression grammar: agents trust this -// hint completely and never attempt constructs it says are unsupported. +// Keep it in sync with the parser's safe-expression grammar AND the manifest +// registry: agents trust this hint completely — they never attempt constructs +// it says are unsupported, and they treat tools it omits as unavailable +// (2026-08-05 and 2026-08-20 incidents were both hint-inventory lies, not +// policy gaps). If you change what validates, change this string in the same +// commit. const winrmConnectHint = "Use PowerShell cmdlets (Get-Process, Get-Service, Get-WinEvent, etc.), single-quoted strings, and | to pipe. " + "Script blocks support $_ and [math]::/[datetime]:: calls: calculated properties like Select-Object @{N='GB';E={[math]::Round($_.WorkingSet64/1GB,2)}} " + "and filters like Where-Object { $_.Id -eq 4625 } work. " + "Get-WinEvent -FilterHashtable accepts time bounds: @{LogName='System'; StartTime='2026-08-05 03:00'}. " + - "JVM tools (jps, jstack, jcmd, jmap, jstat) and net accounts are available. " + + "Network checks: Test-NetConnection -Port for TCP reachability, and curl -k -m 10 for HTTP (GET-only; executed as curl.exe, so real curl flags apply — Invoke-WebRequest is denied). " + + "JVM tools (jps, jstack, jcmd, jmap, jstat), net accounts, and installed vendor pack CLIs (e.g. DSCheckLS.exe -l) pass policy; if one is not installed or not on PATH the host returns 'not recognized' — attempt it and report that as an installation gap, never assume policy blocked it. " + "Not supported: variables/assignment, subexpressions $(...), semicolons outside hashtables, redirection." func (c *Core) Execute(ctx context.Context, in ExecuteInput) (output.CommandResult, error) { From 6514910f9664aa290582d2eb405db07f00e063ca Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Thu, 20 Aug 2026 19:59:29 -0400 Subject: [PATCH 4/6] test: add env-gated live WinRM e2e suite Every other test stops at string reconstruction, which is how both the curl alias bug and the comma-list quoting bug shipped: exit-code-clean failures that only a real Windows host exposes. Gated on SHELLGUARD_E2E_WINRM_* so CI is unaffected. Co-Authored-By: Claude Fable 5 --- e2e_winrm_live_test.go | 184 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 e2e_winrm_live_test.go diff --git a/e2e_winrm_live_test.go b/e2e_winrm_live_test.go new file mode 100644 index 0000000..ba28e8f --- /dev/null +++ b/e2e_winrm_live_test.go @@ -0,0 +1,184 @@ +package shellguard_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/fawdyinc/shellguard/manifest" + "github.com/fawdyinc/shellguard/parser" + "github.com/fawdyinc/shellguard/ssh" + "github.com/fawdyinc/shellguard/validator" + "github.com/fawdyinc/shellguard/winrm" +) + +// Live end-to-end smoke test against a real Windows host over WinRM. +// +// Every other test in this repo stops at string reconstruction. That gap is +// how the curl→Invoke-WebRequest alias bug shipped: the validator accepted +// `curl -k`, and the first process to notice that PowerShell resolves `curl` +// to a denied cmdlet was a customer demo. This suite runs the full +// parse → validate → reconstruct → WrapForWinRM → execute pipeline and +// asserts on real exit codes and output. +// +// Gated on environment; skips when unset so CI is unaffected: +// +// SHELLGUARD_E2E_WINRM_HOST=192.168.1.50 \ +// SHELLGUARD_E2E_WINRM_USER=fawdytest \ +// SHELLGUARD_E2E_WINRM_PASSWORD=... \ +// go test -run TestLiveWinRM -v . +// +// Optional: +// +// SHELLGUARD_E2E_HTTP_URL HTTPS URL reachable from the Windows host +// (default https://example.com) +// SHELLGUARD_E2E_DSLS=1 also run DSCheckLS.exe -l (3DEXPERIENCE hosts) +func TestLiveWinRM(t *testing.T) { + host := os.Getenv("SHELLGUARD_E2E_WINRM_HOST") + if host == "" { + t.Skip("SHELLGUARD_E2E_WINRM_HOST not set; skipping live WinRM e2e") + } + password := os.Getenv("SHELLGUARD_E2E_WINRM_PASSWORD") + if password == "" { + t.Fatal("SHELLGUARD_E2E_WINRM_HOST is set but SHELLGUARD_E2E_WINRM_PASSWORD is not") + } + + registry, err := manifest.LoadEmbedded() + if err != nil { + t.Fatalf("LoadEmbedded: %v", err) + } + scoped := validator.ScopeRegistry(registry, "powershell") + + mgr := winrm.NewWinRMManager(&winrm.WinRMDialer{}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + err = mgr.Connect(ctx, ssh.ConnectionParams{ + Host: host, + User: os.Getenv("SHELLGUARD_E2E_WINRM_USER"), + Password: password, + }) + if err != nil { + t.Fatalf("connect to %s: %v", host, err) + } + defer mgr.Disconnect(ctx, host) + + // run pushes one command through the exact pipeline server.Core.Execute + // uses for a WinRM host, then executes it for real. + run := func(t *testing.T, command string) ssh.ExecResult { + t.Helper() + pipeline, err := parser.ParsePowerShell(command) + if err != nil { + t.Fatalf("parse rejected %q: %v", command, err) + } + if err := validator.ValidatePipeline(pipeline, scoped); err != nil { + t.Fatalf("validator rejected %q: %v", command, err) + } + psCmd := winrm.ReconstructPowerShellCommand(pipeline) + t.Logf("reconstructed: %s", psCmd) + result, err := mgr.Execute(ctx, host, winrm.WrapForWinRM(psCmd), 60*time.Second) + if err != nil { + t.Fatalf("execute %q: %v", psCmd, err) + } + t.Logf("exit=%d runtime=%dms stdout[0:120]=%q stderr[0:200]=%q", + result.ExitCode, result.RuntimeMs, firstN(result.Stdout, 120), firstN(result.Stderr, 200)) + return result + } + + mustSucceed := func(t *testing.T, command string) ssh.ExecResult { + t.Helper() + result := run(t, command) + if result.ExitCode != 0 { + t.Fatalf("%q exited %d\nstderr: %s", command, result.ExitCode, result.Stderr) + } + if strings.TrimSpace(result.Stderr) != "" { + t.Fatalf("%q wrote to stderr despite exit 0:\n%s", command, result.Stderr) + } + return result + } + + t.Run("whoami", func(t *testing.T) { + result := mustSucceed(t, "whoami") + if strings.TrimSpace(result.Stdout) == "" { + t.Fatal("whoami produced no output") + } + }) + + t.Run("cmdlet_pipeline", func(t *testing.T) { + result := mustSucceed(t, "Get-Service | Select-Object -Property Name -First 5") + if !strings.Contains(result.Stdout, "Name") { + t.Fatalf("expected service table, got: %s", firstN(result.Stdout, 200)) + } + }) + + t.Run("calculated_property", func(t *testing.T) { + // The safe-expression grammar from PR #111 — keep it exercised live. + // Exit code alone is not enough: the comma-list quoting bug found in + // the 2026-08-20 windows-dev audit produced exit 0 with one + // literal-named empty column. Require the MB header, numeric data, + // and no hashtable text leaking into the output as a property name. + result := mustSucceed(t, "Get-Process | Select-Object -First 3 -Property Name, @{N='MB';E={[math]::Round($_.WorkingSet64/1MB,1)}}") + if strings.Contains(result.Stdout, "@{") { + t.Fatalf("property list reached PowerShell as a single string, not an array: %s", + firstN(result.Stdout, 300)) + } + if !strings.Contains(result.Stdout, "MB") || !strings.ContainsAny(result.Stdout, "0123456789") { + t.Fatalf("calculated property produced no data: %s", firstN(result.Stdout, 300)) + } + }) + + t.Run("tcp_port_check", func(t *testing.T) { + // The TCP-level liveness probe the agent should reach for first. + result := mustSucceed(t, fmt.Sprintf("Test-NetConnection %s -Port 5985", host)) + if !strings.Contains(result.Stdout, "TcpTestSucceeded") { + t.Fatalf("unexpected Test-NetConnection output: %s", firstN(result.Stdout, 300)) + } + }) + + t.Run("http_get_curl", func(t *testing.T) { + // The HTTP-level liveness probe. This is the case that failed at the + // customer: `curl` validates, but inside powershell.exe it resolves to + // the Invoke-WebRequest alias, which rejects curl's flags. + url := os.Getenv("SHELLGUARD_E2E_HTTP_URL") + if url == "" { + url = "https://example.com" + } + result := run(t, fmt.Sprintf("curl -sS -k -m 10 -I '%s'", url)) + if strings.Contains(result.Stderr, "Invoke-WebRequest") || + strings.Contains(result.Stderr, "parameter") { + t.Fatalf("curl resolved to the Invoke-WebRequest alias instead of curl.exe — "+ + "the validated command is not the command that ran.\nstderr: %s", result.Stderr) + } + if result.ExitCode != 0 { + t.Fatalf("curl exited %d\nstderr: %s", result.ExitCode, result.Stderr) + } + if !strings.Contains(result.Stdout, "HTTP/") { + t.Fatalf("expected HTTP status line, got: %s", firstN(result.Stdout, 200)) + } + }) + + t.Run("dsls_license_check", func(t *testing.T) { + if os.Getenv("SHELLGUARD_E2E_DSLS") == "" { + t.Skip("SHELLGUARD_E2E_DSLS not set; host is not a 3DEXPERIENCE server") + } + result := run(t, "DSCheckLS.exe -l") + if strings.Contains(result.Stderr, "is not recognized") { + t.Fatalf("DSCheckLS.exe validated but was not found at runtime — "+ + "not on PATH and full paths are rejected by design.\nstderr: %s", result.Stderr) + } + if result.ExitCode != 0 { + t.Fatalf("DSCheckLS.exe -l exited %d\nstderr: %s", result.ExitCode, result.Stderr) + } + }) +} + +func firstN(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} From 1511afd1177d210ead66bc9aae24406a41dc17c5 Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Thu, 20 Aug 2026 20:03:03 -0400 Subject: [PATCH 5/6] chore(test): check the deferred Disconnect error (errcheck) Co-Authored-By: Claude Fable 5 --- e2e_winrm_live_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e_winrm_live_test.go b/e2e_winrm_live_test.go index ba28e8f..594293b 100644 --- a/e2e_winrm_live_test.go +++ b/e2e_winrm_live_test.go @@ -64,7 +64,7 @@ func TestLiveWinRM(t *testing.T) { if err != nil { t.Fatalf("connect to %s: %v", host, err) } - defer mgr.Disconnect(ctx, host) + defer func() { _ = mgr.Disconnect(ctx, host) }() // run pushes one command through the exact pipeline server.Core.Execute // uses for a WinRM host, then executes it for real. From 1a36a119437770e0c8436ec1c1a8aec9823cc54d Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Thu, 20 Aug 2026 20:05:42 -0400 Subject: [PATCH 6/6] chore: bump Go to 1.25.13 for stdlib vulnerability fixes govulncheck flags GO-2026-6218 (net/url), GO-2026-6090 (crypto/tls), GO-2026-6088 (encoding/xml), and GO-2026-5972 (encoding/asn1) as reachable from pre-existing code paths; all are fixed in go1.25.13. Co-Authored-By: Claude Fable 5 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index af2843c..e40aa74 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fawdyinc/shellguard -go 1.25.12 +go 1.25.13 require ( github.com/alecthomas/participle/v2 v2.1.4