param( [ValidateSet("PlanOnly", "Capture")] [string]$Mode = "PlanOnly", [string]$DataRoot = "C:\ProgramData\ProxyWarden", [string]$AppRoot = "C:\Program Files\ProxyWarden", [string]$ProxiFyreRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre", [string]$SingBoxRoot = "C:\Program Files\ProxyWarden\components\sing-box", [string]$ForeignServiceName = "", [string]$OutputPath = "" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function New-Result { param( [bool]$Success, [string]$Action, [bool]$Changed, [string]$Message, [hashtable]$Details ) [ordered]@{ success = $Success action = $Action changed = $Changed message = $Message details = $Details } | ConvertTo-Json -Depth 8 } function Get-ServiceEvidence { param([string[]]$Names) $result = @() foreach ($name in $Names | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) { $escaped = $name.Replace("'", "''") $service = Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue if ($null -eq $service) { $result += [ordered]@{ name = $name; found = $false } continue } $result += [ordered]@{ name = $service.Name found = $true state = $service.State startMode = $service.StartMode pathName = $service.PathName processId = [int]$service.ProcessId } } return $result } function Test-PathUnderRoot { param([string]$Path, [string]$Root) if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false } $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') $fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') return $fullPath.Equals($fullRoot, [StringComparison]::OrdinalIgnoreCase) -or $fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase) } function Get-ServiceExecutablePath { param([string]$PathName) if ([string]::IsNullOrWhiteSpace($PathName)) { return "" } $trimmed = $PathName.Trim() if ($trimmed.StartsWith('"')) { $closingQuote = $trimmed.IndexOf('"', 1) if ($closingQuote -gt 1) { return $trimmed.Substring(1, $closingQuote - 1) } } return ($trimmed -split '\s+', 2)[0] } function Get-FileEvidence { param([string]$Root) if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() } return @( Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue | Select-Object @{N="path";E={$_.FullName}}, @{N="length";E={$_.Length}}, @{N="lastWriteTimeUtc";E={$_.LastWriteTimeUtc.ToString("o")}} ) } function Get-SecretFindingCategories { param([string]$Root) if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() } $patterns = [ordered]@{ urlUserInfo = '://[^/\s"'']+@' credentialQuery = '(?i)[?&](token|key|auth|password|passwd|secret)=[^&\s"'']+' socksCredentials = '(?i)socks5://[^/\s:@]+:[^/\s@]+@' hwidHeader = '(?i)x-hwid[^\r\n]*[0-9a-f]{8}-[0-9a-f-]{27,}' } $findings = @() $files = Get-ChildItem -LiteralPath $Root -Recurse -File -Include *.json,*.log,*.txt -ErrorAction SilentlyContinue foreach ($file in $files) { $content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue if ($null -eq $content) { continue } foreach ($entry in $patterns.GetEnumerator()) { if ($content -match $entry.Value) { $findings += [ordered]@{ path = $file.FullName; category = $entry.Key } } } } return $findings } function Get-InternalStateEvidence { param([string]$Root) $categories = [ordered]@{ cutoverJournal = ".proxywarden-cutover" cutoverQuarantine = ".proxywarden-quarantine" packageStaging = ".proxywarden-package-staging" privilegedJobs = ".proxywarden-privileged-jobs" serviceLogs = ".proxywarden-service-logs" singBoxCleanupTombstone = ".proxywarden-sing-box-cleanup" } $result = @() foreach ($entry in $categories.GetEnumerator()) { $path = Join-Path $Root $entry.Value $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue $result += [ordered]@{ category = $entry.Key present = $null -ne $item itemType = if ($null -eq $item) { $null } elseif ($item.PSIsContainer) { "directory" } else { "file" } } } return $result } try { $quotedServiceFixture = '"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" -service' $quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\components\sing-box")) { throw "Quoted service PathName ownership self-test failed." } $plan = [ordered]@{ mode = $Mode serviceNames = @("ProxiFyreService", "ProxyWardenSingBox") foreignServiceName = $ForeignServiceName roots = [ordered]@{ app = [IO.Path]::GetFullPath($AppRoot) data = [IO.Path]::GetFullPath($DataRoot) proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot) singbox = [IO.Path]::GetFullPath($SingBoxRoot) } checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan", "internal-state-presence-only") } if ($Mode -eq "PlanOnly") { New-Result -Success $true -Action "audit-windows-smoke.plan" -Changed $false -Message "Windows smoke evidence plan is ready." -Details $plan exit 0 } if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $PWD ("audit-windows-smoke-{0}.json" -f (Get-Date -Format "yyyyMMdd-HHmmss")) } $outputFullPath = [IO.Path]::GetFullPath($OutputPath) $outputDirectory = Split-Path -Parent $outputFullPath if ([string]::IsNullOrWhiteSpace($outputDirectory)) { throw "OutputPath must include a writable directory." } New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null $serviceNames = @("ProxiFyreService", "ProxyWardenSingBox", $ForeignServiceName) $services = @(Get-ServiceEvidence -Names $serviceNames) $ownership = @( $services | Where-Object found | ForEach-Object { $expectedRoot = switch ($_.name) { "ProxiFyreService" { $ProxiFyreRoot } "ProxyWardenSingBox" { $SingBoxRoot } default { "" } } [ordered]@{ name = $_.name expectedManagedRoot = if ($expectedRoot) { [IO.Path]::GetFullPath($expectedRoot) } else { $null } pathUnderExpectedRoot = if ($expectedRoot) { Test-PathUnderRoot -Path (Get-ServiceExecutablePath -PathName $_.pathName) -Root $expectedRoot } else { $false } } } ) $report = [ordered]@{ capturedAt = (Get-Date).ToUniversalTime().ToString("o") computerName = $env:COMPUTERNAME os = (Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture) services = $services ownership = $ownership files = @(Get-FileEvidence -Root $DataRoot) secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot) internalState = @(Get-InternalStateEvidence -Root $AppRoot) } $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8 New-Result -Success $true -Action "audit-windows-smoke.capture" -Changed $true -Message "Read-only Windows smoke evidence captured." -Details @{ outputPath = $outputFullPath serviceCount = @($services | Where-Object found).Count fileCount = @($report.files).Count secretFindingCount = @($report.secretFindingCategories).Count internalStateCategoryCount = @($report.internalState).Count } } catch { New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{} exit 1 }