diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md index 0377c261..cd8f68b0 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -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, output/source overlap in either direction, and any reparse point in source or output ancestry. It enumerates normalized relative paths with ordinal ordering, -never its own output, and normalizes ZIP host metadata so Windows/Linux hashes -are identical under multiple cultures. It writes fixed-timestamp sorted ZIPs, +never its own output, and normalizes ZIP origin to Unix on both hosts so +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 server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B 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 clicking Refresh/Play. It correlates only the unique isolated session-config -path, records neither command line nor config contents, and must finish while -the child is still live: +path, records neither raw command line nor config contents, and must finish +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 $CapturePath = Join-Path $Evidence '-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') ` -StatusFile $Status ` -Mode '' ` - -ExpectedProcessId $Capture.processId ` - -SessionConfigPath $SessionConfig ` + -ProcessCapturePath $CapturePath ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectedSessionId $Capture.sessionId ` -ReportPath (Join-Path $Evidence '-status.validation.json') @@ -273,14 +276,16 @@ Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact v1 fields **and property order**, one session id, UTC monotonic timestamps, mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login command failure, exact terminal `disconnected.reason == stopped`, credential -redaction, and that exact captured PID is gone. Optional config-path correlation -uses Windows CIM or Linux `/proc/*/cmdline`; it never globally scans a process -name, so unrelated same-name processes and Linux's 15-character names do not -affect the result. The validator verifies owner-only profile access, reads only -password/secret fields in memory, recursively checks every allowed status +redaction, and that exact captured process instance is gone. Independent exact +config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never +globally scans a process name, treats a different start identity on a reused PID +as a different process, and is unaffected by unrelated same-name processes or +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 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 A–H @@ -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') ` -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` -Mode guiSelect ` - -ExpectedProcessId '' ` - -SessionConfigPath (Join-Path $WinCache 'launcher/sessions//session.json') ` + -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectNoEnteredWorld ` -ExpectedSessionId '' ` diff --git a/tools/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1 index 00718f13..25864a2d 100644 --- a/tools/CampaignLaProcessCorrelation.ps1 +++ b/tools/CampaignLaProcessCorrelation.ps1 @@ -1,31 +1,127 @@ 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 { [CmdletBinding()] param() - $matches = [Collections.Generic.List[object]]::new() + $correlations = [Collections.Generic.List[object]]::new() 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)) { $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( $commandLine, $pattern)) { - $value = if ($match.Groups[1].Success) { - $match.Groups[1].Value - } else { $match.Groups[2].Value } + $argument = $match.Groups[1].Value.ToLowerInvariant() + $value = if ($match.Groups[2].Success) { + $match.Groups[2].Value + } else { $match.Groups[3].Value } if ([IO.Path]::IsPathFullyQualified($value)) { - $matches.Add([pscustomobject]@{ + $configPath = [IO.Path]::GetFullPath($value) + $correlations.Add([pscustomobject]@{ ProcessId = [int]$candidate.ProcessId - SessionConfigPath = [IO.Path]::GetFullPath($value) + ProcessInstanceIdentity = $identity + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $executablePath $argument $configPath }) } } } } 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')) { $leaf = [IO.Path]::GetFileName($directory) $processId = 0 @@ -37,24 +133,34 @@ function Get-CampaignLaSessionProcessCorrelations { continue } try { + $identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline')) if ($bytes.Length -eq 0) { continue } $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split( [char]0, [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++) { if ($arguments[$index] -cin @('--config', '--session-config') -and [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) { - $matches.Add([pscustomobject]@{ + $configPath = [IO.Path]::GetFullPath($arguments[$index + 1]) + $correlations.Add([pscustomobject]@{ ProcessId = $processId - SessionConfigPath = [IO.Path]::GetFullPath( - $arguments[$index + 1]) + ProcessInstanceIdentity = $identityBefore + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $arguments[0] $arguments[$index] $configPath }) } } } 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] { # 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.' } - return @($matches) + return @($correlations) } function Get-CampaignLaCorrelatedProcessIds { @@ -79,15 +185,48 @@ function Get-CampaignLaCorrelatedProcessIds { $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } - $matches = [Collections.Generic.HashSet[int]]::new() + $processIds = [Collections.Generic.HashSet[int]]::new() foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { if ([string]::Equals( $candidate.SessionConfigPath, $SessionConfigPath, $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 + } } diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1 index 448660d4..4b0fd74a 100644 --- a/tools/capture-campaign-la-session-process.ps1 +++ b/tools/capture-campaign-la-session-process.ps1 @@ -90,18 +90,26 @@ if ($correlations.Count -ne 1) { throw 'No live process uses the isolated session config.' } $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 if (-not [string]::IsNullOrEmpty($directory)) { $null = New-Item -ItemType Directory -Force -Path $directory } $report = [ordered]@{ - schemaVersion = 1 + schemaVersion = 2 kind = 'campaign-la-session-process-capture' processId = [int]$correlations[0].ProcessId + processInstanceIdentity = $processIdentity sessionId = [IO.Path]::GetFileName( [IO.Path]::GetDirectoryName($SessionConfigPath)) - sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath) + sessionConfigPath = $SessionConfigPath + commandLineFingerprintSha256 = $commandFingerprint capturedUtc = [DateTime]::UtcNow.ToString('O') } $report | ConvertTo-Json -Depth 3 | diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 2e8723f8..d6609f47 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -208,10 +208,11 @@ function Set-DeterministicZipHostPlatform([string]$Path) { (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { throw "Generated ZIP central-directory entry is invalid: $Path" } - # ZipArchive intentionally stamps the creating host (FAT on Windows, - # Unix on Linux) in the upper byte of "version made by". Normalize it - # to FAT; permissions are already explicit in ExternalAttributes. - $bytes[[int]$cursor + 5] = 0 + # ZipArchive stamps the creating host (FAT on Windows, Unix on Linux) + # in the upper byte of "version made by". Normalize to Unix so native + # extraction honors the explicit regular-file type and 0755/0644 mode + # bits already stored in ExternalAttributes. + $bytes[[int]$cursor + 5] = 3 $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1 index 11756555..f1751988 100644 --- a/tools/test-campaign-la-gate-helpers.ps1 +++ b/tools/test-campaign-la-gate-helpers.ps1 @@ -28,6 +28,7 @@ if ([string]::IsNullOrWhiteSpace($pwsh)) { } $validator = Join-Path $Repository 'tools/test-campaign-la-session-status.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) { $document = [ordered]@{ @@ -86,16 +87,14 @@ function Invoke-Validator( [string]$Status, [string]$Profile, [string]$Report, - [int]$ExpectedProcessId, - [bool]$ShouldPass, - [string]$SessionConfig = '') { + [string]$ProcessCapture, + [bool]$ShouldPass) { $arguments = [Collections.Generic.List[string]]::new() foreach ($value in @( '-NoProfile', '-File', $validator, '-StatusFile', $Status, '-Mode', 'gui', - '-ExpectedProcessId', $ExpectedProcessId.ToString( - [Globalization.CultureInfo]::InvariantCulture), + '-ProcessCapturePath', $ProcessCapture, '-CredentialProfilePath', $Profile, '-ExpectedPlugin', 'smoke', '-AllowPluginFailure', @@ -103,10 +102,6 @@ function Invoke-Validator( '-ReportPath', $Report)) { $arguments.Add($value) } - if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) { - $arguments.Add('-SessionConfigPath') - $arguments.Add($SessionConfig) - } $start = [Diagnostics.ProcessStartInfo]::new($pwsh) $start.UseShellExecute = $false $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.UseShellExecute = $false $quickInfo.ArgumentList.Add('-NoProfile') @@ -138,13 +157,33 @@ $goneProcessId = $quick.Id $quick.WaitForExit() $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' Write-Profile $profile 'la11-positive-secret-7E477A2D' $positiveStatus = Join-Path $OutputDirectory 'positive.jsonl' Write-Events $positiveStatus (New-GuiEvents) Invoke-Validator ` $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')) { $events = @(New-GuiEvents) @@ -152,7 +191,7 @@ foreach ($reason in @('transport', 'reconnect', 'other')) { $path = Join-Path $OutputDirectory "reason-$reason.jsonl" $report = Join-Path $OutputDirectory "reason-$reason.validation.json" 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 if (-not ($result.failures -match 'disconnected reason')) { 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" $report = Join-Path $OutputDirectory "secret-$case.validation.json" 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 if (-not ($result.failures -match 'credential value')) { throw "Credential echo case '$case' was not rejected by recursive scanning." @@ -212,12 +251,14 @@ if ($IsLinux) { [IO.File]::GetUnixFileMode($sourceHost)) } -$sessionConfig = Join-Path $OutputDirectory 'session.json' -[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) $targetReady = Join-Path $OutputDirectory 'target.ready' $targetRelease = Join-Path $OutputDirectory 'target.release' $unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' $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) { $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) @@ -230,7 +271,7 @@ function Start-Fixture([string[]]$Arguments) { $target = Start-Fixture @( 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) $unrelated = Start-Fixture @( - 'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'), + 'hold-campaign-la-process', '--config', $unrelatedConfig, $unrelatedReady, $unrelatedRelease) if ($null -eq $target -or $null -eq $unrelated) { throw 'Could not start process-correlation fixtures.' @@ -252,16 +293,19 @@ try { -SessionConfigPath $sessionConfig -ReportPath $captureReport if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' } $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json - if ([int]$captured.processId -ne $target.Id) { - throw 'Process capture did not return the exact correlated PID.' + if ([int]$captured.processId -ne $target.Id -or + [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' Invoke-Validator ` - $positiveStatus $profile $liveReport $target.Id $false $sessionConfig + $positiveStatus $profile $liveReport $captureReport $false $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json - if (-not ($liveResult.failures -match 'remains alive')) { - throw 'A live exact child PID was not rejected by the terminal validator.' + if (-not ($liveResult.failures -match 'process instance.*remains alive') -or + $liveResult.capturedProcessInstanceExited) { + throw 'A live exact child instance was not rejected by the terminal validator.' } Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline @@ -269,7 +313,31 @@ try { Invoke-Validator ` $positiveStatus $profile ` (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 { Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline @@ -287,7 +355,10 @@ $summary = [ordered]@{ disconnectedReasonNegatives = 3 credentialStringFieldNegatives = $secretCases.Count exactPidCapture = $true - livePidRejected = $true + stableProcessInstanceCapture = $true + liveProcessInstanceRejected = $true + malformedProcessCaptureRejected = $true + injectedPidReuseIgnored = $true unrelatedSameNameIgnored = $true platform = if ($IsWindows) { 'windows' } else { 'linux' } } diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1 index 7124823c..d7f93c90 100644 --- a/tools/test-campaign-la-script-safety.ps1 +++ b/tools/test-campaign-la-script-safety.ps1 @@ -165,6 +165,84 @@ function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) { $null = New-Item -ItemType Directory -Force -Path $directory [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' $payloads = [ordered]@{ ClientWin = Join-Path $payloadRoot 'client-win' @@ -208,6 +286,9 @@ try { [Globalization.CultureInfo]::CurrentUICulture = $culture $destination = Join-Path $OutputDirectory "fixture-$cultureName" & $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 | Where-Object { $_.Name -ne 'fixture-report.json' } | ForEach-Object { @@ -235,9 +316,49 @@ $digestBytes = [Security.Cryptography.SHA256]::HashData( [Text.Encoding]::UTF8.GetBytes($firstInventory)) $deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() $expectedCrossPlatformDigest = - '9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167' + 'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76' 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]@{ @@ -248,6 +369,9 @@ $summary = [ordered]@{ cultures = @('en-US', 'tr-TR', 'sv-SE') fixtureArtifactSetSha256 = $deterministicDigest crossPlatformExpectedSha256 = $expectedCrossPlatformDigest + zipOrigin = 'unix' + zipModesValidated = $true + nativeExtractionModesValidated = $nativeExtractionModesValidated } $summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 1f93340a..ce89fcd2 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -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)