From 8d63ec2fe6132aede623d637c6340d7ddb6ff3d4 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 20 Jul 2026 20:49:07 -0400 Subject: [PATCH 1/3] feat(gateway): hardcoded WinRM operations for PAM discovery and rotation (PAM-350) Add enumerate-accounts, enumerate-dependencies, rotate-credential, and sync-dependency WinRM operations so the control plane names a vetted operation and passes parameters instead of sending raw PowerShell. Credential changes use pure-PowerShell cmdlets (CIM Win32_Service.Change, Set-ScheduledTask, Set-ADAccountPassword) so a password never crosses a native-exe argument line. --- packages/gateway-v2/winrm/pam.go | 186 +++++++++++++++++++++++++++ packages/gateway-v2/winrm_handler.go | 75 +++++++++++ 2 files changed, 261 insertions(+) create mode 100644 packages/gateway-v2/winrm/pam.go diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go new file mode 100644 index 00000000..ed290a41 --- /dev/null +++ b/packages/gateway-v2/winrm/pam.go @@ -0,0 +1,186 @@ +package winrm + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// normalizeJSONArray coerces ConvertTo-Json output (an object for a single item, an array for many, +// empty for none) into a JSON array so callers always parse a list. +func normalizeJSONArray(s string) json.RawMessage { + s = strings.TrimSpace(s) + if s == "" { + return json.RawMessage("[]") + } + if strings.HasPrefix(s, "[") { + return json.RawMessage(s) + } + return json.RawMessage("[" + s + "]") +} + +const enumerateAccountsScript = `$ProgressPreference='SilentlyContinue'; ` + + `Get-LocalUser | Select-Object Name, Enabled, @{Name='SID';Expression={$_.SID.Value}} | ConvertTo-Json -Compress` + +// EnumerateLocalAccounts lists the host's local user accounts as a JSON array. +func EnumerateLocalAccounts(ctx context.Context, creds Credentials) (json.RawMessage, error) { + client, err := newClient(ctx, creds) + if err != nil { + return nil, err + } + out, err := run(ctx, client, enumerateAccountsScript) + if err != nil { + return nil, err + } + return normalizeJSONArray(out), nil +} + +// enumerateDependenciesScript collects services, password-based scheduled tasks, and IIS app pools that +// run as a named account, with their run-as identity, so the control plane can anchor each to an account. +// Services and scheduled tasks are always present on a Windows Server, so a failure enumerating either is a +// hard error: the control plane must treat the machine as not-scanned rather than silently prune the rows it +// couldn't see this run. IIS is optional (an absent module just means no app pools), so it is best-effort. +const enumerateDependenciesScript = ` +$ErrorActionPreference = 'Stop' +$deps = @() + +try { + foreach ($s in (Get-CimInstance Win32_Service)) { + if (-not $s.StartName) { continue } + $deps += [pscustomobject]@{ + type = 'windows-service'; runAs = $s.StartName; name = $s.Name + data = @{ displayName = $s.DisplayName; startMode = $s.StartMode; state = [string]$s.State; + processId = $s.ProcessId; pathName = $s.PathName; description = $s.Description; runAsAccount = $s.StartName } + } + } +} catch { throw "service enumeration failed: $($_.Exception.Message)" } + +try { + foreach ($t in (Get-ScheduledTask | Where-Object { $_.Principal.LogonType -eq 'Password' -and $_.Principal.UserId })) { + $info = $null + try { $info = Get-ScheduledTaskInfo -TaskName $t.TaskName -TaskPath $t.TaskPath } catch {} + $deps += [pscustomobject]@{ + type = 'scheduled-task'; runAs = $t.Principal.UserId; name = ($t.TaskPath + $t.TaskName) + data = @{ taskPath = $t.TaskPath; taskName = $t.TaskName; logonType = [string]$t.Principal.LogonType; + runLevel = [string]$t.Principal.RunLevel; state = [string]$t.State; runAsAccount = $t.Principal.UserId; + lastRunTime = if ($info) { [string]$info.LastRunTime } else { $null }; + nextRunTime = if ($info) { [string]$info.NextRunTime } else { $null }; + lastTaskResult = if ($info) { $info.LastTaskResult } else { $null } } + } + } +} catch { throw "scheduled task enumeration failed: $($_.Exception.Message)" } + +try { + Import-Module WebAdministration -ErrorAction Stop + foreach ($p in (Get-ChildItem 'IIS:\AppPools')) { + if ($p.processModel.identityType -ne 'SpecificUser') { continue } + if (-not $p.processModel.userName) { continue } + $deps += [pscustomobject]@{ + type = 'iis-app-pool'; runAs = $p.processModel.userName; name = $p.Name + data = @{ identityType = [string]$p.processModel.identityType; managedRuntimeVersion = [string]$p.managedRuntimeVersion; + managedPipelineMode = [string]$p.managedPipelineMode; autoStart = [bool]$p.autoStart; + state = [string]$p.state; runAsAccount = $p.processModel.userName } + } + } +} catch {} + +$deps | ConvertTo-Json -Depth 5 -Compress +` + +// EnumerateDependencies lists services / scheduled tasks / IIS app pools that run as a named account. +func EnumerateDependencies(ctx context.Context, creds Credentials) (json.RawMessage, error) { + client, err := newClient(ctx, creds) + if err != nil { + return nil, err + } + out, err := run(ctx, client, enumerateDependenciesScript) + if err != nil { + return nil, err + } + return normalizeJSONArray(out), nil +} + +// RotateCredential resets the password of a local or domain account. The connecting credentials +// (an administrator/rotation identity) must be authorized to change the target account's password. +func RotateCredential(ctx context.Context, creds Credentials, kind, username, newPassword string) error { + client, err := newClient(ctx, creds) + if err != nil { + return err + } + u := escapePowerShellSingleQuotes(username) + p := escapePowerShellSingleQuotes(newPassword) + + var script string + switch kind { + case "local": + script = fmt.Sprintf( + `$ErrorActionPreference='Stop'; Set-LocalUser -Name '%s' -Password (ConvertTo-SecureString '%s' -AsPlainText -Force)`, + u, p, + ) + case "domain": + script = fmt.Sprintf( + `$ErrorActionPreference='Stop'; Import-Module ActiveDirectory; `+ + `Set-ADAccountPassword -Identity '%s' -Reset -NewPassword (ConvertTo-SecureString '%s' -AsPlainText -Force)`, + u, p, + ) + default: + return fmt.Errorf("unsupported credential kind %q", kind) + } + // The password is only ever a ConvertTo-SecureString value (never printed), so run() is safe and surfaces + // the real host error; the control plane redacts secrets from it before storing. + _, err = run(ctx, client, script) + return err +} + +// SyncDependency writes a new password into a service / scheduled task / IIS app pool that runs as the +// account, then restarts it so it re-authenticates. For scheduled tasks, name is the full task path. +func SyncDependency(ctx context.Context, creds Credentials, depType, name, runAsUsername, newPassword string) error { + client, err := newClient(ctx, creds) + if err != nil { + return err + } + n := escapePowerShellSingleQuotes(name) + u := escapePowerShellSingleQuotes(runAsUsername) + p := escapePowerShellSingleQuotes(newPassword) + + var script string + switch depType { + case "windows-service": + // Use the CIM Change() method, not sc.exe: PowerShell 5.1 does not escape embedded quotes when + // quoting arguments to a native executable, so a password containing " would reach sc.exe mangled. + // Passing it as a method argument keeps it entirely within PowerShell. + script = fmt.Sprintf( + `$ErrorActionPreference='Stop'; `+ + `$svc = Get-CimInstance Win32_Service | Where-Object { $_.Name -eq '%s' }; `+ + `if (-not $svc) { throw 'service not found' }; `+ + `$r = Invoke-CimMethod -InputObject $svc -MethodName Change -Arguments @{ StartName = '%s'; StartPassword = '%s' }; `+ + `if ($r.ReturnValue -ne 0) { throw "service credential update failed (return $($r.ReturnValue))" }; `+ + `Restart-Service -Name '%s' -Force`, + n, u, p, n, + ) + case "scheduled-task": + // Set-ScheduledTask keeps the password in PowerShell, avoiding schtasks.exe's native-arg re-quoting. + script = fmt.Sprintf( + `$ErrorActionPreference='Stop'; `+ + `$t = Get-ScheduledTask | Where-Object { ($_.TaskPath + $_.TaskName) -eq '%s' }; `+ + `if (-not $t) { throw 'scheduled task not found' }; `+ + `Set-ScheduledTask -TaskName $t.TaskName -TaskPath $t.TaskPath -User '%s' -Password '%s' | Out-Null`, + n, u, p, + ) + case "iis-app-pool": + // Pure PowerShell already: the password is a cmdlet value, never a native-exe argument. + script = fmt.Sprintf( + `$ErrorActionPreference='Stop'; Import-Module WebAdministration; `+ + `Set-ItemProperty 'IIS:\AppPools\%s' -Name processModel.userName -Value '%s'; `+ + `Set-ItemProperty 'IIS:\AppPools\%s' -Name processModel.password -Value '%s'; Restart-WebAppPool '%s'`, + n, u, n, p, n, + ) + default: + return fmt.Errorf("unsupported dependency type %q", depType) + } + // The scripts pass the password only as PowerShell string/method values (never printed), so run() is safe + // and surfaces the real host error; the control plane redacts secrets from it before storing. + _, err = run(ctx, client, script) + return err +} diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index 4769362c..25c6fcf9 100644 --- a/packages/gateway-v2/winrm_handler.go +++ b/packages/gateway-v2/winrm_handler.go @@ -67,6 +67,21 @@ type winrmRemoveParams struct { Paths []string `json:"paths"` } +type winrmRotateParams struct { + winrmTransportParams + Kind string `json:"kind"` // "local" or "domain" + Username string `json:"targetUsername"` + NewPassword string `json:"newPassword"` +} + +type winrmSyncDependencyParams struct { + winrmTransportParams + Type string `json:"type"` // windows-service / scheduled-task / iis-app-pool + Name string `json:"name"` + RunAs string `json:"runAsUsername"` + NewPassword string `json:"newPassword"` +} + type winrmResponse struct { Result json.RawMessage `json:"result"` } @@ -132,6 +147,10 @@ var serveWinrmMux = sync.OnceValue(func() *http.ServeMux { mux.HandleFunc("/v1/test-connection", wrapWinrm(handleWinrmConnectionTest)) mux.HandleFunc("/v1/deliver-files", wrapWinrm(handleWinrmDeliverFiles)) mux.HandleFunc("/v1/remove-files", wrapWinrm(handleWinrmRemoveFiles)) + mux.HandleFunc("/v1/enumerate-accounts", wrapWinrm(handleWinrmEnumerateAccounts)) + mux.HandleFunc("/v1/enumerate-dependencies", wrapWinrm(handleWinrmEnumerateDependencies)) + mux.HandleFunc("/v1/rotate-credential", wrapWinrm(handleWinrmRotateCredential)) + mux.HandleFunc("/v1/sync-dependency", wrapWinrm(handleWinrmSyncDependency)) return mux }) @@ -266,6 +285,62 @@ func handleWinrmRemoveFiles(ctx context.Context, env *winrmRequestEnvelope) (any return map[string]any{"removed": len(p.Paths)}, nil } +func handleWinrmEnumerateAccounts(ctx context.Context, env *winrmRequestEnvelope) (any, error) { + var tp winrmTransportParams + if len(env.Params) > 0 { + if err := json.Unmarshal(env.Params, &tp); err != nil { + return nil, fmt.Errorf("malformed enumerate-accounts params") + } + } + accounts, err := winrm.EnumerateLocalAccounts(ctx, credsFromEnv(ctx, env, tp)) + if err != nil { + return nil, err + } + return map[string]any{"accounts": accounts}, nil +} + +func handleWinrmEnumerateDependencies(ctx context.Context, env *winrmRequestEnvelope) (any, error) { + var tp winrmTransportParams + if len(env.Params) > 0 { + if err := json.Unmarshal(env.Params, &tp); err != nil { + return nil, fmt.Errorf("malformed enumerate-dependencies params") + } + } + dependencies, err := winrm.EnumerateDependencies(ctx, credsFromEnv(ctx, env, tp)) + if err != nil { + return nil, err + } + return map[string]any{"dependencies": dependencies}, nil +} + +func handleWinrmRotateCredential(ctx context.Context, env *winrmRequestEnvelope) (any, error) { + var p winrmRotateParams + if err := json.Unmarshal(env.Params, &p); err != nil { + return nil, fmt.Errorf("malformed rotate-credential params") + } + if p.Username == "" || p.NewPassword == "" { + return nil, fmt.Errorf("targetUsername and newPassword are required") + } + if err := winrm.RotateCredential(ctx, credsFromEnv(ctx, env, p.winrmTransportParams), p.Kind, p.Username, p.NewPassword); err != nil { + return nil, err + } + return map[string]any{"ok": true}, nil +} + +func handleWinrmSyncDependency(ctx context.Context, env *winrmRequestEnvelope) (any, error) { + var p winrmSyncDependencyParams + if err := json.Unmarshal(env.Params, &p); err != nil { + return nil, fmt.Errorf("malformed sync-dependency params") + } + if p.Type == "" || p.Name == "" || p.RunAs == "" || p.NewPassword == "" { + return nil, fmt.Errorf("type, name, runAsUsername and newPassword are required") + } + if err := winrm.SyncDependency(ctx, credsFromEnv(ctx, env, p.winrmTransportParams), p.Type, p.Name, p.RunAs, p.NewPassword); err != nil { + return nil, err + } + return map[string]any{"ok": true}, nil +} + func writeWinrmError(w http.ResponseWriter, status int, message string) { body, _ := json.Marshal(winrmErrorResponse{Error: winrmErrorBody{Message: message}}) w.Header().Set("Content-Type", "application/json") From ba64b36e418cfc56dd5e773fa666f742234ed703 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 20 Jul 2026 21:37:23 -0400 Subject: [PATCH 2/3] fix(gateway): harden PAM WinRM operations from review - Change service credentials via CIM Win32_Service.Change and scheduled tasks via Set-ScheduledTask (pure PowerShell) so a password with quotes isn't mangled by native-exe argument quoting. - Only restart a service/app-pool that was already running. - Include InteractiveOrPassword scheduled tasks; fail the enumeration hard on a service/task error so partial results can't prune real dependency rows. --- packages/gateway-v2/winrm/pam.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go index ed290a41..5031e5b7 100644 --- a/packages/gateway-v2/winrm/pam.go +++ b/packages/gateway-v2/winrm/pam.go @@ -57,7 +57,7 @@ try { } catch { throw "service enumeration failed: $($_.Exception.Message)" } try { - foreach ($t in (Get-ScheduledTask | Where-Object { $_.Principal.LogonType -eq 'Password' -and $_.Principal.UserId })) { + foreach ($t in (Get-ScheduledTask | Where-Object { ($_.Principal.LogonType -eq 'Password' -or $_.Principal.LogonType -eq 'InteractiveOrPassword') -and $_.Principal.UserId })) { $info = $null try { $info = Get-ScheduledTaskInfo -TaskName $t.TaskName -TaskPath $t.TaskPath } catch {} $deps += [pscustomobject]@{ @@ -149,14 +149,16 @@ func SyncDependency(ctx context.Context, creds Credentials, depType, name, runAs case "windows-service": // Use the CIM Change() method, not sc.exe: PowerShell 5.1 does not escape embedded quotes when // quoting arguments to a native executable, so a password containing " would reach sc.exe mangled. - // Passing it as a method argument keeps it entirely within PowerShell. + // Passing it as a method argument keeps it entirely within PowerShell. Only restart a service that + // was already running, so a deliberately-stopped service is not started as a side effect. script = fmt.Sprintf( `$ErrorActionPreference='Stop'; `+ `$svc = Get-CimInstance Win32_Service | Where-Object { $_.Name -eq '%s' }; `+ `if (-not $svc) { throw 'service not found' }; `+ + `$wasRunning = $svc.State -eq 'Running'; `+ `$r = Invoke-CimMethod -InputObject $svc -MethodName Change -Arguments @{ StartName = '%s'; StartPassword = '%s' }; `+ `if ($r.ReturnValue -ne 0) { throw "service credential update failed (return $($r.ReturnValue))" }; `+ - `Restart-Service -Name '%s' -Force`, + `if ($wasRunning) { Restart-Service -Name '%s' -Force }`, n, u, p, n, ) case "scheduled-task": @@ -169,12 +171,14 @@ func SyncDependency(ctx context.Context, creds Credentials, depType, name, runAs n, u, p, ) case "iis-app-pool": - // Pure PowerShell already: the password is a cmdlet value, never a native-exe argument. + // Pure PowerShell already: the password is a cmdlet value, never a native-exe argument. Only restart a + // pool that was already started (Restart-WebAppPool throws on a stopped pool). script = fmt.Sprintf( `$ErrorActionPreference='Stop'; Import-Module WebAdministration; `+ `Set-ItemProperty 'IIS:\AppPools\%s' -Name processModel.userName -Value '%s'; `+ - `Set-ItemProperty 'IIS:\AppPools\%s' -Name processModel.password -Value '%s'; Restart-WebAppPool '%s'`, - n, u, n, p, n, + `Set-ItemProperty 'IIS:\AppPools\%s' -Name processModel.password -Value '%s'; `+ + `if ((Get-WebAppPoolState -Name '%s').Value -eq 'Started') { Restart-WebAppPool '%s' }`, + n, u, n, p, n, n, ) default: return fmt.Errorf("unsupported dependency type %q", depType) From 48e6e2d6a50dcc7707fa4f7849d1f7f29acccad1 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 20 Jul 2026 23:04:55 -0400 Subject: [PATCH 3/3] fix(gateway): local credential validation + enumeration hardening - Add a validate-credential op so the admin rotator can verify a local account's password on the box (ValidateCredentials) without logging in as the account. - Only restart services/app-pools that were already running (done earlier), and now: harden local-account enumeration with ErrorActionPreference=Stop, include InteractiveOrPassword scheduled tasks, and gate IIS enumeration on the module being present so a real IIS error hard-fails instead of being swallowed. --- packages/gateway-v2/winrm/pam.go | 35 ++++++++++++++++++++++++---- packages/gateway-v2/winrm_handler.go | 22 +++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go index 5031e5b7..31f91cad 100644 --- a/packages/gateway-v2/winrm/pam.go +++ b/packages/gateway-v2/winrm/pam.go @@ -20,7 +20,9 @@ func normalizeJSONArray(s string) json.RawMessage { return json.RawMessage("[" + s + "]") } -const enumerateAccountsScript = `$ProgressPreference='SilentlyContinue'; ` + +// ErrorActionPreference=Stop so a Get-LocalUser failure is a hard error (matching the dependencies script) +// rather than a silent short list that reads as "this machine has fewer local accounts". +const enumerateAccountsScript = `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ` + `Get-LocalUser | Select-Object Name, Enabled, @{Name='SID';Expression={$_.SID.Value}} | ConvertTo-Json -Compress` // EnumerateLocalAccounts lists the host's local user accounts as a JSON array. @@ -71,8 +73,10 @@ try { } } catch { throw "scheduled task enumeration failed: $($_.Exception.Message)" } -try { - Import-Module WebAdministration -ErrorAction Stop +if (Get-Module -ListAvailable -Name WebAdministration) { + # Module present means IIS is installed, so enumerate it; an error here now propagates (hard fail) instead of + # being swallowed, which would leave a "scanned" machine missing its app-pool deps and prune real rows. + Import-Module WebAdministration foreach ($p in (Get-ChildItem 'IIS:\AppPools')) { if ($p.processModel.identityType -ne 'SpecificUser') { continue } if (-not $p.processModel.userName) { continue } @@ -83,7 +87,7 @@ try { state = [string]$p.state; runAsAccount = $p.processModel.userName } } } -} catch {} +} $deps | ConvertTo-Json -Depth 5 -Compress ` @@ -133,6 +137,29 @@ func RotateCredential(ctx context.Context, creds Credentials, kind, username, ne return err } +// ValidateLocalCredential checks whether a local account's password is correct, run by the connecting +// (administrator) identity via PrincipalContext.ValidateCredentials. This lets rotation verify a local +// credential without logging in AS the account, which an ordinary local account cannot do over WinRM. +func ValidateLocalCredential(ctx context.Context, creds Credentials, username, password string) (bool, error) { + client, err := newClient(ctx, creds) + if err != nil { + return false, err + } + u := escapePowerShellSingleQuotes(username) + p := escapePowerShellSingleQuotes(password) + script := fmt.Sprintf( + `$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.DirectoryServices.AccountManagement; `+ + `$ctx = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('Machine'); `+ + `if ($ctx.ValidateCredentials('%s','%s')) { 'VALID' } else { 'INVALID' }`, + u, p, + ) + out, err := run(ctx, client, script) + if err != nil { + return false, err + } + return strings.TrimSpace(out) == "VALID", nil +} + // SyncDependency writes a new password into a service / scheduled task / IIS app pool that runs as the // account, then restarts it so it re-authenticates. For scheduled tasks, name is the full task path. func SyncDependency(ctx context.Context, creds Credentials, depType, name, runAsUsername, newPassword string) error { diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index 25c6fcf9..cbe24d24 100644 --- a/packages/gateway-v2/winrm_handler.go +++ b/packages/gateway-v2/winrm_handler.go @@ -82,6 +82,12 @@ type winrmSyncDependencyParams struct { NewPassword string `json:"newPassword"` } +type winrmValidateCredentialParams struct { + winrmTransportParams + TargetUsername string `json:"targetUsername"` + Password string `json:"password"` +} + type winrmResponse struct { Result json.RawMessage `json:"result"` } @@ -151,6 +157,7 @@ var serveWinrmMux = sync.OnceValue(func() *http.ServeMux { mux.HandleFunc("/v1/enumerate-dependencies", wrapWinrm(handleWinrmEnumerateDependencies)) mux.HandleFunc("/v1/rotate-credential", wrapWinrm(handleWinrmRotateCredential)) mux.HandleFunc("/v1/sync-dependency", wrapWinrm(handleWinrmSyncDependency)) + mux.HandleFunc("/v1/validate-credential", wrapWinrm(handleWinrmValidateCredential)) return mux }) @@ -341,6 +348,21 @@ func handleWinrmSyncDependency(ctx context.Context, env *winrmRequestEnvelope) ( return map[string]any{"ok": true}, nil } +func handleWinrmValidateCredential(ctx context.Context, env *winrmRequestEnvelope) (any, error) { + var p winrmValidateCredentialParams + if err := json.Unmarshal(env.Params, &p); err != nil { + return nil, fmt.Errorf("malformed validate-credential params") + } + if p.TargetUsername == "" { + return nil, fmt.Errorf("targetUsername is required") + } + valid, err := winrm.ValidateLocalCredential(ctx, credsFromEnv(ctx, env, p.winrmTransportParams), p.TargetUsername, p.Password) + if err != nil { + return nil, err + } + return map[string]any{"valid": valid}, nil +} + func writeWinrmError(w http.ResponseWriter, status int, message string) { body, _ := json.Marshal(winrmErrorResponse{Error: winrmErrorBody{Message: message}}) w.Header().Set("Content-Type", "application/json")