Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions e2e_winrm_live_test.go
Original file line number Diff line number Diff line change
@@ -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 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.
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] + "…"
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion manifest/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
1 change: 0 additions & 1 deletion manifest/manifests/jcmd.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 0 additions & 1 deletion manifest/manifests/jmap.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 0 additions & 1 deletion manifest/manifests/jstat.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
82 changes: 82 additions & 0 deletions manifest/manifests/powershell/curl.exe.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion server/hint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 8 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host> -Port <n> for TCP reachability, and curl -k -m 10 <url> 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) {
Expand Down
38 changes: 38 additions & 0 deletions validator/curl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package validator

import (
"testing"

"github.com/fawdyinc/shellguard/parser"
)

func TestCurlAllowsVerboseFlag(t *testing.T) {
Expand All @@ -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)
}
}
}
Loading
Loading