From 8d63ec2fe6132aede623d637c6340d7ddb6ff3d4 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 20 Jul 2026 20:49:07 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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") From 33549b18104aeb71011cebf5d98375a68ef8b5a2 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 22 Jul 2026 16:18:52 -0400 Subject: [PATCH 4/6] gateway: qualify scheduled-task principals and decode CLIXML errors - winrm/pam.go: resolve a scheduled task's run-as principal through the host's LSA (SID round-trip) so a member server's local account is not mis-anchored to a same-named domain account - winrm/winrm.go: decode PowerShell CLIXML stderr to plain text so a WinRM command failure surfaces the real error instead of the raw XML envelope --- packages/gateway-v2/winrm/pam.go | 8 ++++-- packages/gateway-v2/winrm/winrm.go | 39 +++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go index 31f91cad..94c90f3c 100644 --- a/packages/gateway-v2/winrm/pam.go +++ b/packages/gateway-v2/winrm/pam.go @@ -62,10 +62,14 @@ try { 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 {} + # Principal.UserId is unqualified (e.g. 'Administrator'), so qualify it via the machine's own LSA: a bare + # name is the local account on a member (DOMAIN\user only on a DC), and must not anchor to the domain account. + $q = $t.Principal.UserId + try { $q = (New-Object System.Security.Principal.NTAccount($q)).Translate([System.Security.Principal.SecurityIdentifier]).Translate([System.Security.Principal.NTAccount]).Value } catch {} $deps += [pscustomobject]@{ - type = 'scheduled-task'; runAs = $t.Principal.UserId; name = ($t.TaskPath + $t.TaskName) + type = 'scheduled-task'; runAs = $q; 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; + runLevel = [string]$t.Principal.RunLevel; state = [string]$t.State; runAsAccount = $q; lastRunTime = if ($info) { [string]$info.LastRunTime } else { $null }; nextRunTime = if ($info) { [string]$info.NextRunTime } else { $null }; lastTaskResult = if ($info) { $info.LastTaskResult } else { $null } } diff --git a/packages/gateway-v2/winrm/winrm.go b/packages/gateway-v2/winrm/winrm.go index 1005daae..0a4767ba 100644 --- a/packages/gateway-v2/winrm/winrm.go +++ b/packages/gateway-v2/winrm/winrm.go @@ -15,6 +15,8 @@ import ( "io" "net" "net/http" + "regexp" + "strconv" "strings" "sync" "time" @@ -270,11 +272,46 @@ func run(ctx context.Context, client *winrm.Client, script string) (string, erro if msg == "" { msg = strings.TrimSpace(stdout.String()) } - return "", fmt.Errorf("command failed (exit %d): %s", code, truncate(msg, 500)) + return "", fmt.Errorf("command failed (exit %d): %s", code, truncate(cleanPowerShellError(msg), 500)) } return strings.TrimSpace(stdout.String()), nil } +var ( + clixmlErrSegment = regexp.MustCompile(`(?s)]*>(.*?)`) + clixmlHexEscape = regexp.MustCompile(`_x([0-9A-Fa-f]{4})_`) +) + +// PowerShell writes error output as CLIXML (an XML envelope with _xXXXX_ hex escapes), which is unreadable in a +// UI. Decode it to the plain error text: concatenate the error-stream segments, unescape, and drop the "At line" +// positional trailer (meaningless for our one-line scripts). Non-CLIXML output is returned untouched. +func cleanPowerShellError(msg string) string { + if !strings.Contains(msg, "CLIXML") { + return msg + } + var b strings.Builder + for _, m := range clixmlErrSegment.FindAllStringSubmatch(msg, -1) { + b.WriteString(m[1]) + } + text := b.String() + if text == "" { + return msg + } + text = clixmlHexEscape.ReplaceAllStringFunc(text, func(tok string) string { + code, err := strconv.ParseInt(tok[2:6], 16, 32) + if err != nil { + return tok + } + return string(rune(code)) + }) + text = strings.NewReplacer("<", "<", ">", ">", """, `"`, "'", "'", "&", "&").Replace(text) + text = strings.Join(strings.Fields(text), " ") + if i := strings.Index(text, "At line:"); i > 0 { + text = strings.TrimSpace(text[:i]) + } + return text +} + // runSuppressingOutput runs a script whose text carries file content, returning a generic error on // failure that never echoes the remote stdout/stderr (which could contain that content). func runSuppressingOutput(ctx context.Context, client *winrm.Client, script string) error { From d2c8a358acd5778aca28ef60c31ea9fc00a9949c Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 22 Jul 2026 17:47:31 -0400 Subject: [PATCH 5/6] gateway: literal IIS app-pool path and single-write credential update - winrm/pam.go: Set-ItemProperty -LiteralPath so an app-pool name containing PowerShell wildcard chars is matched literally, not expanded as a pattern - write only the pool password (the run-as identity is unchanged during a rotation), removing the separate userName write and its partial-commit hazard --- packages/gateway-v2/winrm/pam.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go index 94c90f3c..6b45797c 100644 --- a/packages/gateway-v2/winrm/pam.go +++ b/packages/gateway-v2/winrm/pam.go @@ -202,14 +202,16 @@ 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. Only restart a - // pool that was already started (Restart-WebAppPool throws on a stopped pool). + // -LiteralPath so an app-pool name containing PowerShell wildcard characters ([, ], *, ?) is matched + // literally rather than expanded as a pattern (which could update the wrong pool, several, or none). + // Only the password is written: the run-as identity does not change during a rotation, and dropping the + // separate userName write means a single write that can't leave the pool on a new identity with the old + // password. 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'; `+ - `if ((Get-WebAppPoolState -Name '%s').Value -eq 'Started') { Restart-WebAppPool '%s' }`, - n, u, n, p, n, n, + `Set-ItemProperty -LiteralPath 'IIS:\AppPools\%s' -Name processModel.password -Value '%s'; `+ + `if ((Get-WebAppPoolState -Name '%s').Value -eq 'Started') { Restart-WebAppPool -Name '%s' }`, + n, p, n, n, ) default: return fmt.Errorf("unsupported dependency type %q", depType) From ffdba8ac90b4ac686760cecee9558ffe88c3c345 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 22 Jul 2026 20:12:24 -0400 Subject: [PATCH 6/6] chore(pam): condense winrm comments --- packages/gateway-v2/winrm/pam.go | 25 ++++++++----------------- packages/gateway-v2/winrm/winrm.go | 5 ++--- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go index 6b45797c..243d85ba 100644 --- a/packages/gateway-v2/winrm/pam.go +++ b/packages/gateway-v2/winrm/pam.go @@ -38,11 +38,8 @@ func EnumerateLocalAccounts(ctx context.Context, creds Credentials) (json.RawMes 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. +// enumerateDependenciesScript lists services / scheduled tasks / IIS pools running as a named account. A +// service/task enumeration failure is a hard error (don't prune a machine we couldn't fully scan); IIS is best-effort. const enumerateDependenciesScript = ` $ErrorActionPreference = 'Stop' $deps = @() @@ -141,9 +138,8 @@ 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. +// ValidateLocalCredential checks a local account's password via the admin's PrincipalContext.ValidateCredentials, +// so rotation can verify it without logging in as the account (which a plain local account can't do over WinRM). func ValidateLocalCredential(ctx context.Context, creds Credentials, username, password string) (bool, error) { client, err := newClient(ctx, creds) if err != nil { @@ -178,10 +174,8 @@ func SyncDependency(ctx context.Context, creds Credentials, depType, name, runAs 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. Only restart a service that - // was already running, so a deliberately-stopped service is not started as a side effect. + // CIM Change(), not sc.exe: PS 5.1 mangles a password containing " as a native-exe arg. Restart only + // a service that was already running. script = fmt.Sprintf( `$ErrorActionPreference='Stop'; `+ `$svc = Get-CimInstance Win32_Service | Where-Object { $_.Name -eq '%s' }; `+ @@ -202,11 +196,8 @@ func SyncDependency(ctx context.Context, creds Credentials, depType, name, runAs n, u, p, ) case "iis-app-pool": - // -LiteralPath so an app-pool name containing PowerShell wildcard characters ([, ], *, ?) is matched - // literally rather than expanded as a pattern (which could update the wrong pool, several, or none). - // Only the password is written: the run-as identity does not change during a rotation, and dropping the - // separate userName write means a single write that can't leave the pool on a new identity with the old - // password. Only restart a pool that was already started (Restart-WebAppPool throws on a stopped pool). + // -LiteralPath matches a wildcard-containing pool name literally, not as a pattern. Write only the + // password (the run-as identity is unchanged in a rotation, so one write can't half-commit). script = fmt.Sprintf( `$ErrorActionPreference='Stop'; Import-Module WebAdministration; `+ `Set-ItemProperty -LiteralPath 'IIS:\AppPools\%s' -Name processModel.password -Value '%s'; `+ diff --git a/packages/gateway-v2/winrm/winrm.go b/packages/gateway-v2/winrm/winrm.go index 0a4767ba..a91eb65c 100644 --- a/packages/gateway-v2/winrm/winrm.go +++ b/packages/gateway-v2/winrm/winrm.go @@ -282,9 +282,8 @@ var ( clixmlHexEscape = regexp.MustCompile(`_x([0-9A-Fa-f]{4})_`) ) -// PowerShell writes error output as CLIXML (an XML envelope with _xXXXX_ hex escapes), which is unreadable in a -// UI. Decode it to the plain error text: concatenate the error-stream segments, unescape, and drop the "At line" -// positional trailer (meaningless for our one-line scripts). Non-CLIXML output is returned untouched. +// cleanPowerShellError decodes PowerShell's CLIXML error envelope to plain text (unescapes the segments, drops +// the positional "At line" trailer). Non-CLIXML output is returned untouched. func cleanPowerShellError(msg string) string { if !strings.Contains(msg, "CLIXML") { return msg