Files
minerops/agents/windows/tests/Test-MinerOpsNativeHarness.ps1
T

583 lines
22 KiB
PowerShell

$ErrorActionPreference = "Stop"
$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1"
. $supportPath
function Assert-True {
param(
[Parameter(Mandatory = $true)][bool]$Condition,
[Parameter(Mandatory = $true)][string]$Message
)
if (-not $Condition) { throw $Message }
}
function Assert-Throws {
param(
[Parameter(Mandatory = $true)][scriptblock]$Action,
[Parameter(Mandatory = $true)][string]$Pattern
)
try {
& $Action | Out-Null
}
catch {
if ($_.Exception.Message -notmatch $Pattern) {
throw "Expected '$Pattern', got '$($_.Exception.Message)'."
}
return
}
throw "Expected action to fail with '$Pattern'."
}
$script:Passed = 0
function Invoke-HarnessTest {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
& $Action
$script:Passed++
Write-Host "PASS: $Name"
}
function New-PassingSwapPostconditions {
return [pscustomobject]@{
TargetExists = $true
TargetIdentityUnchanged = $true
TargetContentUnchanged = $true
TargetAclTrusted = $true
TargetAclUnchanged = $true
TargetOwnerTrusted = $true
TargetOwnerUnchanged = $true
TargetSingleLink = $true
SourceExists = $true
SourceIdentityUnchanged = $true
SourceContentUnchanged = $true
AttackerSourceNotPromoted = $true
TargetDeniedStandardUserRead = $true
TargetDeniedStandardUserModify = $true
TargetDeniedStandardUserDelete = $true
TargetDeniedStandardUserRename = $true
NoAttackerReadableBackup = $true
}
}
function New-ReadySwapFixture {
return [pscustomobject]@{
SourceExists = $true
TargetExists = $true
PathsDistinct = $true
SourceCreatedByStandardUser = $true
SourceOwnedByStandardUser = $true
SourceReadable = $true
SourceModifiable = $true
SourceRenameable = $true
SourceDeleteCapable = $true
SourceReplaceAccessVerified = $true
TargetAclTrusted = $true
TargetOwnerTrusted = $true
TargetIdentityCaptured = $true
SourceIdentityCaptured = $true
FileIdentitiesDistinct = $true
BackupArgumentValid = $true
BackupPathsDistinct = $true
BackupExpectedStateVerified = $true
BackupConfidentialitySafe = $true
SameVolume = $true
NtfsVolume = $true
}
}
function New-SwapResult {
param([Parameter(Mandatory = $true)][string]$Status)
return [pscustomobject]@{
operation = "swap"
status = $Status
exception_type = ""
hresult = 0
outer_exception_type = ""
outer_hresult = 0
leaf_exception_type = ""
leaf_hresult = 0
native_error = $null
exception_chain_types = @()
replace_invoked = $true
}
}
Invoke-HarnessTest "helper classifies success and status exit codes" {
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) `
"Success exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) `
"Security-denial exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) `
"Fixture-error exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) `
"Unexpected-failure exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode `
-Status "security_denied_requires_postconditions") -eq 8) `
"Postcondition-gated security exit code changed."
Assert-True ((Get-MinerOpsNativeStatusExitCode `
-Status "api_precondition_failure") -eq 9) `
"API-precondition exit code changed."
}
Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" {
$denied = [UnauthorizedAccessException]::new("denied")
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $denied) -ceq "security_denied") `
"UnauthorizedAccessException was not a security denial."
$unableToRemove = [IO.IOException]::new("expected", -2147023721)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $unableToRemove) -ceq
"security_denied_requires_postconditions") `
"ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated."
$missing = [IO.FileNotFoundException]::new("missing")
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $missing) -ceq "fixture_error") `
"Missing swap source was not a fixture error."
$invalidArgument = [ArgumentException]::new("illegal backup path")
Assert-True ($invalidArgument.HResult -eq -2147024809) `
"ArgumentException no longer carries 0x80070057."
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $invalidArgument) -ceq "fixture_error") `
"ERROR_INVALID_PARAMETER-like argument failure was not a fixture error."
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $crossVolume) -ceq
"api_precondition_failure") `
"Cross-volume swap was not an API-precondition failure."
$partialMove = [IO.IOException]::new("unexpected", -2147023720)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $partialMove) -ceq "unexpected_failure") `
"Partial ReplaceFile failure was accepted."
}
Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" {
$denied = [UnauthorizedAccessException]::new("denied")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $wrapped) -ceq "security_denied") `
"Wrapped UnauthorizedAccessException was not a security denial."
}
Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" {
$denied = [UnauthorizedAccessException]::new("denied")
$methodWrapper = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
$runtimeWrapper = [Management.Automation.RuntimeException]::new(
"runtime failed",
$methodWrapper
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $runtimeWrapper) -ceq "security_denied") `
"Nested PowerShell wrappers were not unwrapped."
}
Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" {
Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) `
"0x80070005 did not decode as ERROR_ACCESS_DENIED."
$accessDenied = [IO.IOException]::new("access denied", -2147024891)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $accessDenied) -ceq "security_denied") `
"Win32-derived ERROR_ACCESS_DENIED was not security evidence."
Assert-True ($null -eq (
Get-MinerOpsNativeWin32Error -HResult -2146233087
)) "CLR HRESULT 0x80131501 produced a bogus native error."
}
Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" {
$ioFailure = [IO.IOException]::new("unrelated")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$ioFailure
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $wrapped) -ceq "unexpected_failure") `
"Unrelated wrapped IOException was accepted as security evidence."
}
Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" {
$missing = [IO.FileNotFoundException]::new("missing")
$wrappedMissing = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$missing
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $wrappedMissing) -ceq "fixture_error") `
"Wrapped fixture error changed classification."
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
$wrappedCrossVolume = [Reflection.TargetInvocationException]::new(
"target invocation failed",
$crossVolume
)
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation swap -Exception $wrappedCrossVolume) -ceq
"api_precondition_failure") `
"Wrapped API precondition changed classification."
}
Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" {
$denied = [UnauthorizedAccessException]::new("denied")
$wrapped = [Management.Automation.MethodInvocationException]::new(
"method invocation failed",
$denied
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped
Assert-True ($details.OuterExceptionType -ceq
"System.Management.Automation.MethodInvocationException") `
"Exception diagnostics recorded the wrong outer type."
Assert-True ($details.LeafExceptionType -ceq
"System.UnauthorizedAccessException") `
"Exception diagnostics recorded the wrong leaf type."
Assert-True ($details.ExceptionChainTypes.Count -eq 2) `
"Exception diagnostics recorded the wrong chain length."
Assert-True (-not $details.ExceptionChainTruncated) `
"A short exception chain was reported as truncated."
}
Invoke-HarnessTest "exception diagnostics handle null InnerException" {
$wrapperOnly = [Management.Automation.RuntimeException]::new(
"wrapper without inner exception"
)
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly
Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) `
"Null InnerException did not leave the wrapper as the leaf."
Assert-True ($details.ExceptionChainTypes.Count -eq 1) `
"Null InnerException produced an invalid chain."
}
Invoke-HarnessTest "exception-chain traversal is bounded defensively" {
[Exception]$deep = [UnauthorizedAccessException]::new("denied")
foreach ($level in 1..40) {
$deep = [Exception]::new("wrapper $level", $deep)
}
$details = Get-MinerOpsNativeExceptionDetails -Exception $deep
Assert-True ($details.ExceptionChainTypes.Count -eq 32) `
"Deep exception traversal did not stop at its bound."
Assert-True ([bool]$details.ExceptionChainTruncated) `
"Deep exception traversal did not report truncation."
Assert-True ((Get-MinerOpsNativeExceptionStatus `
-Operation read -Exception $deep) -ceq "unexpected_failure") `
"Truncated exception evidence did not fail closed."
}
Invoke-HarnessTest "swap probe parses and passes an explicit native null" {
$probePath = Join-Path $PSScriptRoot `
"Invoke-MinerOpsStandardUserProbe.ps1"
$tokens = $null
$parseErrors = $null
[Management.Automation.Language.Parser]::ParseFile(
$probePath,
[ref]$tokens,
[ref]$parseErrors
) | Out-Null
Assert-True ($parseErrors.Count -eq 0) `
"Standard-user probe has parser errors."
$probeSource = [IO.File]::ReadAllText($probePath)
Assert-True ($probeSource -match '\[NullString\]::Value') `
"Swap probe does not pass an explicit native null backup argument."
Assert-True ($probeSource -notmatch
'File\]::Replace\([^\)]*\$null') `
"Swap probe regressed to a bare PowerShell null backup argument."
Assert-True ($probeSource -match
'backup-producing swap fixtures are prohibited') `
"Swap probe no longer prohibits confidentiality-unsafe backups."
}
Invoke-HarnessTest "NullString preserves native null for a .NET string" {
if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) {
Add-Type -TypeDefinition @'
public static class MinerOpsNativeHarnessNullStringProbe
{
public static bool IsNull(string value)
{
return value == null;
}
}
'@
}
Assert-True (-not (
[MinerOpsNativeHarnessNullStringProbe]::IsNull($null)
)) "A bare PowerShell null was not coerced to String.Empty."
Assert-True (
[MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value)
) "NullString did not preserve native null for a .NET string."
}
Invoke-HarnessTest "same source and destination is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.PathsDistinct = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Equal source and destination paths were accepted."
}
Invoke-HarnessTest "missing source is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.SourceExists = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing source was accepted."
}
Invoke-HarnessTest "missing target is a fixture failure" {
$fixture = New-ReadySwapFixture
$fixture.TargetExists = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing target was accepted."
}
Invoke-HarnessTest "invalid backup argument is a fixture failure" {
foreach ($property in @(
"BackupArgumentValid",
"BackupPathsDistinct",
"BackupExpectedStateVerified",
"BackupConfidentialitySafe"
)) {
$fixture = New-ReadySwapFixture
$fixture.$property = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Invalid backup property $property was accepted."
}
}
Invoke-HarnessTest "cross-volume limitation is not a security pass" {
$fixture = New-ReadySwapFixture
$fixture.SameVolume = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "api_precondition_failure") `
"Cross-volume fixture was not an API-precondition failure."
}
Invoke-HarnessTest "valid controlled same-volume source is attack-ready" {
$classification = Get-MinerOpsNativeSwapFixtureStatus `
-Fixture (New-ReadySwapFixture)
Assert-True ($classification.Status -ceq "ready") `
"Valid same-volume fixture was not attack-ready."
}
Invoke-HarnessTest "source ownership and control are mandatory" {
foreach ($property in @(
"SourceCreatedByStandardUser",
"SourceOwnedByStandardUser",
"SourceReadable",
"SourceModifiable",
"SourceRenameable",
"SourceDeleteCapable",
"SourceReplaceAccessVerified"
)) {
$fixture = New-ReadySwapFixture
$fixture.$property = $false
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
Assert-True ($classification.Status -ceq "fixture_error") `
"Missing source-control proof $property was accepted."
}
}
Invoke-HarnessTest "invalid swap fixture cannot pass as security" {
Assert-Throws -Pattern "fixture was invalid" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "fixture_error") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" {
Assert-Throws -Pattern "unrelated API precondition" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "api_precondition_failure") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "successful replacement is a security failure" {
Assert-Throws -Pattern "completed the protected replacement" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "succeeded") `
-Postconditions (New-PassingSwapPostconditions)
}
}
Invoke-HarnessTest "security denial passes only with all postconditions" {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions (New-PassingSwapPostconditions)
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult `
-Status "security_denied_requires_postconditions") `
-Postconditions (New-PassingSwapPostconditions)
$failed = New-PassingSwapPostconditions
$failed.TargetAclTrusted = $false
Assert-Throws -Pattern "target ACL is not trusted" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed target identity is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.TargetIdentityUnchanged = $false
Assert-Throws -Pattern "target identity changed" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed target content is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.TargetContentUnchanged = $false
Assert-Throws -Pattern "target content changed" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial with changed ACL or owner is a security failure" {
foreach ($property in @(
"TargetAclTrusted",
"TargetAclUnchanged",
"TargetOwnerTrusted",
"TargetOwnerUnchanged"
)) {
$failed = New-PassingSwapPostconditions
$failed.$property = $false
Assert-Throws -Pattern "target (ACL|owner)" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
}
Invoke-HarnessTest "attacker-readable protected backup is a security failure" {
$failed = New-PassingSwapPostconditions
$failed.NoAttackerReadableBackup = $false
Assert-Throws -Pattern "through a backup" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result (New-SwapResult -Status "security_denied") `
-Postconditions $failed
}
}
Invoke-HarnessTest "denial must record that File.Replace was reached" {
$result = New-SwapResult -Status "security_denied"
$result.replace_invoked = $false
Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action {
Assert-MinerOpsNativeSwapSecurityOutcome `
-Result $result -Postconditions (New-PassingSwapPostconditions)
}
}
$initialState = Get-MinerOpsNativeGetItemState
try {
Invoke-HarnessTest "trace leaves no global function when none existed" {
$childScript = Join-Path $PSScriptRoot `
"Test-MinerOpsNativeHarnessGetItemState.ps1"
$powerShell = (Get-Process -Id $PID).Path
& $powerShell -NoLogo -NoProfile -File $childScript `
-Mode NoExistingFunction -SupportPath $supportPath
Assert-True ($LASTEXITCODE -eq 0) `
"No-existing-function child test failed."
}
Invoke-HarnessTest "trace restores an existing global function exactly" {
$childScript = Join-Path $PSScriptRoot `
"Test-MinerOpsNativeHarnessGetItemState.ps1"
$powerShell = (Get-Process -Id $PID).Path
& $powerShell -NoLogo -NoProfile -File $childScript `
-Mode ExistingFunction -SupportPath $supportPath
Assert-True ($LASTEXITCODE -eq 0) `
"Existing-function child test failed."
}
Invoke-HarnessTest "trace cleanup survives Action failure" {
$before = Get-MinerOpsNativeGetItemState
Assert-Throws -Pattern "injected trace failure" -Action {
Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath | Out-Null
throw "injected trace failure"
}
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "throwing trace action"
}
Invoke-HarnessTest "trace cleanup survives a later assertion failure" {
$before = Get-MinerOpsNativeGetItemState
try {
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
throw "injected post-trace assertion failure"
}
catch {
Assert-True (
$_.Exception.Message -match "post-trace assertion failure"
) "Unexpected post-trace failure."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "post-trace assertion failure"
}
Invoke-HarnessTest "multiple sequential traces preserve command state" {
$before = Get-MinerOpsNativeGetItemState
foreach ($iteration in 1..3) {
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
Assert-True ($observed.Count -eq 1) `
"Sequential trace $iteration recorded the wrong count."
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "sequential trace $iteration"
}
}
Invoke-HarnessTest "simulated top-level failure restores Get-Item" {
$before = Get-MinerOpsNativeGetItemState
try {
try {
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
Get-Item -LiteralPath $supportPath
})
throw "injected top-level harness failure"
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "simulated harness final cleanup"
}
}
catch {
Assert-True (
$_.Exception.Message -match "top-level harness failure"
) "Unexpected simulated top-level failure."
}
Assert-MinerOpsNativeGetItemState -Expected $before `
-Context "after simulated top-level failure"
}
}
finally {
Assert-MinerOpsNativeGetItemState -Expected $initialState `
-Context "portable native-harness tests final cleanup"
}
Write-Host "$Passed native-harness regression tests passed."