diff --git a/packages/gateway-v2/winrm/pam.go b/packages/gateway-v2/winrm/pam.go new file mode 100644 index 00000000..243d85ba --- /dev/null +++ b/packages/gateway-v2/winrm/pam.go @@ -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)`, + 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 +} + +// 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 +} diff --git a/packages/gateway-v2/winrm/winrm.go b/packages/gateway-v2/winrm/winrm.go index 1005daae..a91eb65c 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,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)]*>(.*?)`) + 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("<", "<", ">", ">", """, `"`, "'", "'", "&", "&").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 { diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index 4769362c..cbe24d24 100644 --- a/packages/gateway-v2/winrm_handler.go +++ b/packages/gateway-v2/winrm_handler.go @@ -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"` } @@ -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 }) @@ -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 == "" { + 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")