fix(launcher): harden Campaign LA11 gate evidence
This commit is contained in:
parent
134edabed2
commit
accd01a008
16 changed files with 1820 additions and 210 deletions
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue