536 lines
23 KiB
PowerShell
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
|
|
}
|