fix(launcher): harden LA11 gate evidence
This commit is contained in:
parent
accd01a008
commit
9f9c116792
7 changed files with 514 additions and 89 deletions
|
|
@ -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 '<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') `
|
||||
-StatusFile $Status `
|
||||
-Mode '<probe|guiSelect|gui|headless>' `
|
||||
-ExpectedProcessId $Capture.processId `
|
||||
-SessionConfigPath $SessionConfig `
|
||||
-ProcessCapturePath $CapturePath `
|
||||
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
|
||||
-ExpectedSessionId $Capture.sessionId `
|
||||
-ReportPath (Join-Path $Evidence '<ROW>-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/<SESSION_ID>/status.jsonl') `
|
||||
-Mode guiSelect `
|
||||
-ExpectedProcessId '<CAPTURED_CHILD_PID>' `
|
||||
-SessionConfigPath (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/session.json') `
|
||||
-ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') `
|
||||
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
|
||||
-ExpectNoEnteredWorld `
|
||||
-ExpectedSessionId '<SESSION_ID>' `
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if ($null -ne $expectedProcess) {
|
||||
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive")
|
||||
$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
|
||||
}
|
||||
|
||||
$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) {
|
||||
if ($capturedState.SameInstanceAlive) {
|
||||
$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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue