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

@ -180,8 +180,10 @@ The helper rejects nonempty output, invalid or non-monotonic versions, missing
root executables (including the co-deployed Bake CLI), nonabsolute inputs, root executables (including the co-deployed Bake CLI), nonabsolute inputs,
output/source overlap in either direction, and any reparse point in source or output/source overlap in either direction, and any reparse point in source or
output ancestry. It enumerates normalized relative paths with ordinal ordering, output ancestry. It enumerates normalized relative paths with ordinal ordering,
never its own output, and normalizes ZIP host metadata so Windows/Linux hashes never its own output, and normalizes ZIP origin to Unix on both hosts so
are identical under multiple cultures. It writes fixed-timestamp sorted ZIPs, Windows/Linux hashes are identical under multiple cultures while native Linux
extraction retains 0755 for App/Headless/Launcher/Bake and 0644 for ordinary
files. It writes fixed-timestamp sorted ZIPs,
the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only
server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B
selector. Both generated helpers reject a `-Root` other than their own fixture selector. Both generated helpers reject a `-Root` other than their own fixture
@ -233,8 +235,10 @@ session composer, and orchestrator must all use this one exact path set.
For every play/probe row, start this gate-only PID watcher immediately before For every play/probe row, start this gate-only PID watcher immediately before
clicking Refresh/Play. It correlates only the unique isolated session-config clicking Refresh/Play. It correlates only the unique isolated session-config
path, records neither command line nor config contents, and must finish while path, records neither raw command line nor config contents, and must finish
the child is still live: while the child is still live. Its safe sidecar contains the normalized config
path, a sanitized command fingerprint, and PID plus an OS-native process-start
identity so later PID reuse cannot become a false leak:
```powershell ```powershell
$CapturePath = Join-Path $Evidence '<ROW>-process.capture.json' $CapturePath = Join-Path $Evidence '<ROW>-process.capture.json'
@ -262,8 +266,7 @@ $Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.js
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
-StatusFile $Status ` -StatusFile $Status `
-Mode '<probe|guiSelect|gui|headless>' ` -Mode '<probe|guiSelect|gui|headless>' `
-ExpectedProcessId $Capture.processId ` -ProcessCapturePath $CapturePath `
-SessionConfigPath $SessionConfig `
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
-ExpectedSessionId $Capture.sessionId ` -ExpectedSessionId $Capture.sessionId `
-ReportPath (Join-Path $Evidence '<ROW>-status.validation.json') -ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')
@ -273,14 +276,16 @@ Add `-ExpectedPlugin acdream.smoke` to rows DF. The validator enforces exact
v1 fields **and property order**, one session id, UTC monotonic timestamps, v1 fields **and property order**, one session id, UTC monotonic timestamps,
mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
command failure, exact terminal `disconnected.reason == stopped`, credential command failure, exact terminal `disconnected.reason == stopped`, credential
redaction, and that exact captured PID is gone. Optional config-path correlation redaction, and that exact captured process instance is gone. Independent exact
uses Windows CIM or Linux `/proc/*/cmdline`; it never globally scans a process config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never
name, so unrelated same-name processes and Linux's 15-character names do not globally scans a process name, treats a different start identity on a reused PID
affect the result. The validator verifies owner-only profile access, reads only as a different process, and is unaffected by unrelated same-name processes or
password/secret fields in memory, recursively checks every allowed status Linux's 15-character names. The validator verifies owner-only profile access,
reads only password/secret fields in memory, recursively checks every allowed status
string (including command/error text), and reports only the forbidden-value string (including command/error text), and reports only the forbidden-value
count and status hash—never credential content or a credential hash. Keep the count and status hash—never credential content or a credential hash. Keep the
profile and raw `session.json`/`status.jsonl` local; never upload them. profile, raw `session.json`/`status.jsonl`, and raw process-capture sidecar
(which contains the absolute isolated path) local; never upload them.
## 5. Serial Windows user rows AH ## 5. Serial Windows user rows AH
@ -429,8 +434,7 @@ called. This connected row proves the actual ACE graceful-logout half. Issue
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
-StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') ` -StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') `
-Mode guiSelect ` -Mode guiSelect `
-ExpectedProcessId '<CAPTURED_CHILD_PID>' ` -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') `
-SessionConfigPath (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/session.json') `
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
-ExpectNoEnteredWorld ` -ExpectNoEnteredWorld `
-ExpectedSessionId '<SESSION_ID>' ` -ExpectedSessionId '<SESSION_ID>' `

View file

@ -1,31 +1,127 @@
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
function Get-CampaignLaSha256([string]$Text) {
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
return [Convert]::ToHexString(
[Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
}
function Get-CampaignLaCommandLineFingerprint(
[string]$ExecutablePath,
[string]$ConfigArgument,
[string]$SessionConfigPath) {
if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or
-not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or
$ConfigArgument -cnotin @('--config', '--session-config')) {
throw 'Cannot fingerprint an incomplete launcher-child command line.'
}
$executable = [IO.Path]::GetFullPath($ExecutablePath)
$config = [IO.Path]::GetFullPath($SessionConfigPath)
# Only the executable and the recognized config argument are retained in
# this projection. Launcher credentials use stdin; unrelated argv is
# deliberately excluded so an accidental secret can never enter evidence.
return Get-CampaignLaSha256(
"campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config")
}
function Get-CampaignLaLinuxProcessIdentity(
[string]$ProcessDirectory,
[string]$BootId) {
$stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat'))
$commandEnd = $stat.LastIndexOf(')')
if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) {
throw 'Linux process stat record is malformed.'
}
# The tail begins at field 3 (state); field 22 (starttime) is index 19.
$tail = @($stat.Substring($commandEnd + 2).Split(
' ',
[StringSplitOptions]::RemoveEmptyEntries))
if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' }
$startTicks = [uint64]::Parse(
$tail[19],
[Globalization.NumberStyles]::None,
[Globalization.CultureInfo]::InvariantCulture)
return "linux-proc-start-v1:$BootId`:$startTicks"
}
function Get-CampaignLaProcessInstanceIdentity {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)][int]$ProcessId)
if ($IsWindows) {
$candidate = Get-CimInstance Win32_Process `
-Filter "ProcessId=$ProcessId" -ErrorAction Stop
if ($null -eq $candidate) { return $null }
if ($null -eq $candidate.CreationDate) {
throw "Windows process $ProcessId has no creation time."
}
return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
}
if ($IsLinux) {
$directory = "/proc/$ProcessId"
if (-not [IO.Directory]::Exists($directory)) { return $null }
try {
$bootId = [IO.File]::ReadAllText(
'/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
throw 'Linux boot id is malformed.'
}
return Get-CampaignLaLinuxProcessIdentity $directory $bootId
}
catch [IO.FileNotFoundException] { return $null }
catch [IO.DirectoryNotFoundException] { return $null }
catch [IO.IOException] {
if (-not [IO.Directory]::Exists($directory)) { return $null }
throw
}
}
throw 'Campaign LA process identity supports Windows and Linux only.'
}
function Get-CampaignLaSessionProcessCorrelations { function Get-CampaignLaSessionProcessCorrelations {
[CmdletBinding()] [CmdletBinding()]
param() param()
$matches = [Collections.Generic.List[object]]::new() $correlations = [Collections.Generic.List[object]]::new()
if ($IsWindows) { if ($IsWindows) {
$pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))' $pattern = '(?i)(?:^|\s)(--config|--session-config)\s+(?:"([^"]+)"|(\S+))'
foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) {
$commandLine = [string]$candidate.CommandLine $commandLine = [string]$candidate.CommandLine
if ([string]::IsNullOrWhiteSpace($commandLine)) { continue } $executablePath = [string]$candidate.ExecutablePath
if ([string]::IsNullOrWhiteSpace($commandLine) -or
-not [IO.Path]::IsPathFullyQualified($executablePath) -or
$null -eq $candidate.CreationDate) {
continue
}
$identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
foreach ($match in [Text.RegularExpressions.Regex]::Matches( foreach ($match in [Text.RegularExpressions.Regex]::Matches(
$commandLine, $commandLine,
$pattern)) { $pattern)) {
$value = if ($match.Groups[1].Success) { $argument = $match.Groups[1].Value.ToLowerInvariant()
$match.Groups[1].Value $value = if ($match.Groups[2].Success) {
} else { $match.Groups[2].Value } $match.Groups[2].Value
} else { $match.Groups[3].Value }
if ([IO.Path]::IsPathFullyQualified($value)) { if ([IO.Path]::IsPathFullyQualified($value)) {
$matches.Add([pscustomobject]@{ $configPath = [IO.Path]::GetFullPath($value)
$correlations.Add([pscustomobject]@{
ProcessId = [int]$candidate.ProcessId ProcessId = [int]$candidate.ProcessId
SessionConfigPath = [IO.Path]::GetFullPath($value) ProcessInstanceIdentity = $identity
SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$executablePath $argument $configPath
}) })
} }
} }
} }
} }
elseif ($IsLinux) { elseif ($IsLinux) {
$bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
throw 'Linux boot id is malformed.'
}
foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) { foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) {
$leaf = [IO.Path]::GetFileName($directory) $leaf = [IO.Path]::GetFileName($directory)
$processId = 0 $processId = 0
@ -37,24 +133,34 @@ function Get-CampaignLaSessionProcessCorrelations {
continue continue
} }
try { try {
$identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId
$bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline')) $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline'))
if ($bytes.Length -eq 0) { continue } if ($bytes.Length -eq 0) { continue }
$arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split( $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split(
[char]0, [char]0,
[StringSplitOptions]::RemoveEmptyEntries)) [StringSplitOptions]::RemoveEmptyEntries))
$identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId
if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or
-not [IO.Path]::IsPathFullyQualified($arguments[0])) {
continue
}
for ($index = 0; $index + 1 -lt $arguments.Count; $index++) { for ($index = 0; $index + 1 -lt $arguments.Count; $index++) {
if ($arguments[$index] -cin @('--config', '--session-config') -and if ($arguments[$index] -cin @('--config', '--session-config') -and
[IO.Path]::IsPathFullyQualified($arguments[$index + 1])) { [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) {
$matches.Add([pscustomobject]@{ $configPath = [IO.Path]::GetFullPath($arguments[$index + 1])
$correlations.Add([pscustomobject]@{
ProcessId = $processId ProcessId = $processId
SessionConfigPath = [IO.Path]::GetFullPath( ProcessInstanceIdentity = $identityBefore
$arguments[$index + 1]) SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$arguments[0] $arguments[$index] $configPath
}) })
} }
} }
} }
catch [IO.IOException] { catch [IO.IOException] {
# A process may exit between /proc enumeration and cmdline read. # A process may exit between /proc enumeration and either read.
} }
catch [UnauthorizedAccessException] { catch [UnauthorizedAccessException] {
# Other-user processes cannot be the owner-readable gate child. # Other-user processes cannot be the owner-readable gate child.
@ -65,7 +171,7 @@ function Get-CampaignLaSessionProcessCorrelations {
throw 'Campaign LA process correlation supports Windows and Linux only.' throw 'Campaign LA process correlation supports Windows and Linux only.'
} }
return @($matches) return @($correlations)
} }
function Get-CampaignLaCorrelatedProcessIds { function Get-CampaignLaCorrelatedProcessIds {
@ -79,15 +185,48 @@ function Get-CampaignLaCorrelatedProcessIds {
$comparison = if ($IsWindows) { $comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase [StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal } } else { [StringComparison]::Ordinal }
$matches = [Collections.Generic.HashSet[int]]::new() $processIds = [Collections.Generic.HashSet[int]]::new()
foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) {
if ([string]::Equals( if ([string]::Equals(
$candidate.SessionConfigPath, $candidate.SessionConfigPath,
$SessionConfigPath, $SessionConfigPath,
$comparison)) { $comparison)) {
$null = $matches.Add([int]$candidate.ProcessId) $null = $processIds.Add([int]$candidate.ProcessId)
} }
} }
return @($matches | Sort-Object) return @($processIds | Sort-Object)
}
function Test-CampaignLaCapturedProcessState {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][int]$ProcessId,
[Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity,
[Parameter(Mandatory = $true)][string]$SessionConfigPath,
[AllowNull()][string]$CurrentProcessInstanceIdentity,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()][object[]]$Correlations)
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
$CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity
$exactConfigPathAlive = $false
$pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
$CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity
foreach ($candidate in $Correlations) {
if ([string]::Equals(
[string]$candidate.SessionConfigPath,
$SessionConfigPath,
$comparison)) {
$exactConfigPathAlive = $true
}
}
return [pscustomobject]@{
SameInstanceAlive = $sameInstanceAlive
ExactConfigPathAlive = $exactConfigPathAlive
PidReused = $pidReused
}
} }

View file

@ -90,18 +90,26 @@ if ($correlations.Count -ne 1) {
throw 'No live process uses the isolated session config.' throw 'No live process uses the isolated session config.'
} }
$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath) $SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath)
$processIdentity = [string]$correlations[0].ProcessInstanceIdentity
$commandFingerprint = [string]$correlations[0].CommandLineFingerprintSha256
if ($processIdentity -notmatch '^(windows-creation-v1:[0-9]{15,19}|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]+)$' -or
$commandFingerprint -notmatch '^[0-9a-f]{64}$') {
throw 'The correlated process instance evidence is malformed.'
}
$directory = Split-Path -Parent $ReportPath $directory = Split-Path -Parent $ReportPath
if (-not [string]::IsNullOrEmpty($directory)) { if (-not [string]::IsNullOrEmpty($directory)) {
$null = New-Item -ItemType Directory -Force -Path $directory $null = New-Item -ItemType Directory -Force -Path $directory
} }
$report = [ordered]@{ $report = [ordered]@{
schemaVersion = 1 schemaVersion = 2
kind = 'campaign-la-session-process-capture' kind = 'campaign-la-session-process-capture'
processId = [int]$correlations[0].ProcessId processId = [int]$correlations[0].ProcessId
processInstanceIdentity = $processIdentity
sessionId = [IO.Path]::GetFileName( sessionId = [IO.Path]::GetFileName(
[IO.Path]::GetDirectoryName($SessionConfigPath)) [IO.Path]::GetDirectoryName($SessionConfigPath))
sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath) sessionConfigPath = $SessionConfigPath
commandLineFingerprintSha256 = $commandFingerprint
capturedUtc = [DateTime]::UtcNow.ToString('O') capturedUtc = [DateTime]::UtcNow.ToString('O')
} }
$report | ConvertTo-Json -Depth 3 | $report | ConvertTo-Json -Depth 3 |

View file

@ -208,10 +208,11 @@ function Set-DeterministicZipHostPlatform([string]$Path) {
(Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
throw "Generated ZIP central-directory entry is invalid: $Path" throw "Generated ZIP central-directory entry is invalid: $Path"
} }
# ZipArchive intentionally stamps the creating host (FAT on Windows, # ZipArchive stamps the creating host (FAT on Windows, Unix on Linux)
# Unix on Linux) in the upper byte of "version made by". Normalize it # in the upper byte of "version made by". Normalize to Unix so native
# to FAT; permissions are already explicit in ExternalAttributes. # extraction honors the explicit regular-file type and 0755/0644 mode
$bytes[[int]$cursor + 5] = 0 # bits already stored in ExternalAttributes.
$bytes[[int]$cursor + 5] = 3
$nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28)
$extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30)
$commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32)

View file

@ -28,6 +28,7 @@ if ([string]::IsNullOrWhiteSpace($pwsh)) {
} }
$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1' $validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1'
$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1' $capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1'
. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.ps1')
function Write-Profile([string]$Path, [string]$Secret) { function Write-Profile([string]$Path, [string]$Secret) {
$document = [ordered]@{ $document = [ordered]@{
@ -86,16 +87,14 @@ function Invoke-Validator(
[string]$Status, [string]$Status,
[string]$Profile, [string]$Profile,
[string]$Report, [string]$Report,
[int]$ExpectedProcessId, [string]$ProcessCapture,
[bool]$ShouldPass, [bool]$ShouldPass) {
[string]$SessionConfig = '') {
$arguments = [Collections.Generic.List[string]]::new() $arguments = [Collections.Generic.List[string]]::new()
foreach ($value in @( foreach ($value in @(
'-NoProfile', '-File', $validator, '-NoProfile', '-File', $validator,
'-StatusFile', $Status, '-StatusFile', $Status,
'-Mode', 'gui', '-Mode', 'gui',
'-ExpectedProcessId', $ExpectedProcessId.ToString( '-ProcessCapturePath', $ProcessCapture,
[Globalization.CultureInfo]::InvariantCulture),
'-CredentialProfilePath', $Profile, '-CredentialProfilePath', $Profile,
'-ExpectedPlugin', 'smoke', '-ExpectedPlugin', 'smoke',
'-AllowPluginFailure', '-AllowPluginFailure',
@ -103,10 +102,6 @@ function Invoke-Validator(
'-ReportPath', $Report)) { '-ReportPath', $Report)) {
$arguments.Add($value) $arguments.Add($value)
} }
if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) {
$arguments.Add('-SessionConfigPath')
$arguments.Add($SessionConfig)
}
$start = [Diagnostics.ProcessStartInfo]::new($pwsh) $start = [Diagnostics.ProcessStartInfo]::new($pwsh)
$start.UseShellExecute = $false $start.UseShellExecute = $false
$start.CreateNoWindow = $true $start.CreateNoWindow = $true
@ -127,6 +122,30 @@ function Invoke-Validator(
} }
} }
function Write-ProcessCapture(
[string]$Path,
[int]$ProcessId,
[string]$ProcessInstanceIdentity,
[string]$SessionConfigPath,
[string]$CommandFingerprint = ('a' * 64)) {
$sessionId = [IO.Path]::GetFileName(
[IO.Path]::GetDirectoryName($SessionConfigPath))
$document = [ordered]@{
schemaVersion = 2
kind = 'campaign-la-session-process-capture'
processId = $ProcessId
processInstanceIdentity = $ProcessInstanceIdentity
sessionId = $sessionId
sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
commandLineFingerprintSha256 = $CommandFingerprint
capturedUtc = [DateTime]::UtcNow.ToString('O')
}
[IO.File]::WriteAllText(
$Path,
($document | ConvertTo-Json -Depth 4),
[Text.UTF8Encoding]::new($false))
}
$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh) $quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh)
$quickInfo.UseShellExecute = $false $quickInfo.UseShellExecute = $false
$quickInfo.ArgumentList.Add('-NoProfile') $quickInfo.ArgumentList.Add('-NoProfile')
@ -138,13 +157,33 @@ $goneProcessId = $quick.Id
$quick.WaitForExit() $quick.WaitForExit()
$quick.Dispose() $quick.Dispose()
$sessionRoot = Join-Path $OutputDirectory 'fixture-session'
$null = New-Item -ItemType Directory -Path $sessionRoot
$sessionConfig = Join-Path $sessionRoot 'session.json'
[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false))
$syntheticIdentity = if ($IsWindows) {
'windows-creation-v1:638000000000000000'
} else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' }
$goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json'
Write-ProcessCapture `
$goneCapture $goneProcessId $syntheticIdentity $sessionConfig
$profile = Join-Path $OutputDirectory 'launcher-profiles.json' $profile = Join-Path $OutputDirectory 'launcher-profiles.json'
Write-Profile $profile 'la11-positive-secret-7E477A2D' Write-Profile $profile 'la11-positive-secret-7E477A2D'
$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl' $positiveStatus = Join-Path $OutputDirectory 'positive.jsonl'
Write-Events $positiveStatus (New-GuiEvents) Write-Events $positiveStatus (New-GuiEvents)
Invoke-Validator ` Invoke-Validator `
$positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') ` $positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') `
$goneProcessId $true $goneCapture $true
$malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json'
[IO.File]::WriteAllText(
$malformedCapture,
'{"schemaVersion":2}',
[Text.UTF8Encoding]::new($false))
Invoke-Validator `
$positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') `
$malformedCapture $false
foreach ($reason in @('transport', 'reconnect', 'other')) { foreach ($reason in @('transport', 'reconnect', 'other')) {
$events = @(New-GuiEvents) $events = @(New-GuiEvents)
@ -152,7 +191,7 @@ foreach ($reason in @('transport', 'reconnect', 'other')) {
$path = Join-Path $OutputDirectory "reason-$reason.jsonl" $path = Join-Path $OutputDirectory "reason-$reason.jsonl"
$report = Join-Path $OutputDirectory "reason-$reason.validation.json" $report = Join-Path $OutputDirectory "reason-$reason.validation.json"
Write-Events $path $events Write-Events $path $events
Invoke-Validator $path $profile $report $goneProcessId $false Invoke-Validator $path $profile $report $goneCapture $false
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
if (-not ($result.failures -match 'disconnected reason')) { if (-not ($result.failures -match 'disconnected reason')) {
throw "Disconnected reason '$reason' was not rejected by its exact assertion." throw "Disconnected reason '$reason' was not rejected by its exact assertion."
@ -186,7 +225,7 @@ foreach ($case in $secretCases) {
$path = Join-Path $OutputDirectory "secret-$case.jsonl" $path = Join-Path $OutputDirectory "secret-$case.jsonl"
$report = Join-Path $OutputDirectory "secret-$case.validation.json" $report = Join-Path $OutputDirectory "secret-$case.validation.json"
Write-Events $path $events Write-Events $path $events
Invoke-Validator $path $caseProfile $report $goneProcessId $false Invoke-Validator $path $caseProfile $report $goneCapture $false
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
if (-not ($result.failures -match 'credential value')) { if (-not ($result.failures -match 'credential value')) {
throw "Credential echo case '$case' was not rejected by recursive scanning." throw "Credential echo case '$case' was not rejected by recursive scanning."
@ -212,12 +251,14 @@ if ($IsLinux) {
[IO.File]::GetUnixFileMode($sourceHost)) [IO.File]::GetUnixFileMode($sourceHost))
} }
$sessionConfig = Join-Path $OutputDirectory 'session.json'
[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false))
$targetReady = Join-Path $OutputDirectory 'target.ready' $targetReady = Join-Path $OutputDirectory 'target.ready'
$targetRelease = Join-Path $OutputDirectory 'target.release' $targetRelease = Join-Path $OutputDirectory 'target.release'
$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' $unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready'
$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release' $unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release'
$unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session'
$null = New-Item -ItemType Directory -Path $unrelatedSessionRoot
$unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json'
[IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false))
function Start-Fixture([string[]]$Arguments) { function Start-Fixture([string[]]$Arguments) {
$start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost)
@ -230,7 +271,7 @@ function Start-Fixture([string[]]$Arguments) {
$target = Start-Fixture @( $target = Start-Fixture @(
'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease)
$unrelated = Start-Fixture @( $unrelated = Start-Fixture @(
'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'), 'hold-campaign-la-process', '--config', $unrelatedConfig,
$unrelatedReady, $unrelatedRelease) $unrelatedReady, $unrelatedRelease)
if ($null -eq $target -or $null -eq $unrelated) { if ($null -eq $target -or $null -eq $unrelated) {
throw 'Could not start process-correlation fixtures.' throw 'Could not start process-correlation fixtures.'
@ -252,16 +293,19 @@ try {
-SessionConfigPath $sessionConfig -ReportPath $captureReport -SessionConfigPath $sessionConfig -ReportPath $captureReport
if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' } if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' }
$captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json
if ([int]$captured.processId -ne $target.Id) { if ([int]$captured.processId -ne $target.Id -or
throw 'Process capture did not return the exact correlated PID.' [string]$captured.sessionConfigPath -cne $sessionConfig -or
[string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') {
throw 'Process capture did not return exact sanitized instance evidence.'
} }
$liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json'
Invoke-Validator ` Invoke-Validator `
$positiveStatus $profile $liveReport $target.Id $false $sessionConfig $positiveStatus $profile $liveReport $captureReport $false
$liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json
if (-not ($liveResult.failures -match 'remains alive')) { if (-not ($liveResult.failures -match 'process instance.*remains alive') -or
throw 'A live exact child PID was not rejected by the terminal validator.' $liveResult.capturedProcessInstanceExited) {
throw 'A live exact child instance was not rejected by the terminal validator.'
} }
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
@ -269,7 +313,31 @@ try {
Invoke-Validator ` Invoke-Validator `
$positiveStatus $profile ` $positiveStatus $profile `
(Join-Path $OutputDirectory 'unrelated-same-name.validation.json') ` (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') `
$target.Id $true $sessionConfig $captureReport $true
$reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json'
$capturedIdentity = [string]$captured.processInstanceIdentity
$identitySeparator = $capturedIdentity.LastIndexOf(':')
$capturedStartValue = [uint64]::Parse(
$capturedIdentity.Substring($identitySeparator + 1),
[Globalization.CultureInfo]::InvariantCulture)
$reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) `
+ ($capturedStartValue + 1).ToString(
[Globalization.CultureInfo]::InvariantCulture)
Write-ProcessCapture `
$reusedCapture `
$unrelated.Id `
$reusedPriorIdentity `
$sessionConfig `
([string]$captured.commandLineFingerprintSha256)
$reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json'
Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true
$reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json
if (-not $reusedResult.capturedPidReused -or
-not $reusedResult.capturedProcessInstanceExited -or
-not $reusedResult.sessionConfigProcessExited) {
throw 'A reused PID was not distinguished from the exited captured instance.'
}
} }
finally { finally {
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
@ -287,7 +355,10 @@ $summary = [ordered]@{
disconnectedReasonNegatives = 3 disconnectedReasonNegatives = 3
credentialStringFieldNegatives = $secretCases.Count credentialStringFieldNegatives = $secretCases.Count
exactPidCapture = $true exactPidCapture = $true
livePidRejected = $true stableProcessInstanceCapture = $true
liveProcessInstanceRejected = $true
malformedProcessCaptureRejected = $true
injectedPidReuseIgnored = $true
unrelatedSameNameIgnored = $true unrelatedSameNameIgnored = $true
platform = if ($IsWindows) { 'windows' } else { 'linux' } platform = if ($IsWindows) { 'windows' } else { 'linux' }
} }

View file

@ -165,6 +165,84 @@ function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) {
$null = New-Item -ItemType Directory -Force -Path $directory $null = New-Item -ItemType Directory -Force -Path $directory
[IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false))
} }
function Get-ZipUInt16([byte[]]$Bytes, [int]$Offset) {
return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
}
function Get-ZipUInt32([byte[]]$Bytes, [int]$Offset) {
return [uint32]([uint32]$Bytes[$Offset] -bor
([uint32]$Bytes[$Offset + 1] -shl 8) -bor
([uint32]$Bytes[$Offset + 2] -shl 16) -bor
([uint32]$Bytes[$Offset + 3] -shl 24))
}
function Test-ZipExecutableName([string]$Name) {
return $Name -cin @(
'AcDream.App', 'acdream-headless', 'acdream-launcher', 'acdream-bake')
}
function Assert-ZipUnixMetadata([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
$eocd = $bytes.Length - 22
if ($eocd -lt 0 -or (Get-ZipUInt32 $bytes $eocd) -ne 0x06054b50 -or
(Get-ZipUInt16 $bytes ($eocd + 20)) -ne 0) {
throw "Fixture ZIP end record is invalid: $Path"
}
$entryCount = Get-ZipUInt16 $bytes ($eocd + 10)
$centralSize = Get-ZipUInt32 $bytes ($eocd + 12)
[uint64]$cursor = Get-ZipUInt32 $bytes ($eocd + 16)
$centralEnd = $cursor + $centralSize
if ($centralEnd -ne $eocd) { throw "Fixture ZIP central bounds are invalid: $Path" }
$rawModes = @{}
for ($index = 0; $index -lt $entryCount; $index++) {
if ($cursor + 46 -gt $centralEnd -or
(Get-ZipUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
throw "Fixture ZIP central entry is invalid: $Path"
}
if ($bytes[[int]$cursor + 5] -ne 3) {
throw "Fixture ZIP entry origin is not Unix: $Path"
}
$nameLength = Get-ZipUInt16 $bytes ([int]$cursor + 28)
$extraLength = Get-ZipUInt16 $bytes ([int]$cursor + 30)
$commentLength = Get-ZipUInt16 $bytes ([int]$cursor + 32)
$name = [Text.Encoding]::UTF8.GetString(
$bytes,
[int]$cursor + 46,
$nameLength)
$expectedMode = if (Test-ZipExecutableName $name) { 0x81ED } else { 0x81A4 }
$external = Get-ZipUInt32 $bytes ([int]$cursor + 38)
$expectedExternal = [uint32](([uint64]$expectedMode) -shl 16)
if ($external -ne $expectedExternal) {
throw "Fixture ZIP entry '$name' has wrong raw type/mode bits."
}
$rawModes[$name] = $expectedMode
$cursor += 46 + $nameLength + $extraLength + $commentLength
}
if ($cursor -ne $centralEnd) { throw "Fixture ZIP central length is invalid: $Path" }
Add-Type -AssemblyName System.IO.Compression
$stream = [IO.File]::OpenRead($Path)
try {
$archive = [IO.Compression.ZipArchive]::new(
$stream,
[IO.Compression.ZipArchiveMode]::Read,
$false,
[Text.Encoding]::UTF8)
try {
if ($archive.Entries.Count -ne $rawModes.Count) {
throw "Fixture ZIP entry count changed through ZipArchive: $Path"
}
foreach ($entry in $archive.Entries) {
$mode = ($entry.ExternalAttributes -shr 16) -band 0xffff
if (-not $rawModes.ContainsKey($entry.FullName) -or
$mode -ne $rawModes[$entry.FullName]) {
throw "ZipArchive reports wrong type/mode for '$($entry.FullName)'."
}
}
}
finally { $archive.Dispose() }
}
finally { $stream.Dispose() }
}
$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads' $payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads'
$payloads = [ordered]@{ $payloads = [ordered]@{
ClientWin = Join-Path $payloadRoot 'client-win' ClientWin = Join-Path $payloadRoot 'client-win'
@ -208,6 +286,9 @@ try {
[Globalization.CultureInfo]::CurrentUICulture = $culture [Globalization.CultureInfo]::CurrentUICulture = $culture
$destination = Join-Path $OutputDirectory "fixture-$cultureName" $destination = Join-Path $OutputDirectory "fixture-$cultureName"
& $fixture -OutputDirectory $destination @fixtureParameters & $fixture -OutputDirectory $destination @fixtureParameters
foreach ($zip in @(Get-ChildItem -LiteralPath $destination -Filter '*.zip' -File -Recurse)) {
Assert-ZipUnixMetadata $zip.FullName
}
$relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse | $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse |
Where-Object { $_.Name -ne 'fixture-report.json' } | Where-Object { $_.Name -ne 'fixture-report.json' } |
ForEach-Object { ForEach-Object {
@ -235,9 +316,49 @@ $digestBytes = [Security.Cryptography.SHA256]::HashData(
[Text.Encoding]::UTF8.GetBytes($firstInventory)) [Text.Encoding]::UTF8.GetBytes($firstInventory))
$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() $deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant()
$expectedCrossPlatformDigest = $expectedCrossPlatformDigest =
'9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167' 'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76'
if ($deterministicDigest -cne $expectedCrossPlatformDigest) { if ($deterministicDigest -cne $expectedCrossPlatformDigest) {
throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.' throw "Fixture artifact hashes differ from the pinned Windows/Linux contract: actual $deterministicDigest."
}
$nativeExtractionModesValidated = $false
if ($IsLinux) {
$unzip = @(Get-Command unzip -CommandType Application -ErrorAction Stop)[0].Source
$extractClient = Join-Path $OutputDirectory 'native-extract-client'
$extractLauncher = Join-Path $OutputDirectory 'native-extract-launcher'
$null = New-Item -ItemType Directory -Path $extractClient
$null = New-Item -ItemType Directory -Path $extractLauncher
& $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/client-linux-x64.zip') `
-d $extractClient
if ($LASTEXITCODE -ne 0) { throw 'Native client ZIP extraction failed.' }
& $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/launcher-linux-x64.zip') `
-d $extractLauncher
if ($LASTEXITCODE -ne 0) { throw 'Native launcher ZIP extraction failed.' }
$mode755 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor
[IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupRead -bor
[IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherRead -bor
[IO.UnixFileMode]::OtherExecute
$mode644 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor
[IO.UnixFileMode]::GroupRead -bor [IO.UnixFileMode]::OtherRead
foreach ($path in @(
(Join-Path $extractClient 'AcDream.App'),
(Join-Path $extractClient 'acdream-headless'),
(Join-Path $extractLauncher 'acdream-launcher'),
(Join-Path $extractLauncher 'acdream-bake'))) {
if ([IO.File]::GetUnixFileMode($path) -ne $mode755) {
throw "Native extraction did not retain mode 0755: $path"
}
}
foreach ($path in @(
(Join-Path $extractClient 'nested/I.txt'),
(Join-Path $extractClient 'campaign-la-fixture-release.txt'),
(Join-Path $extractLauncher 'nested/Z.txt'),
(Join-Path $extractLauncher 'campaign-la-fixture-release.txt'))) {
if ([IO.File]::GetUnixFileMode($path) -ne $mode644) {
throw "Native extraction did not retain mode 0644: $path"
}
}
$nativeExtractionModesValidated = $true
} }
$summary = [ordered]@{ $summary = [ordered]@{
@ -248,6 +369,9 @@ $summary = [ordered]@{
cultures = @('en-US', 'tr-TR', 'sv-SE') cultures = @('en-US', 'tr-TR', 'sv-SE')
fixtureArtifactSetSha256 = $deterministicDigest fixtureArtifactSetSha256 = $deterministicDigest
crossPlatformExpectedSha256 = $expectedCrossPlatformDigest crossPlatformExpectedSha256 = $expectedCrossPlatformDigest
zipOrigin = 'unix'
zipModesValidated = $true
nativeExtractionModesValidated = $nativeExtractionModesValidated
} }
$summary | ConvertTo-Json -Depth 5 | $summary | ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM

View file

@ -14,10 +14,8 @@ param(
[Parameter(Mandatory = $true)][string]$StatusFile, [Parameter(Mandatory = $true)][string]$StatusFile,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)][string]$ProcessCapturePath,
[ValidateRange(1, 2147483647)][int]$ExpectedProcessId,
[Parameter(Mandatory = $true)][string]$CredentialProfilePath, [Parameter(Mandatory = $true)][string]$CredentialProfilePath,
[string]$SessionConfigPath,
[string]$ExpectedSessionId, [string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(), [string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld, [switch]$ExpectNoEnteredWorld,
@ -42,6 +40,87 @@ if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
throw "Status file does not exist: $StatusFile" 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)) { if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) {
throw '-CredentialProfilePath must be absolute.' throw '-CredentialProfilePath must be absolute.'
} }
@ -82,12 +161,6 @@ elseif ($IsWindows) {
} }
} }
else { throw 'Campaign LA status validation supports Windows and Linux only.' } 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)) { if ([string]::IsNullOrWhiteSpace($ReportPath)) {
$ReportPath = "$StatusFile.validation.json" $ReportPath = "$StatusFile.validation.json"
} }
@ -402,27 +475,29 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) {
} }
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
$capturedState = $null
do { do {
$expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue $currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity `
if ($null -eq $expectedProcess) { break } -ProcessId $capturedProcessId
Start-Sleep -Milliseconds 100 $correlations = @(Get-CampaignLaSessionProcessCorrelations)
} while ([DateTime]::UtcNow -lt $deadline) $capturedState = Test-CampaignLaCapturedProcessState `
if ($null -ne $expectedProcess) { -ProcessId $capturedProcessId `
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive") -ProcessInstanceIdentity $capturedProcessIdentity `
-SessionConfigPath $capturedSessionConfigPath `
-CurrentProcessInstanceIdentity $currentProcessIdentity `
-Correlations $correlations
if (-not $capturedState.SameInstanceAlive -and
-not $capturedState.ExactConfigPathAlive) {
break
} }
$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 Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline) } while ([DateTime]::UtcNow -lt $deadline)
if ($correlated.Count -gt 0) { if ($capturedState.SameInstanceAlive) {
$failures.Add( $failures.Add(
"session-config-correlated launcher child PID(s) remain: $($correlated -join ',')") "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 $reportDirectory = Split-Path -Parent $ReportPath
@ -442,9 +517,12 @@ $report = [ordered]@{
eventNames = @($eventNames) eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen terminalObserved = $terminalSeen
expectedProcessId = $ExpectedProcessId capturedProcessId = $capturedProcessId
processExited = ($null -eq $expectedProcess) capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive)
sessionConfigCorrelationChecked = $pathCorrelationChecked capturedPidReused = [bool]$capturedState.PidReused
sessionConfigCorrelationChecked = $true
sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive)
processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant()
credentialPermissionsValidated = $true credentialPermissionsValidated = $true
forbiddenCredentialValueCount = $forbiddenValues.Count forbiddenCredentialValueCount = $forbiddenValues.Count
failures = @($failures) failures = @($failures)