fix(launcher): harden LA11 gate evidence

This commit is contained in:
Erik 2026-08-15 01:45:15 +02:00
parent accd01a008
commit 9f9c116792
7 changed files with 514 additions and 89 deletions

View file

@ -14,10 +14,8 @@ 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]$ProcessCapturePath,
[Parameter(Mandatory = $true)][string]$CredentialProfilePath,
[string]$SessionConfigPath,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
@ -42,6 +40,87 @@ if (-not [IO.Path]::IsPathFullyQualified($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.'
}
@ -82,12 +161,6 @@ elseif ($IsWindows) {
}
}
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"
}
@ -402,27 +475,29 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) {
}
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
$capturedState = $null
do {
$expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue
if ($null -eq $expectedProcess) { break }
$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 ($null -ne $expectedProcess) {
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive")
if ($capturedState.SameInstanceAlive) {
$failures.Add(
"captured launcher child process instance PID $capturedProcessId 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 ',')")
}
if ($capturedState.ExactConfigPathAlive) {
$failures.Add('a launcher child remains correlated to the exact session-config path')
}
$reportDirectory = Split-Path -Parent $ReportPath
@ -442,9 +517,12 @@ $report = [ordered]@{
eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen
expectedProcessId = $ExpectedProcessId
processExited = ($null -eq $expectedProcess)
sessionConfigCorrelationChecked = $pathCorrelationChecked
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)