458 lines
19 KiB
PowerShell
458 lines
19 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)]
|
|
[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
|
|
}
|