Skip to content
214 changes: 214 additions & 0 deletions packages/gateway-v2/winrm/pam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
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 + "]")
}

// 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.
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 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 = @()

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' -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 = $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 = $q;
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)" }

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 }
$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 }
}
}
}

$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)`,
Comment thread
bernie-g marked this conversation as resolved.
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)
Comment thread
bernie-g marked this conversation as resolved.
return err
}

// 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 {
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 {
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":
// 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' }; `+
`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))" }; `+
`if ($wasRunning) { 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":
// -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'; `+
`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)
}
// 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
}
38 changes: 37 additions & 1 deletion packages/gateway-v2/winrm/winrm.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -270,11 +272,45 @@ 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)<S[^>]*>(.*?)</S>`)
clixmlHexEscape = regexp.MustCompile(`_x([0-9A-Fa-f]{4})_`)
)

// 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
}
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("&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'", "&amp;", "&").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 {
Expand Down
97 changes: 97 additions & 0 deletions packages/gateway-v2/winrm_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,27 @@ 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 winrmValidateCredentialParams struct {
winrmTransportParams
TargetUsername string `json:"targetUsername"`
Password string `json:"password"`
}

type winrmResponse struct {
Result json.RawMessage `json:"result"`
}
Expand Down Expand Up @@ -132,6 +153,11 @@ 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))
mux.HandleFunc("/v1/validate-credential", wrapWinrm(handleWinrmValidateCredential))
return mux
})

Expand Down Expand Up @@ -266,6 +292,77 @@ 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 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 == "" || p.Password == "" {
return nil, fmt.Errorf("targetUsername and password are 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")
Expand Down
Loading