acdream/tools/test-campaign-la-session-status.ps1

536 lines
23 KiB
PowerShell

<#
.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)][string]$ProcessCapturePath,
[Parameter(Mandatory = $true)][string]$CredentialProfilePath,
[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($ProcessCapturePath)) {
throw '-ProcessCapturePath must be absolute.'
}
$ProcessCapturePath = [IO.Path]::GetFullPath($ProcessCapturePath)
if (-not (Test-Path -LiteralPath $ProcessCapturePath -PathType Leaf)) {
throw "Process capture does not exist: $ProcessCapturePath"
}
$captureItem = Get-Item -LiteralPath $ProcessCapturePath -Force
if (($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Process capture must not be a reparse point.'
}
$captureDocument = [Text.Json.JsonDocument]::Parse(
[IO.File]::ReadAllText($ProcessCapturePath))
try {
$captureRoot = $captureDocument.RootElement
if ($captureRoot.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'Process capture root must be an object.'
}
$captureNames = @($captureRoot.EnumerateObject() | ForEach-Object { $_.Name })
$expectedCaptureNames = @(
'schemaVersion', 'kind', 'processId', 'processInstanceIdentity',
'sessionId', 'sessionConfigPath', 'commandLineFingerprintSha256',
'capturedUtc')
if ([string]::Join("`n", $captureNames) -cne
[string]::Join("`n", $expectedCaptureNames)) {
throw 'Process capture fields/order do not match schema v2.'
}
if ($captureRoot.GetProperty('schemaVersion').GetInt32() -ne 2 -or
$captureRoot.GetProperty('kind').GetString() -cne
'campaign-la-session-process-capture') {
throw 'Process capture schema/kind is invalid.'
}
$capturedProcessId = $captureRoot.GetProperty('processId').GetInt32()
if ($capturedProcessId -le 0) { throw 'Process capture PID is invalid.' }
$capturedProcessIdentity = $captureRoot.GetProperty(
'processInstanceIdentity').GetString()
$expectedIdentityPattern = if ($IsWindows) {
'^windows-creation-v1:[0-9]{15,19}$'
} else { '^linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+$' }
if ($capturedProcessIdentity -notmatch $expectedIdentityPattern) {
throw 'Process capture instance identity is invalid for this platform.'
}
$capturedSessionId = $captureRoot.GetProperty('sessionId').GetString()
if ([string]::IsNullOrWhiteSpace($capturedSessionId) -or
$capturedSessionId.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) {
throw 'Process capture session id is invalid.'
}
$capturedSessionConfigPath = $captureRoot.GetProperty(
'sessionConfigPath').GetString()
if (-not [IO.Path]::IsPathFullyQualified($capturedSessionConfigPath)) {
throw 'Process capture session-config path is not absolute.'
}
$normalizedCapturedConfigPath = [IO.Path]::GetFullPath(
$capturedSessionConfigPath)
if ($capturedSessionConfigPath -cne $normalizedCapturedConfigPath -or
[IO.Path]::GetFileName($capturedSessionConfigPath) -cne 'session.json' -or
[IO.Path]::GetFileName([IO.Path]::GetDirectoryName(
$capturedSessionConfigPath)) -cne $capturedSessionId) {
throw 'Process capture session-config path is not the exact normalized session path.'
}
$capturedCommandFingerprint = $captureRoot.GetProperty(
'commandLineFingerprintSha256').GetString()
if ($capturedCommandFingerprint -cnotmatch '^[0-9a-f]{64}$') {
throw 'Process capture command-line fingerprint is invalid.'
}
$capturedUtcText = $captureRoot.GetProperty('capturedUtc').GetString()
$capturedUtc = [DateTimeOffset]::MinValue
if (-not [DateTimeOffset]::TryParseExact(
$capturedUtcText,
'O',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::RoundtripKind,
[ref]$capturedUtc) -or $capturedUtc.Offset -ne [TimeSpan]::Zero) {
throw 'Process capture timestamp is not exact UTC round-trip form.'
}
}
finally { $captureDocument.Dispose() }
if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
$ExpectedSessionId -cne $capturedSessionId) {
throw 'Process capture session id does not match -ExpectedSessionId.'
}
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 ([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)
$capturedState = $null
do {
$currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity `
-ProcessId $capturedProcessId
$correlations = @(Get-CampaignLaSessionProcessCorrelations)
$capturedState = Test-CampaignLaCapturedProcessState `
-ProcessId $capturedProcessId `
-ProcessInstanceIdentity $capturedProcessIdentity `
-SessionConfigPath $capturedSessionConfigPath `
-CurrentProcessInstanceIdentity $currentProcessIdentity `
-Correlations $correlations
if (-not $capturedState.SameInstanceAlive -and
-not $capturedState.ExactConfigPathAlive) {
break
}
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($capturedState.SameInstanceAlive) {
$failures.Add(
"captured launcher child process instance PID $capturedProcessId remains alive")
}
if ($capturedState.ExactConfigPathAlive) {
$failures.Add('a launcher child remains correlated to the exact session-config path')
}
$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
capturedProcessId = $capturedProcessId
capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive)
capturedPidReused = [bool]$capturedState.PidReused
sessionConfigCorrelationChecked = $true
sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive)
processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant()
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
}