diff --git a/agent99-contract-check.ps1 b/agent99-contract-check.ps1 index b004017e5..60f8dfae8 100644 --- a/agent99-contract-check.ps1 +++ b/agent99-contract-check.ps1 @@ -142,6 +142,7 @@ $windowsControlBaseline = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99 Add-Check "transport:dedicated_identity" ($control.Contains("sshIdentityFile") -and $control.Contains("IdentitiesOnly=yes")) "dedicated SSH identity is enforced" Add-Check "transport:jump_first" ($control.Contains("preferJumpHost") -and $control.Contains("ssh_jump_fallback")) "preferred jump with bounded fallback is present" Add-Check "transport:stale_process_guard" ($control.Contains("Invoke-AgentStaleSshProcessGuard") -and $control.Contains("taskkill.exe /PID") -and $control.Contains("ssh_stale_process_guard")) "timed-out and stale SSH clients are bounded" +Add-Check "transport:stale_windows_probe_guard" ($control.Contains("Invoke-AgentStaleWindowsProbeProcessGuard") -and $control.Contains("windows_probe_process_guard") -and $control.Contains("commandLineCaptured = `$false")) "legacy stdin probes are matched without command-line capture and cleared in bounded batches" Add-Check "transport:cross_process_serialization" ($control.Contains("Enter-AgentSshTransportLock") -and $control.Contains("[IO.FileShare]::None") -and $control.Contains("ssh_transport_lock_failed")) "cross-process SSH sessions are serialized" Add-Check "windows99:input_independent_control" ($windowsControlBaseline.Contains('$FallbackInputTip = "0409:00000409"') -and $windowsControlBaseline.Contains('$PreservedChineseLanguage = "zh-Hant-TW"') -and $windowsControlBaseline.Contains('encodedCommandCompatible = $true') -and $windowsControlBaseline.Contains('inputMethodIndependent = $true') -and $windowsControlBaseline.Contains('Invoke-IndependentSnapshot') -and $windowsControlBaseline.Contains('Restore-WindowsControlBaseline') -and $windowsControlBaseline.Contains('$Mode -eq "Check" -and $runtimeVerified')) "Windows99 keeps Traditional Chinese/Bopomofo while enforcing a US-keyboard and UTF-8 fallback with an independent verifier, fail-closed drift detection, and rollback" Add-Check "windows99:selfcheck_control_baseline" ($control.Contains('function Test-AgentWindowsControlBaseline') -and $control.Contains('$windowsControlBaseline = Test-AgentWindowsControlBaseline $runtimeManifest') -and $control.Contains('windows_control_baseline_verified_no_write') -and $control.Contains('windowsControlBaseline = $windowsControlBaseline')) "Agent99 SelfCheck continuously verifies the Windows99 keyboard, UTF-8 console, and SSH control baseline without applying writes" diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1 index 143614a51..460902a11 100644 --- a/agent99-control-plane.ps1 +++ b/agent99-control-plane.ps1 @@ -472,7 +472,7 @@ function Get-AgentSshProcessGuardConfig { function Invoke-AgentStaleSshProcessGuard { $guard = Get-AgentSshProcessGuardConfig if (-not $guard.enabled -or -not $ControlledApply) { - return [pscustomobject]@{ ok = $true; skipped = $true; reason = "guard_disabled_or_observe_mode"; stoppedCount = 0; remainingCount = $null; processes = @() } + return [pscustomobject]@{ ok = $true; skipped = $true; reason = "guard_disabled_or_observe_mode"; runtimeWritePerformed = $false; stoppedCount = 0; remainingCount = $null; processes = @() } } $targets = @($Config.hosts) @@ -522,6 +522,7 @@ function Invoke-AgentStaleSshProcessGuard { $result = [pscustomobject]@{ ok = [bool]($failures -eq 0) skipped = $false + runtimeWritePerformed = [bool]($stoppedCount -gt 0) stoppedCount = $stoppedCount failureCount = $failures remainingCount = $remainingCount @@ -535,6 +536,154 @@ function Invoke-AgentStaleSshProcessGuard { $result } +function Get-AgentWindowsProbeProcessGuardConfig { + $configured = if ($Config.PSObject.Properties["windowsProbeProcessGuard"]) { $Config.windowsProbeProcessGuard } else { $null } + [pscustomobject]@{ + enabled = [bool](-not $configured -or -not $configured.PSObject.Properties["enabled"] -or (Convert-AgentBool $configured.enabled)) + staleMinutes = if ($configured -and $configured.PSObject.Properties["staleMinutes"]) { [math]::Max(5, [int]$configured.staleMinutes) } else { 10 } + maxStopsPerRun = if ($configured -and $configured.PSObject.Properties["maxStopsPerRun"]) { [math]::Min(50, [math]::Max(1, [int]$configured.maxStopsPerRun)) } else { 20 } + } +} + +function Get-AgentStaleWindowsProbeProcessReadback { + $guard = Get-AgentWindowsProbeProcessGuardConfig + if (-not $guard.enabled) { + return [pscustomobject]@{ + ok = $true + skipped = $true + reason = "guard_disabled" + staleMinutes = $guard.staleMinutes + staleCount = 0 + processes = @() + commandLineCaptured = $false + runtimeWritePerformed = $false + } + } + + $rowsByPid = @{} + try { + foreach ($probeMode in @("Verify", "Check")) { + # WMI performs the command-line match but returns no command-line field. + $query = "SELECT ProcessId,ParentProcessId,CreationDate,Name FROM Win32_Process WHERE (Name='powershell.exe' OR Name='pwsh.exe') AND CommandLine LIKE '%scriptblock%Create%Console%ReadToEnd%' AND CommandLine LIKE '%-Mode $probeMode%'" + foreach ($processInfo in @(Get-CimInstance -Query $query -ErrorAction Stop)) { + $pidKey = [string]$processInfo.ProcessId + if ($rowsByPid.ContainsKey($pidKey)) { continue } + $process = Get-Process -Id $processInfo.ProcessId -ErrorAction SilentlyContinue + if (-not $process) { continue } + $parentInfo = Get-CimInstance -Query "SELECT Name FROM Win32_Process WHERE ProcessId=$($processInfo.ParentProcessId)" -ErrorAction SilentlyContinue + $parentName = if ($parentInfo) { [string]$parentInfo.Name } else { "" } + $ageMinutes = [math]::Round(((Get-Date) - $process.StartTime).TotalMinutes, 1) + $eligible = [bool]($parentName -ieq "cmd.exe" -and $ageMinutes -ge $guard.staleMinutes) + $rowsByPid[$pidKey] = [pscustomobject]@{ + pid = [int]$processInfo.ProcessId + parentPid = [int]$processInfo.ParentProcessId + parentName = $parentName + mode = $probeMode + ageMinutes = $ageMinutes + workingSetMiB = [math]::Round($process.WorkingSet64 / 1MB, 1) + eligible = $eligible + } + } + } + } catch { + return [pscustomobject]@{ + ok = $false + skipped = $false + reason = "windows_probe_process_readback_failed" + staleMinutes = $guard.staleMinutes + staleCount = 0 + processes = @() + commandLineCaptured = $false + runtimeWritePerformed = $false + } + } + + $rows = @($rowsByPid.Values | Sort-Object ageMinutes -Descending) + $stale = @($rows | Where-Object { $_.eligible }) + [pscustomobject]@{ + ok = $true + skipped = $false + reason = if ($stale.Count -eq 0) { "no_stale_legacy_windows_probes" } else { "stale_legacy_windows_probes_detected" } + staleMinutes = $guard.staleMinutes + staleCount = $stale.Count + totalWorkingSetMiB = [math]::Round(($stale | Measure-Object -Property workingSetMiB -Sum).Sum, 1) + processes = $stale + commandLineCaptured = $false + runtimeWritePerformed = $false + } +} + +function Invoke-AgentStaleWindowsProbeProcessGuard { + $guard = Get-AgentWindowsProbeProcessGuardConfig + if (-not $guard.enabled -or -not $ControlledApply) { + return [pscustomobject]@{ + ok = $true + skipped = $true + reason = "guard_disabled_or_observe_mode" + stoppedCount = 0 + remainingStaleCount = $null + commandLineCaptured = $false + runtimeWritePerformed = $false + } + } + + $before = Get-AgentStaleWindowsProbeProcessReadback + if (-not $before.ok) { + return [pscustomobject]@{ + ok = $false + skipped = $false + reason = $before.reason + stoppedCount = 0 + failureCount = 0 + remainingStaleCount = $null + commandLineCaptured = $false + runtimeWritePerformed = $false + } + } + + $attempts = @() + foreach ($candidate in @($before.processes | Select-Object -First $guard.maxStopsPerRun)) { + $stopped = $false + try { + Stop-Process -Id $candidate.pid -Force -ErrorAction Stop + try { Wait-Process -Id $candidate.pid -Timeout 5 -ErrorAction SilentlyContinue } catch {} + $stopped = [bool](-not (Get-Process -Id $candidate.pid -ErrorAction SilentlyContinue)) + } catch { + $stopped = [bool](-not (Get-Process -Id $candidate.pid -ErrorAction SilentlyContinue)) + } + $attempts += [pscustomobject]@{ + pid = $candidate.pid + mode = $candidate.mode + ageMinutes = $candidate.ageMinutes + stopped = $stopped + } + } + + $after = Get-AgentStaleWindowsProbeProcessReadback + $stoppedCount = @($attempts | Where-Object { $_.stopped }).Count + $failureCount = @($attempts | Where-Object { -not $_.stopped }).Count + $remainingStaleCount = if ($after.ok) { [int]$after.staleCount } else { $null } + $ok = [bool]($after.ok -and $failureCount -eq 0 -and $remainingStaleCount -eq 0) + $result = [pscustomobject]@{ + ok = $ok + skipped = $false + reason = if ($ok) { "stale_legacy_windows_probes_cleared" } elseif ($remainingStaleCount -gt 0) { "bounded_batch_remaining" } else { "stale_legacy_windows_probe_stop_failed" } + staleMinutes = $guard.staleMinutes + maxStopsPerRun = $guard.maxStopsPerRun + candidateCount = $before.staleCount + stoppedCount = $stoppedCount + failureCount = $failureCount + remainingStaleCount = $remainingStaleCount + attempts = $attempts + commandLineCaptured = $false + runtimeWritePerformed = [bool]($stoppedCount -gt 0) + } + if ($before.staleCount -gt 0 -or $failureCount -gt 0) { + Record-AgentEvent "windows_probe_process_guard" $(if ($failureCount -gt 0) { "warning" } else { "info" }) "candidates=$($before.staleCount) stopped=$stoppedCount failures=$failureCount remaining=$remainingStaleCount" $result -NoAlert + } + $result +} + function Get-AgentObjectValue { param( [object]$Data, @@ -4372,6 +4521,7 @@ function Test-AgentSelfHealth { $latestPerf = Test-AgentLatestPerformanceEvidence $selfConfig.requiredHosts $runtimeManifest = Test-AgentRuntimeManifest $windowsControlBaseline = Test-AgentWindowsControlBaseline $runtimeManifest + $windowsProbeProcesses = Get-AgentStaleWindowsProbeProcessReadback $runningQueue = Get-AgentRunningQueueHealth $telegram = if ($selfConfig.requireRecentTelegramDelivery) { Test-AgentRecentTelegramDelivery $selfConfig.evidenceFreshness @@ -4396,11 +4546,13 @@ function Test-AgentSelfHealth { if ($telegram.severity -eq "critical") { $critical += 1 } if ($runtimeManifest.severity -eq "critical") { $critical += 1 } if ($windowsControlBaseline.severity -eq "critical") { $critical += 1 } + if (-not $windowsProbeProcesses.ok) { $critical += 1 } if ($runningQueue.severity -eq "critical") { $critical += 1 } $warning = $evidenceWarning + if ($windowsProbeProcesses.ok -and $windowsProbeProcesses.staleCount -gt 0) { $warning += 1 } $severity = if ($critical -gt 0) { "critical" } elseif ($warning -gt 0) { "warning" } else { "ok" } - $message = "selfHealth severity=$severity taskFailures=$taskFailures evidenceCritical=$evidenceCritical evidenceWarning=$evidenceWarning perfSignal=$($latestPerf.severity) perfReadbackFailures=$($perfReadbackFailures.Count) telegram=$($telegram.severity) runtimeManifest=$($runtimeManifest.severity) windowsControlBaseline=$($windowsControlBaseline.severity) staleRunningQueue=$($runningQueue.staleCount)" + $message = "selfHealth severity=$severity taskFailures=$taskFailures evidenceCritical=$evidenceCritical evidenceWarning=$evidenceWarning perfSignal=$($latestPerf.severity) perfReadbackFailures=$($perfReadbackFailures.Count) telegram=$($telegram.severity) runtimeManifest=$($runtimeManifest.severity) windowsControlBaseline=$($windowsControlBaseline.severity) staleWindowsProbes=$($windowsProbeProcesses.staleCount) staleRunningQueue=$($runningQueue.staleCount)" Record-AgentEvent "agent_selfcheck" $(if ($severity -eq "ok") { "info" } else { $severity }) $message $null -Alert [pscustomobject]@{ @@ -4412,6 +4564,7 @@ function Test-AgentSelfHealth { telegramDelivery = $telegram runtimeManifest = $runtimeManifest windowsControlBaseline = $windowsControlBaseline + windowsProbeProcesses = $windowsProbeProcesses runningQueue = $runningQueue summary = [pscustomobject]@{ taskFailures = $taskFailures @@ -4427,6 +4580,9 @@ function Test-AgentSelfHealth { windowsControlBaselineSeverity = $windowsControlBaseline.severity windowsControlBaselineReason = $windowsControlBaseline.reason windowsControlBaselineEvidenceRef = $windowsControlBaseline.evidenceRef + windowsProbeProcessReadbackOk = $windowsProbeProcesses.ok + windowsProbeProcessStaleCount = $windowsProbeProcesses.staleCount + windowsProbeProcessWorkingSetMiB = $windowsProbeProcesses.totalWorkingSetMiB runningQueueActiveCount = $runningQueue.activeCount runningQueueStaleCount = $runningQueue.staleCount runningQueueThresholdSeconds = $runningQueue.thresholdSeconds @@ -8617,6 +8773,7 @@ $providerFreshness = $null $securityTriage = $null $controlTick = $null $sshProcessGuard = $null +$windowsProbeProcessGuard = $null $bootState = $null $recoveryTrigger = $null $recoverySlo = $null @@ -8764,6 +8921,7 @@ if ($Mode -in @("Status", "Recover", "Perf")) { if ($Mode -in @("Perf", "LoadShed") -and $ControlledApply) { $sshProcessGuard = Invoke-AgentStaleSshProcessGuard + $windowsProbeProcessGuard = Invoke-AgentStaleWindowsProbeProcessGuard } if ($Mode -in @("Status", "Recover", "Perf", "LoadShed")) { @@ -8864,9 +9022,12 @@ $result = [pscustomobject]@{ ($controlTick -and $controlTick.staleRunningRecovery -and $controlTick.staleRunningRecovery.runtimeWritePerformed) -or @($externalHostRecovery | Where-Object { $_.runtimeWritePerformed }).Count -gt 0 -or ($host112Recovery -and $host112Recovery.runtimeWritePerformed) -or - ($host188EdgeRecovery -and $host188EdgeRecovery.runtimeWritePerformed) + ($host188EdgeRecovery -and $host188EdgeRecovery.runtimeWritePerformed) -or + ($sshProcessGuard -and $sshProcessGuard.runtimeWritePerformed) -or + ($windowsProbeProcessGuard -and $windowsProbeProcessGuard.runtimeWritePerformed) ) sshProcessGuard = $sshProcessGuard + windowsProbeProcessGuard = $windowsProbeProcessGuard bootState = $bootState recoveryTrigger = $recoveryTrigger recoverySlo = $recoverySlo diff --git a/agent99-deploy.ps1 b/agent99-deploy.ps1 index d2ebb0b2c..8096d7cf9 100644 --- a/agent99-deploy.ps1 +++ b/agent99-deploy.ps1 @@ -428,6 +428,10 @@ try { Add-DefaultProperty $config.sshProcessGuard "enabled" $true Add-DefaultProperty $config.sshProcessGuard "staleMinutes" 15 Add-DefaultProperty $config.sshProcessGuard "hardStaleMinutes" 60 + Add-DefaultProperty $config "windowsProbeProcessGuard" ([pscustomobject]@{}) + Add-DefaultProperty $config.windowsProbeProcessGuard "enabled" $true + Add-DefaultProperty $config.windowsProbeProcessGuard "staleMinutes" 10 + Add-DefaultProperty $config.windowsProbeProcessGuard "maxStopsPerRun" 20 Add-DefaultProperty $config "hosts" @("192.168.0.110", "192.168.0.111", "192.168.0.112", "192.168.0.120", "192.168.0.121", "192.168.0.188") $configuredHosts = @($config.hosts | ForEach-Object { [string]$_ } | Where-Object { $_ }) if ("192.168.0.111" -notin $configuredHosts) { diff --git a/agent99.config.99.example.json b/agent99.config.99.example.json index 4ff128f1b..9684591a0 100644 --- a/agent99.config.99.example.json +++ b/agent99.config.99.example.json @@ -13,6 +13,11 @@ "staleMinutes": 15, "hardStaleMinutes": 60 }, + "windowsProbeProcessGuard": { + "enabled": true, + "staleMinutes": 10, + "maxStopsPerRun": 20 + }, "sshUsers": { "192.168.0.111": "ooo", "192.168.0.112": "kali", diff --git a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py index d1ffc62ab..b4519b930 100644 --- a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py +++ b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py @@ -554,6 +554,34 @@ def test_agent99_bounds_stale_ssh_processes_without_touching_tunnels() -> None: } +def test_agent99_bounds_legacy_windows_probe_processes_without_command_capture() -> None: + source = CONTROL.read_text(encoding="utf-8") + config = json.loads((ROOT / "agent99.config.99.example.json").read_text()) + deploy = DEPLOY.read_text(encoding="utf-8") + guard = source[source.index("function Get-AgentWindowsProbeProcessGuardConfig") :] + guard = guard[: guard.index("function Get-AgentObjectValue")] + + assert "SELECT ProcessId,ParentProcessId,CreationDate,Name" in guard + assert "CommandLine LIKE '%scriptblock%Create%Console%ReadToEnd%'" in guard + assert "CommandLine LIKE '%-Mode $probeMode%'" in guard + assert 'parentName -ieq "cmd.exe"' in guard + assert "Select-Object -First $guard.maxStopsPerRun" in guard + assert "Stop-Process -Id $candidate.pid -Force" in guard + assert "commandLineCaptured = $false" in guard + assert "CommandLine =" not in guard + assert 'Record-AgentEvent "windows_probe_process_guard"' in guard + assert config["windowsProbeProcessGuard"] == { + "enabled": True, + "staleMinutes": 10, + "maxStopsPerRun": 20, + } + assert 'Add-DefaultProperty $config "windowsProbeProcessGuard"' in deploy + assert 'Add-DefaultProperty $config.windowsProbeProcessGuard "staleMinutes" 10' in deploy + assert 'Add-DefaultProperty $config.windowsProbeProcessGuard "maxStopsPerRun" 20' in deploy + assert "$windowsProbeProcesses = Get-AgentStaleWindowsProbeProcessReadback" in source + assert "windowsProbeProcessStaleCount = $windowsProbeProcesses.staleCount" in source + + def test_agent99_serializes_ssh_across_scheduled_task_processes() -> None: source = CONTROL.read_text(encoding="utf-8") config = json.loads((ROOT / "agent99.config.99.example.json").read_text()) diff --git a/scripts/reboot-recovery/collect-windows99-vmware-verify.sh b/scripts/reboot-recovery/collect-windows99-vmware-verify.sh index 31cc324f5..644219346 100755 --- a/scripts/reboot-recovery/collect-windows99-vmware-verify.sh +++ b/scripts/reboot-recovery/collect-windows99-vmware-verify.sh @@ -16,7 +16,10 @@ if [[ -n "${WINDOWS99_MAX_AUTH_USERS:-}" ]]; then fi KNOWN_HOSTS_FILE="${WINDOWS99_KNOWN_HOSTS_FILE:-${HOME:-/home/wooo}/.ssh/known_hosts}" LOCAL_VERIFY_SCRIPT="${WINDOWS99_LOCAL_VERIFY_SCRIPT:-${SCRIPT_DIR}/windows99-vmware-autostart.ps1}" -REMOTE_VERIFY_COMMAND="${WINDOWS99_REMOTE_VERIFY_COMMAND:-powershell -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create([Console]::In.ReadToEnd())) -Mode Verify\"}" +REMOTE_VERIFY_COMMAND_OVERRIDE="${WINDOWS99_REMOTE_VERIFY_COMMAND:-}" +REMOTE_VERIFY_COMMAND="" +REMOTE_VERIFY_MODE="encoded_command_bounded_job_base64_line" +REMOTE_TRANSPORT_READY=0 SSH_USERS=(Administrator ogt wooo ooo administrator) SSH_USERS_EXPLICIT=0 @@ -39,6 +42,7 @@ fi if ! is_positive_int "${REMOTE_VERIFY_TIMEOUT}"; then REMOTE_VERIFY_TIMEOUT=45 fi +REMOTE_VERIFY_JOB_TIMEOUT=$((REMOTE_VERIFY_TIMEOUT > 20 ? REMOTE_VERIFY_TIMEOUT - 15 : 5)) if ! is_positive_int "${MAX_AUTH_USERS}"; then MAX_AUTH_USERS="${#SSH_USERS[@]}" fi @@ -111,6 +115,71 @@ if ! is_positive_int "${MAX_AUTH_USERS}"; then MAX_AUTH_USERS=2 fi +build_bounded_powershell_command() { + local probe_mode="$1" + local timeout_seconds="$2" + local template encoded + template="$(cat <<'POWERSHELL' +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$OutputEncoding = [Console]::OutputEncoding +$sourceLine = [Console]::In.ReadLine() +$sourceText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($sourceLine)) +$job = $null +$exitCode = 0 +try { + $job = Start-Job -ScriptBlock { + param($probeSource, $probeMode) + $ProgressPreference = 'SilentlyContinue' + [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + $OutputEncoding = [Console]::OutputEncoding + & ([scriptblock]::Create($probeSource)) -Mode $probeMode + } -ArgumentList $sourceText, '__MODE__' + $completed = Wait-Job -Job $job -Timeout __TIMEOUT__ + if ($null -eq $completed) { + [Console]::Error.WriteLine('AGENT99_REMOTE_PROBE_TIMEOUT=1') + $exitCode = 124 + } else { + Receive-Job -Job $job -ErrorAction Continue + if ($job.State -ne 'Completed') { $exitCode = 1 } + } +} catch { + [Console]::Error.WriteLine('AGENT99_REMOTE_PROBE_FAILED=1') + $exitCode = 1 +} finally { + if ($null -ne $job) { + if ($job.State -eq 'Running') { Stop-Job -Job $job -ErrorAction SilentlyContinue } + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } +} +exit $exitCode +POWERSHELL +)" + template="${template//__MODE__/${probe_mode}}" + template="${template//__TIMEOUT__/${timeout_seconds}}" + encoded="$(printf '%s' "${template}" | iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\r\n')" || return 1 + [[ -n "${encoded}" ]] || return 1 + printf '%s' "powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}" +} + +encode_probe_source_line() { + base64 <"$1" | tr -d '\r\n' + printf '\n' +} + +if [[ "${REMOTE_VERIFY_COMMAND_OVERRIDE}" == *"ReadToEnd"* ]]; then + REMOTE_VERIFY_MODE="unsafe_legacy_override_rejected" +elif [[ -n "${REMOTE_VERIFY_COMMAND_OVERRIDE}" ]]; then + REMOTE_VERIFY_COMMAND="${REMOTE_VERIFY_COMMAND_OVERRIDE}" + REMOTE_VERIFY_MODE="explicit_command_override" + REMOTE_TRANSPORT_READY=1 +elif command -v iconv >/dev/null 2>&1 && command -v base64 >/dev/null 2>&1; then + if REMOTE_VERIFY_COMMAND="$(build_bounded_powershell_command Verify "${REMOTE_VERIFY_JOB_TIMEOUT}")"; then + REMOTE_TRANSPORT_READY=1 + fi +fi + if [[ "${MODE}" != "check" && "${MODE}" != "collect" ]]; then printf '%s\n' "error=invalid_mode:${MODE}" >&2 exit 64 @@ -293,6 +362,12 @@ elif [[ "${SSH_BATCHMODE_AUTH_READY}" != "1" ]]; then if [[ "${MODE}" == "collect" ]]; then PROCESS_EXIT_STATUS=75 fi +elif [[ "${REMOTE_TRANSPORT_READY}" != "1" ]]; then + VERIFY_COLLECTION_STATUS="blocked_local_transport_encoder_missing" + SAFE_NEXT_STEP="install_or_restore_local_iconv_and_base64_then_rerun_no_secret_no_remote_write" + if [[ "${MODE}" == "collect" ]]; then + PROCESS_EXIT_STATUS=75 + fi elif [[ "${MODE}" == "check" ]]; then VERIFY_COLLECTION_STATUS="ready_ssh_batchmode_auth_probe_only" SAFE_NEXT_STEP="rerun_collector_with_collect_then_commit_no_secret_verify_artifact_and_scorecard_rerun" @@ -304,7 +379,7 @@ elif [[ "${LOCAL_VERIFY_SCRIPT_PRESENT}" != "1" ]]; then else DRY_RUN="false" REMOTE_VERIFY_ATTEMPTED=1 - if REMOTE_VERIFY_OUTPUT="$(run_ssh_timed "${SSH_AUTHENTICATED_USER}" "${REMOTE_VERIFY_TIMEOUT}" "${REMOTE_VERIFY_COMMAND}" <"${LOCAL_VERIFY_SCRIPT}" 2>&1)"; then + if REMOTE_VERIFY_OUTPUT="$(encode_probe_source_line "${LOCAL_VERIFY_SCRIPT}" | run_ssh_timed "${SSH_AUTHENTICATED_USER}" "${REMOTE_VERIFY_TIMEOUT}" "${REMOTE_VERIFY_COMMAND}" 2>&1)"; then REMOTE_VERIFY_OUTPUT="${REMOTE_VERIFY_OUTPUT//$'\r'/}" REMOTE_VERIFY_EXIT_STATUS=0 if grep -q '^AWOOOI_WINDOWS99_VMWARE_AUTOSTART=1$' <<<"${REMOTE_VERIFY_OUTPUT}" && grep -q '^MODE=Verify$' <<<"${REMOTE_VERIFY_OUTPUT}"; then @@ -331,6 +406,7 @@ printf '%s\n' "via_host=${VIA_HOST:-none}" printf '%s\n' "connect_timeout_seconds=${CONNECT_TIMEOUT}" printf '%s\n' "ssh_timeout_seconds=${SSH_TIMEOUT}" printf '%s\n' "remote_verify_timeout_seconds=${REMOTE_VERIFY_TIMEOUT}" +printf '%s\n' "remote_verify_job_timeout_seconds=${REMOTE_VERIFY_JOB_TIMEOUT}" printf '%s\n' "port_timeout_wrapper=${PORT_TIMEOUT_WRAPPER}" printf '%s\n' "ssh_auth_probe_user_limit=${MAX_AUTH_USERS}" printf '%s\n' "ssh_timeout_wrapper=${SSH_TIMEOUT_WRAPPER}" @@ -345,7 +421,8 @@ printf '%s\n' "ssh_batchmode_auth_ready=${SSH_BATCHMODE_AUTH_READY}" printf '%s\n' "ssh_authenticated_user=${SSH_AUTHENTICATED_USER}" printf '%s\n' "ssh_auth_probe_exit_status=${SSH_AUTH_PROBE_EXIT_STATUS}" printf '%s\n' "ssh_auth_probe_stdout_present=${SSH_AUTH_PROBE_STDOUT_PRESENT}" -printf '%s\n' "remote_verify_mode=in_memory_stdin_scriptblock" +printf '%s\n' "remote_verify_mode=${REMOTE_VERIFY_MODE}" +printf '%s\n' "remote_transport_ready=${REMOTE_TRANSPORT_READY}" printf '%s\n' "local_verify_script_present=${LOCAL_VERIFY_SCRIPT_PRESENT}" printf '%s\n' "remote_verify_attempted=${REMOTE_VERIFY_ATTEMPTED}" printf '%s\n' "remote_verify_exit_status=${REMOTE_VERIFY_EXIT_STATUS}" diff --git a/scripts/reboot-recovery/locate-windows99-vmx-source.sh b/scripts/reboot-recovery/locate-windows99-vmx-source.sh index 49726768d..733111838 100755 --- a/scripts/reboot-recovery/locate-windows99-vmx-source.sh +++ b/scripts/reboot-recovery/locate-windows99-vmx-source.sh @@ -17,7 +17,10 @@ if [[ -n "${WINDOWS99_MAX_AUTH_USERS:-}" ]]; then fi KNOWN_HOSTS_FILE="${WINDOWS99_KNOWN_HOSTS_FILE:-/tmp/awoooi-windows99-known_hosts}" LOCAL_LOCATOR_SCRIPT="${WINDOWS99_LOCAL_LOCATOR_SCRIPT:-${SCRIPT_DIR}/windows99-vmx-source-locator.ps1}" -REMOTE_LOCATOR_COMMAND="${WINDOWS99_REMOTE_LOCATOR_COMMAND:-powershell -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create([Console]::In.ReadToEnd())) -Mode Check\"}" +REMOTE_LOCATOR_COMMAND_OVERRIDE="${WINDOWS99_REMOTE_LOCATOR_COMMAND:-}" +REMOTE_LOCATOR_COMMAND="" +REMOTE_LOCATOR_MODE="encoded_command_bounded_job_base64_line" +REMOTE_TRANSPORT_READY=0 SSH_USERS=(ogt wooo ooo administrator Administrator) SSH_USERS_EXPLICIT=0 @@ -93,6 +96,7 @@ fi if ! is_positive_int "${REMOTE_LOCATOR_TIMEOUT}"; then REMOTE_LOCATOR_TIMEOUT=60 fi +REMOTE_LOCATOR_JOB_TIMEOUT=$((REMOTE_LOCATOR_TIMEOUT > 20 ? REMOTE_LOCATOR_TIMEOUT - 15 : 5)) if ! is_positive_int "${MAX_AUTH_USERS}"; then MAX_AUTH_USERS="${#SSH_USERS[@]}" fi @@ -103,6 +107,71 @@ if ! is_positive_int "${MAX_AUTH_USERS}"; then MAX_AUTH_USERS=2 fi +build_bounded_powershell_command() { + local probe_mode="$1" + local timeout_seconds="$2" + local template encoded + template="$(cat <<'POWERSHELL' +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$OutputEncoding = [Console]::OutputEncoding +$sourceLine = [Console]::In.ReadLine() +$sourceText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($sourceLine)) +$job = $null +$exitCode = 0 +try { + $job = Start-Job -ScriptBlock { + param($probeSource, $probeMode) + $ProgressPreference = 'SilentlyContinue' + [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + $OutputEncoding = [Console]::OutputEncoding + & ([scriptblock]::Create($probeSource)) -Mode $probeMode + } -ArgumentList $sourceText, '__MODE__' + $completed = Wait-Job -Job $job -Timeout __TIMEOUT__ + if ($null -eq $completed) { + [Console]::Error.WriteLine('AGENT99_REMOTE_PROBE_TIMEOUT=1') + $exitCode = 124 + } else { + Receive-Job -Job $job -ErrorAction Continue + if ($job.State -ne 'Completed') { $exitCode = 1 } + } +} catch { + [Console]::Error.WriteLine('AGENT99_REMOTE_PROBE_FAILED=1') + $exitCode = 1 +} finally { + if ($null -ne $job) { + if ($job.State -eq 'Running') { Stop-Job -Job $job -ErrorAction SilentlyContinue } + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } +} +exit $exitCode +POWERSHELL +)" + template="${template//__MODE__/${probe_mode}}" + template="${template//__TIMEOUT__/${timeout_seconds}}" + encoded="$(printf '%s' "${template}" | iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\r\n')" || return 1 + [[ -n "${encoded}" ]] || return 1 + printf '%s' "powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}" +} + +encode_probe_source_line() { + base64 <"$1" | tr -d '\r\n' + printf '\n' +} + +if [[ "${REMOTE_LOCATOR_COMMAND_OVERRIDE}" == *"ReadToEnd"* ]]; then + REMOTE_LOCATOR_MODE="unsafe_legacy_override_rejected" +elif [[ -n "${REMOTE_LOCATOR_COMMAND_OVERRIDE}" ]]; then + REMOTE_LOCATOR_COMMAND="${REMOTE_LOCATOR_COMMAND_OVERRIDE}" + REMOTE_LOCATOR_MODE="explicit_command_override" + REMOTE_TRANSPORT_READY=1 +elif command -v iconv >/dev/null 2>&1 && command -v base64 >/dev/null 2>&1; then + if REMOTE_LOCATOR_COMMAND="$(build_bounded_powershell_command Check "${REMOTE_LOCATOR_JOB_TIMEOUT}")"; then + REMOTE_TRANSPORT_READY=1 + fi +fi + if [[ "${MODE}" != "check" && "${MODE}" != "collect" ]]; then printf '%s\n' "error=invalid_mode:${MODE}" >&2 exit 64 @@ -287,6 +356,12 @@ elif [[ "${SSH_BATCHMODE_AUTH_READY}" != "1" ]]; then if [[ "${MODE}" == "collect" ]]; then PROCESS_EXIT_STATUS=75 fi +elif [[ "${REMOTE_TRANSPORT_READY}" != "1" ]]; then + LOCATOR_COLLECTION_STATUS="blocked_local_transport_encoder_missing" + SAFE_NEXT_STEP="install_or_restore_local_iconv_and_base64_then_rerun_no_secret_no_remote_write" + if [[ "${MODE}" == "collect" ]]; then + PROCESS_EXIT_STATUS=75 + fi elif [[ "${MODE}" == "check" ]]; then LOCATOR_COLLECTION_STATUS="ready_ssh_batchmode_auth_probe_only" SAFE_NEXT_STEP="rerun_locator_with_collect_then_commit_no_secret_locator_artifact_and_scorecard_rerun" @@ -298,7 +373,7 @@ elif [[ "${LOCAL_LOCATOR_SCRIPT_PRESENT}" != "1" ]]; then else DRY_RUN="false" REMOTE_LOCATOR_ATTEMPTED=1 - if REMOTE_LOCATOR_OUTPUT="$(run_ssh_timed "${SSH_AUTHENTICATED_USER}" "${REMOTE_LOCATOR_TIMEOUT}" "${REMOTE_LOCATOR_COMMAND}" <"${LOCAL_LOCATOR_SCRIPT}" 2>&1)"; then + if REMOTE_LOCATOR_OUTPUT="$(encode_probe_source_line "${LOCAL_LOCATOR_SCRIPT}" | run_ssh_timed "${SSH_AUTHENTICATED_USER}" "${REMOTE_LOCATOR_TIMEOUT}" "${REMOTE_LOCATOR_COMMAND}" 2>&1)"; then REMOTE_LOCATOR_OUTPUT="${REMOTE_LOCATOR_OUTPUT//$'\r'/}" REMOTE_LOCATOR_EXIT_STATUS=0 if grep -q '^AWOOOI_WINDOWS99_VMX_SOURCE_LOCATOR=1$' <<<"${REMOTE_LOCATOR_OUTPUT}" && grep -q '^MODE=Check$' <<<"${REMOTE_LOCATOR_OUTPUT}"; then @@ -326,6 +401,7 @@ printf '%s\n' "target_vm_alias=111" printf '%s\n' "connect_timeout_seconds=${CONNECT_TIMEOUT}" printf '%s\n' "ssh_timeout_seconds=${SSH_TIMEOUT}" printf '%s\n' "remote_locator_timeout_seconds=${REMOTE_LOCATOR_TIMEOUT}" +printf '%s\n' "remote_locator_job_timeout_seconds=${REMOTE_LOCATOR_JOB_TIMEOUT}" printf '%s\n' "port_timeout_wrapper=${PORT_TIMEOUT_WRAPPER}" printf '%s\n' "ssh_auth_probe_user_limit=${MAX_AUTH_USERS}" printf '%s\n' "ssh_timeout_wrapper=${SSH_TIMEOUT_WRAPPER}" @@ -339,7 +415,8 @@ printf '%s\n' "ssh_batchmode_auth_ready=${SSH_BATCHMODE_AUTH_READY}" printf '%s\n' "ssh_authenticated_user=${SSH_AUTHENTICATED_USER}" printf '%s\n' "ssh_auth_probe_exit_status=${SSH_AUTH_PROBE_EXIT_STATUS}" printf '%s\n' "ssh_auth_probe_stdout_present=${SSH_AUTH_PROBE_STDOUT_PRESENT}" -printf '%s\n' "remote_locator_mode=in_memory_stdin_scriptblock" +printf '%s\n' "remote_locator_mode=${REMOTE_LOCATOR_MODE}" +printf '%s\n' "remote_transport_ready=${REMOTE_TRANSPORT_READY}" printf '%s\n' "local_locator_script_present=${LOCAL_LOCATOR_SCRIPT_PRESENT}" printf '%s\n' "remote_locator_attempted=${REMOTE_LOCATOR_ATTEMPTED}" printf '%s\n' "remote_locator_exit_status=${REMOTE_LOCATOR_EXIT_STATUS}" diff --git a/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py b/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py index 9302cd169..9d6f33e8a 100644 --- a/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py +++ b/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py @@ -54,12 +54,25 @@ def test_collector_contract_forbids_secret_and_runtime_actions() -> None: assert "StrictHostKeyChecking=accept-new" in text assert "StrictHostKeyChecking=no" not in text assert 'REMOTE_VERIFY_TIMEOUT="${WINDOWS99_REMOTE_VERIFY_TIMEOUT:-45}"' in text + assert "REMOTE_VERIFY_TIMEOUT - 15" in text + assert "unsafe_legacy_override_rejected" in text assert "SSH_USERS=(Administrator ogt wooo ooo administrator)" in text assert "GSSAPIAuthentication=no" in text assert "--via-host" in text assert "via_host=" in text - assert "in_memory_stdin_scriptblock" in text - assert "[Console]::In.ReadToEnd()" in text + assert "encoded_command_bounded_job_base64_line" in text + assert "[Console]::In.ReadLine()" in text + assert "FromBase64String" in text + assert "[Console]::In.ReadToEnd()" not in text + assert "$ProgressPreference = 'SilentlyContinue'" in text + assert "[Text.UTF8Encoding]::new($false)" in text + assert "Start-Job" in text + assert "Wait-Job" in text + assert "Stop-Job" in text + assert "Remove-Job" in text + assert "-NonInteractive" in text + assert "-EncodedCommand" in text + assert 'Command \"& ([scriptblock]::Create([Console]::In.ReadToEnd()))' not in text assert ".\\\\windows99-vmware-autostart.ps1 -Mode Verify" not in text for forbidden in [ "sshpass", @@ -161,6 +174,45 @@ def test_check_mode_probes_all_explicit_users_without_secret_prompt(tmp_path: Pa assert values["password_prompt_allowed"] == "false" +def test_collect_mode_rejects_legacy_readtoend_override( + tmp_path: Path, monkeypatch +) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + exit 0 + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + args="$*" + if [[ "$args" == *"powershell"* ]]; then + exit 44 + fi + printf '%s\n' 'AWOOOI_WINDOWS99_SSH_READY' + exit 0 + """, + ) + monkeypatch.setenv( + "WINDOWS99_REMOTE_VERIFY_COMMAND", + "powershell -Command [Console]::In.ReadToEnd()", + ) + + result = _run_collector(fake_bin, "--collect", "--max-auth-users", "1") + + assert result.returncode == 75 + values = _key_values(result.stdout) + assert values["remote_verify_mode"] == "unsafe_legacy_override_rejected" + assert values["remote_transport_ready"] == "0" + assert values["remote_verify_attempted"] == "0" + assert values["verify_collection_status"] == "blocked_local_transport_encoder_missing" + + def test_check_mode_can_cap_auth_probe_user_count_without_secret_prompt( tmp_path: Path, ) -> None: @@ -254,7 +306,9 @@ def test_check_mode_auth_ready_does_not_run_remote_verify(tmp_path: Path) -> Non assert result.returncode == 0 values = _key_values(result.stdout) assert values["ssh_batchmode_auth_ready"] == "1" - assert values["remote_verify_mode"] == "in_memory_stdin_scriptblock" + assert values["remote_verify_mode"] == "encoded_command_bounded_job_base64_line" + assert values["remote_transport_ready"] == "1" + assert values["remote_verify_job_timeout_seconds"] == "30" assert values["local_verify_script_present"] == "1" assert values["remote_verify_attempted"] == "0" assert values["verify_collection_status"] == "ready_ssh_batchmode_auth_probe_only" @@ -296,6 +350,7 @@ def test_collect_mode_runs_readonly_remote_verify_when_auth_ready(tmp_path: Path assert values["dry_run"] == "false" assert values["ssh_batchmode_auth_ready"] == "1" assert values["remote_verify_attempted"] == "1" + assert values["remote_transport_ready"] == "1" assert values["remote_verify_exit_status"] == "0" assert values["verify_collection_status"] == "collected_windows99_vmware_verify_stdout" assert "remote_verify_output_begin" in result.stdout diff --git a/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py b/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py index 8ca4029f3..1ca918903 100644 --- a/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py +++ b/scripts/reboot-recovery/tests/test_windows99_vmx_source_locator.py @@ -52,8 +52,21 @@ def test_locator_contract_forbids_secret_runtime_actions_and_vmx_content_reads() assert "NumberOfPasswordPrompts=0" in bash_text assert "--via-host" in bash_text assert "via_host=" in bash_text - assert "in_memory_stdin_scriptblock" in bash_text - assert "[Console]::In.ReadToEnd()" in bash_text + assert "REMOTE_LOCATOR_TIMEOUT - 15" in bash_text + assert "unsafe_legacy_override_rejected" in bash_text + assert "encoded_command_bounded_job_base64_line" in bash_text + assert "[Console]::In.ReadLine()" in bash_text + assert "FromBase64String" in bash_text + assert "[Console]::In.ReadToEnd()" not in bash_text + assert "$ProgressPreference = 'SilentlyContinue'" in bash_text + assert "[Text.UTF8Encoding]::new($false)" in bash_text + assert "Start-Job" in bash_text + assert "Wait-Job" in bash_text + assert "Stop-Job" in bash_text + assert "Remove-Job" in bash_text + assert "-NonInteractive" in bash_text + assert "-EncodedCommand" in bash_text + assert 'Command \"& ([scriptblock]::Create([Console]::In.ReadToEnd()))' not in bash_text assert "Get-ChildItem" in ps_text assert "Test-Path" in ps_text for text in [bash_text, ps_text]: @@ -110,6 +123,9 @@ def test_check_mode_reports_auth_probe_without_remote_locator(tmp_path: Path) -> assert values["target_host_alias"] == "99" assert values["target_vm_alias"] == "111" assert values["ssh_batchmode_auth_ready"] == "1" + assert values["remote_locator_mode"] == "encoded_command_bounded_job_base64_line" + assert values["remote_transport_ready"] == "1" + assert values["remote_locator_job_timeout_seconds"] == "45" assert values["remote_locator_attempted"] == "0" assert values["locator_collection_status"] == "ready_ssh_batchmode_auth_probe_only" assert values["secret_value_read"] == "false"