fix(launcher): harden Campaign LA11 gate evidence

This commit is contained in:
Erik 2026-08-15 01:08:41 +02:00
parent 134edabed2
commit accd01a008
16 changed files with 1820 additions and 210 deletions

View file

@ -14,15 +14,15 @@ 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]$CredentialProfilePath,
[string]$SessionConfigPath,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
[switch]$AllowPluginFailure,
[switch]$AllowLoginCommandFailure,
[switch]$AllowLauncherChildren,
[string[]]$ForbiddenEnvironmentVariable = @(
'ACDREAM_TEST_PASS',
'ACDREAM_LA_GATE_SECRET'),
[string]$ReportPath,
[int]$ProcessExitWaitSeconds = 5
)
@ -35,12 +35,59 @@ if ($PSVersionTable.PSVersion.Major -lt 7) {
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
}
. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1')
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
}
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
throw "Status file does not exist: $StatusFile"
}
if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) {
throw '-CredentialProfilePath must be absolute.'
}
$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath)
if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) {
throw "Credential profile does not exist: $CredentialProfilePath"
}
$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force
if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Credential profile must not be a reparse point.'
}
if ($IsLinux) {
$ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite
if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) {
throw 'Credential profile must have exact owner-only mode 0600.'
}
}
elseif ($IsWindows) {
$broadSids = @(
'S-1-1-0', # Everyone
'S-1-5-11', # Authenticated Users
'S-1-5-32-545', # Builtin Users
'S-1-5-32-546') # Guests
$acl = Get-Acl -LiteralPath $CredentialProfilePath
if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' }
foreach ($rule in $acl.Access) {
if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) {
continue
}
try {
$sid = $rule.IdentityReference.Translate(
[Security.Principal.SecurityIdentifier]).Value
}
catch { $sid = [string]$rule.IdentityReference.Value }
if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) {
throw 'Credential profile grants access to a broad Windows identity.'
}
}
}
else { throw 'Campaign LA status validation supports Windows and Linux only.' }
if (-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"
}
@ -65,6 +112,55 @@ $loadedPlugins = [Collections.Generic.List[string]]::new()
$sessionId = $null
$previousTimestamp = [DateTimeOffset]::MinValue
$terminalSeen = $false
$forbiddenValues = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::Ordinal)
function Add-CredentialValues([Text.Json.JsonElement]$Element) {
if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) {
foreach ($property in $Element.EnumerateObject()) {
if ($property.Name -imatch '^(password|secret)$' -and
$property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) {
$value = $property.Value.GetString()
if (-not [string]::IsNullOrEmpty($value)) {
$null = $forbiddenValues.Add($value)
}
}
Add-CredentialValues $property.Value
}
}
elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) {
foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item }
}
}
$credentialDocument = [Text.Json.JsonDocument]::Parse(
[IO.File]::ReadAllText($CredentialProfilePath))
try { Add-CredentialValues $credentialDocument.RootElement }
finally { $credentialDocument.Dispose() }
if ($forbiddenValues.Count -eq 0) {
throw 'Credential profile contains no non-empty password/secret value.'
}
function Test-CredentialEcho([Text.Json.JsonElement]$Element) {
if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) {
[string]$text = $Element.GetString()
foreach ($secret in $forbiddenValues) {
if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true }
}
return $false
}
if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) {
foreach ($property in $Element.EnumerateObject()) {
if (Test-CredentialEcho $property.Value) { return $true }
}
}
elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) {
foreach ($item in $Element.EnumerateArray()) {
if (Test-CredentialEcho $item) { return $true }
}
}
return $false
}
function Get-Properties([Text.Json.JsonElement]$Element) {
$properties = [Collections.Generic.List[object]]::new()
@ -112,13 +208,6 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
$failures.Add("line $lineNumber is empty")
continue
}
foreach ($variable in $ForbiddenEnvironmentVariable) {
$secret = [Environment]::GetEnvironmentVariable($variable)
if (-not [string]::IsNullOrEmpty($secret) -and
$line.Contains($secret, [StringComparison]::Ordinal)) {
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
}
}
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
$failures.Add("line $lineNumber contains a credential-like JSON field")
}
@ -130,6 +219,9 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'root is not an object'
}
if (Test-CredentialEcho $root) {
throw 'an allowed string field contains an exact credential value'
}
$properties = @(Get-Properties $root)
$names = @($properties | ForEach-Object { $_.Name })
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
@ -214,7 +306,12 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
throw 'loginCommandFailed is not allowed for this row'
}
}
'disconnected' { $null = Assert-String $root 'reason' }
'disconnected' {
$reason = Assert-String $root 'reason'
if ($reason -cne 'stopped') {
throw "terminal disconnected reason is '$reason', expected 'stopped'"
}
}
'exited' {
$code = Assert-Int32 $root 'code'
$reason = Assert-String $root 'reason'
@ -304,16 +401,27 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) {
}
}
if (-not $AllowLauncherChildren) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
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")
}
$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath)
if ($pathCorrelationChecked) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do {
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
if ($children.Count -eq 0) { break }
$correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath)
if ($correlated.Count -eq 0) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($children.Count -gt 0) {
if ($correlated.Count -gt 0) {
$failures.Add(
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
"session-config-correlated launcher child PID(s) remain: $($correlated -join ',')")
}
}
@ -334,7 +442,11 @@ $report = [ordered]@{
eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen
launcherChildrenAllowed = [bool]$AllowLauncherChildren
expectedProcessId = $ExpectedProcessId
processExited = ($null -eq $expectedProcess)
sessionConfigCorrelationChecked = $pathCorrelationChecked
credentialPermissionsValidated = $true
forbiddenCredentialValueCount = $forbiddenValues.Count
failures = @($failures)
validatedUtc = [DateTime]::UtcNow.ToString('O')
}