<# .SYNOPSIS Strict Campaign LA v1 session-status and terminal-process validator. .DESCRIPTION Validates exact JSONL property sets and property order, lifecycle order for probe/guiSelect/gui/headless, terminal semantics, plugin expectations, credential redaction, and absence of launcher child-process leaks. The report contains hashes and event names only; it does not copy account, character, command, plugin-error, or other payload text. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$StatusFile, [Parameter(Mandatory = $true)] [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, [Parameter(Mandatory = $true)] [ValidateRange(1, 2147483647)][int]$ExpectedProcessId, [Parameter(Mandatory = $true)][string]$CredentialProfilePath, [string]$SessionConfigPath, [string]$ExpectedSessionId, [string[]]$ExpectedPlugin = @(), [switch]$ExpectNoEnteredWorld, [switch]$AllowPluginFailure, [switch]$AllowLoginCommandFailure, [string]$ReportPath, [int]$ProcessExitWaitSeconds = 5 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'Campaign LA status validation requires PowerShell 7 or newer.' } if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' } . (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { $StatusFile = [IO.Path]::GetFullPath($StatusFile) } if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { throw "Status file does not exist: $StatusFile" } if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) { throw '-CredentialProfilePath must be absolute.' } $CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath) if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) { throw "Credential profile does not exist: $CredentialProfilePath" } $credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Credential profile must not be a reparse point.' } if ($IsLinux) { $ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) { throw 'Credential profile must have exact owner-only mode 0600.' } } elseif ($IsWindows) { $broadSids = @( 'S-1-1-0', # Everyone 'S-1-5-11', # Authenticated Users 'S-1-5-32-545', # Builtin Users 'S-1-5-32-546') # Guests $acl = Get-Acl -LiteralPath $CredentialProfilePath if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' } foreach ($rule in $acl.Access) { if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { continue } try { $sid = $rule.IdentityReference.Translate( [Security.Principal.SecurityIdentifier]).Value } catch { $sid = [string]$rule.IdentityReference.Value } if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) { throw 'Credential profile grants access to a broad Windows identity.' } } } else { throw 'Campaign LA status validation supports Windows and Linux only.' } if (-not [string]::IsNullOrWhiteSpace($SessionConfigPath)) { if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { throw '-SessionConfigPath must be absolute.' } $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) } if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = "$StatusFile.validation.json" } elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { $ReportPath = [IO.Path]::GetFullPath($ReportPath) } $exactFields = @{ started = @('v', 'e', 't', 'sessionId') connected = @('v', 'e', 't', 'sessionId') characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters') enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName') pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin') pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error') loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error') disconnected = @('v', 'e', 't', 'sessionId', 'reason') exited = @('v', 'e', 't', 'sessionId', 'code', 'reason') } $failures = [Collections.Generic.List[string]]::new() $eventNames = [Collections.Generic.List[string]]::new() $loadedPlugins = [Collections.Generic.List[string]]::new() $sessionId = $null $previousTimestamp = [DateTimeOffset]::MinValue $terminalSeen = $false $forbiddenValues = [Collections.Generic.HashSet[string]]::new( [StringComparer]::Ordinal) function Add-CredentialValues([Text.Json.JsonElement]$Element) { if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { foreach ($property in $Element.EnumerateObject()) { if ($property.Name -imatch '^(password|secret)$' -and $property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) { $value = $property.Value.GetString() if (-not [string]::IsNullOrEmpty($value)) { $null = $forbiddenValues.Add($value) } } Add-CredentialValues $property.Value } } elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item } } } $credentialDocument = [Text.Json.JsonDocument]::Parse( [IO.File]::ReadAllText($CredentialProfilePath)) try { Add-CredentialValues $credentialDocument.RootElement } finally { $credentialDocument.Dispose() } if ($forbiddenValues.Count -eq 0) { throw 'Credential profile contains no non-empty password/secret value.' } function Test-CredentialEcho([Text.Json.JsonElement]$Element) { if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) { [string]$text = $Element.GetString() foreach ($secret in $forbiddenValues) { if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true } } return $false } if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { foreach ($property in $Element.EnumerateObject()) { if (Test-CredentialEcho $property.Value) { return $true } } } elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { foreach ($item in $Element.EnumerateArray()) { if (Test-CredentialEcho $item) { return $true } } } return $false } function Get-Properties([Text.Json.JsonElement]$Element) { $properties = [Collections.Generic.List[object]]::new() foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) } return @($properties) } function Assert-String( [Text.Json.JsonElement]$Root, [string]$Name, [bool]$AllowEmpty = $false) { $value = $Root.GetProperty($Name) if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) { throw "field '$Name' is not a string" } $text = $value.GetString() if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) { throw "field '$Name' is empty" } return $text } function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) { $value = $Root.GetProperty($Name) if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { throw "field '$Name' is not a number" } return $value.GetInt32() } function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) { $value = $Root.GetProperty($Name) if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { throw "field '$Name' is not a number" } return $value.GetUInt32() } $lines = @(Get-Content -LiteralPath $StatusFile) if ($lines.Count -eq 0) { $failures.Add('status stream is empty') } for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { $lineNumber = $lineIndex + 1 $line = $lines[$lineIndex] if ([string]::IsNullOrWhiteSpace($line)) { $failures.Add("line $lineNumber is empty") continue } if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') { $failures.Add("line $lineNumber contains a credential-like JSON field") } $document = $null try { $document = [Text.Json.JsonDocument]::Parse($line) $root = $document.RootElement if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { throw 'root is not an object' } if (Test-CredentialEcho $root) { throw 'an allowed string field contains an exact credential value' } $properties = @(Get-Properties $root) $names = @($properties | ForEach-Object { $_.Name }) if (@($names | Sort-Object -Unique).Count -ne $names.Count) { throw 'object contains duplicate fields' } $eventName = Assert-String $root 'e' if (-not $exactFields.ContainsKey($eventName)) { throw 'event name is not in the v1 vocabulary' } $expected = $exactFields[$eventName] if ($names.Count -ne $expected.Count -or [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) { throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'" } if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' } $timestampText = Assert-String $root 't' $timestamp = [DateTimeOffset]::MinValue if (-not [DateTimeOffset]::TryParseExact( $timestampText, 'O', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind, [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) { throw 'field t is not an exact UTC round-trip timestamp' } if ($timestamp -lt $previousTimestamp) { throw 'timestamp order moved backwards' } $previousTimestamp = $timestamp $lineSessionId = Assert-String $root 'sessionId' if ($null -eq $sessionId) { $sessionId = $lineSessionId } if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' } if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and $lineSessionId -cne $ExpectedSessionId) { throw 'sessionId does not match -ExpectedSessionId' } if ($terminalSeen) { throw 'an event appears after terminal exited' } switch ($eventName) { 'characterList' { $null = Assert-String $root 'accountName' $true $slotCount = Assert-Int32 $root 'slotCount' if ($slotCount -lt 0) { throw 'slotCount is negative' } $characters = $root.GetProperty('characters') if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) { throw 'characters is not an array' } foreach ($character in $characters.EnumerateArray()) { if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) { throw 'a character is not an object' } $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name }) $characterExpected = @('id', 'name', 'secondsGreyedOut') if ([string]::Join("`n", $characterNames) -cne [string]::Join("`n", $characterExpected)) { throw 'a character fields/order is not id,name,secondsGreyedOut' } $null = Assert-UInt32 $character 'id' $null = Assert-String $character 'name' $null = Assert-UInt32 $character 'secondsGreyedOut' } } 'enteredWorld' { $null = Assert-UInt32 $root 'characterId' $null = Assert-String $root 'characterName' } 'pluginLoaded' { $loadedPlugins.Add((Assert-String $root 'plugin')) } 'pluginFailed' { $null = Assert-String $root 'plugin' $null = Assert-String $root 'error' if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' } } 'loginCommandFailed' { if ((Assert-Int32 $root 'commandIndex') -lt 0) { throw 'commandIndex is negative' } $null = Assert-String $root 'command' $true $null = Assert-String $root 'error' if (-not $AllowLoginCommandFailure) { throw 'loginCommandFailed is not allowed for this row' } } 'disconnected' { $reason = Assert-String $root 'reason' if ($reason -cne 'stopped') { throw "terminal disconnected reason is '$reason', expected 'stopped'" } } 'exited' { $code = Assert-Int32 $root 'code' $reason = Assert-String $root 'reason' if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } if ($reason -cne $expectedReason) { throw "terminal reason does not match mode '$Mode'" } $terminalSeen = $true } } $eventNames.Add($eventName) } catch { $failures.Add("line ${lineNumber}: $($_.Exception.Message)") } finally { if ($null -ne $document) { $document.Dispose() } } } function Require-Count([string]$EventName, [int]$Count) { $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count if ($actual -ne $Count) { $failures.Add("event '$EventName' count is $actual, expected $Count") } } function First-Index([string]$EventName) { for ($index = 0; $index -lt $eventNames.Count; $index++) { if ($eventNames[$index] -ceq $EventName) { return $index } } return -1 } Require-Count 'started' 1 Require-Count 'connected' 1 Require-Count 'characterList' 1 Require-Count 'disconnected' 1 Require-Count 'exited' 1 $expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 }) if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') { $failures.Add('started is not the first event') } if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') { $failures.Add('exited is not the final event') } $orderedRequired = if (-not $expectEnteredWorld) { @('started', 'connected', 'characterList', 'disconnected', 'exited') } else { @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited') } $last = -1 foreach ($name in $orderedRequired) { $next = First-Index $name if ($next -ge 0 -and $next -le $last) { $failures.Add("event '$name' is out of lifecycle order") } $last = $next } $connectedIndex = First-Index 'connected' foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) { if ($eventNames.Count -eq 0) { break } if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and ($index -le 0 -or $index -ge $connectedIndex)) { $failures.Add("plugin event at index $index is outside started-to-connected startup") } } foreach ($plugin in $ExpectedPlugin) { if (-not ($loadedPlugins -ccontains $plugin)) { $failures.Add("expected plugin '$plugin' did not emit pluginLoaded") } } $expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique) $loadedPluginSet = @($loadedPlugins | Sort-Object -Unique) if ($loadedPlugins.Count -ne $loadedPluginSet.Count) { $failures.Add('a plugin emitted pluginLoaded more than once') } if ([string]::Join("`n", $loadedPluginSet) -cne [string]::Join("`n", $expectedPluginSet)) { $failures.Add( "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)") } $enteredWorldIndex = First-Index 'enteredWorld' for ($index = 0; $index -lt $eventNames.Count; $index++) { if ($eventNames[$index] -ceq 'loginCommandFailed' -and ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) { $failures.Add("loginCommandFailed at index $index did not follow enteredWorld") } } $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) do { $expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue if ($null -eq $expectedProcess) { break } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) if ($null -ne $expectedProcess) { $failures.Add("expected launcher child PID $ExpectedProcessId remains alive") } $pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath) if ($pathCorrelationChecked) { $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) do { $correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath) if ($correlated.Count -eq 0) { break } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) if ($correlated.Count -gt 0) { $failures.Add( "session-config-correlated launcher child PID(s) remain: $($correlated -join ',')") } } $reportDirectory = Split-Path -Parent $ReportPath if (-not [string]::IsNullOrEmpty($reportDirectory)) { $null = New-Item -ItemType Directory -Force -Path $reportDirectory } $report = [ordered]@{ schemaVersion = 1 kind = 'campaign-la-session-status-validation' success = ($failures.Count -eq 0) mode = $Mode enteredWorldExpected = $expectEnteredWorld statusFile = [IO.Path]::GetFileName($StatusFile) statusSize = (Get-Item -LiteralPath $StatusFile).Length statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant() lineCount = $lines.Count eventNames = @($eventNames) loadedPluginCount = $loadedPlugins.Count terminalObserved = $terminalSeen expectedProcessId = $ExpectedProcessId processExited = ($null -eq $expectedProcess) sessionConfigCorrelationChecked = $pathCorrelationChecked credentialPermissionsValidated = $true forbiddenCredentialValueCount = $forbiddenValues.Count failures = @($failures) validatedUtc = [DateTime]::UtcNow.ToString('O') } $report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM Write-Host "Campaign LA status validation report: $ReportPath" if (-not $report.success) { $failures | ForEach-Object { Write-Error $_ } exit 1 }