feat(windows): add recoverable existing-agent credential upgrade
This commit is contained in:
1 parent
3cfb0d1abe
commit
97a0139e3d
25 files changed
+11122
-108
No files matched your search
@@ -0,0 +1,247 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Operation,
|
||||
[Parameter(Mandatory = $true)][string]$InputPath,
|
||||
[string]$DestinationPath = "",
|
||||
[ValidateSet("None", "Path")][string]$BackupMode = "None",
|
||||
[string]$BackupPath = "",
|
||||
[Parameter(Mandatory = $true)][string]$ResultPath,
|
||||
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. $SupportPath
|
||||
|
||||
$sourceCreatedByCaller = $false
|
||||
$sourceReadVerified = $false
|
||||
$sourceModifyVerified = $false
|
||||
$sourceRenameVerified = $false
|
||||
$sourceDeleteAccessVerified = $false
|
||||
$sourceReplaceAccessVerified = $false
|
||||
$replaceInvoked = $false
|
||||
$sourceExistedBeforeAttack = $null
|
||||
$destinationExistedBeforeAttack = $null
|
||||
$backupExistedBeforeAttack = $null
|
||||
$inputFullPath = ""
|
||||
$destinationFullPath = ""
|
||||
$backupFullPath = ""
|
||||
$backupArgumentWasNativeNull = $false
|
||||
|
||||
function Get-ProbeMetadata {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Status,
|
||||
[Exception]$Exception
|
||||
)
|
||||
|
||||
$inputOwnedByCaller = $false
|
||||
if ([IO.File]::Exists($InputPath)) {
|
||||
try {
|
||||
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]
|
||||
)
|
||||
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
|
||||
}
|
||||
catch {
|
||||
$inputOwnedByCaller = $false
|
||||
}
|
||||
}
|
||||
|
||||
$details = if ($null -ne $Exception) {
|
||||
Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||
}
|
||||
else { $null }
|
||||
return [ordered]@{
|
||||
status = $Status
|
||||
operation = $Operation
|
||||
# Retain these two outer-exception fields for compatibility with
|
||||
# earlier native-harness result readers.
|
||||
exception_type = if ($null -ne $Exception) {
|
||||
$Exception.GetType().FullName
|
||||
}
|
||||
else {
|
||||
""
|
||||
}
|
||||
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
|
||||
outer_exception_type = if ($null -ne $details) {
|
||||
$details.OuterExceptionType
|
||||
}
|
||||
else { "" }
|
||||
outer_hresult = if ($null -ne $details) {
|
||||
$details.OuterHResult
|
||||
}
|
||||
else { 0 }
|
||||
leaf_exception_type = if ($null -ne $details) {
|
||||
$details.LeafExceptionType
|
||||
}
|
||||
else { "" }
|
||||
leaf_hresult = if ($null -ne $details) {
|
||||
$details.LeafHResult
|
||||
}
|
||||
else { 0 }
|
||||
native_error = if ($null -ne $details) {
|
||||
$details.NativeError
|
||||
}
|
||||
else { $null }
|
||||
exception_chain_types = if ($null -ne $details) {
|
||||
@($details.ExceptionChainTypes)
|
||||
}
|
||||
else { @() }
|
||||
exception_chain_truncated = if ($null -ne $details) {
|
||||
[bool]$details.ExceptionChainTruncated
|
||||
}
|
||||
else { $false }
|
||||
source_exists = [IO.File]::Exists($InputPath)
|
||||
destination_exists = if (
|
||||
[string]::IsNullOrWhiteSpace($DestinationPath)
|
||||
) {
|
||||
$null
|
||||
}
|
||||
else {
|
||||
[IO.File]::Exists($DestinationPath)
|
||||
}
|
||||
input_owned_by_caller = $inputOwnedByCaller
|
||||
source_created_by_caller = $sourceCreatedByCaller
|
||||
source_read_verified = $sourceReadVerified
|
||||
source_modify_verified = $sourceModifyVerified
|
||||
source_rename_verified = $sourceRenameVerified
|
||||
source_delete_access_verified = $sourceDeleteAccessVerified
|
||||
source_replace_access_verified = $sourceReplaceAccessVerified
|
||||
input_full_path = $inputFullPath
|
||||
destination_full_path = $destinationFullPath
|
||||
backup_mode = $BackupMode
|
||||
backup_full_path = $backupFullPath
|
||||
backup_argument_was_native_null = $backupArgumentWasNativeNull
|
||||
source_existed_before_attack = $sourceExistedBeforeAttack
|
||||
destination_existed_before_attack = $destinationExistedBeforeAttack
|
||||
backup_existed_before_attack = $backupExistedBeforeAttack
|
||||
replace_overload = "System.IO.File.Replace(String,String,String)"
|
||||
replace_invoked = $replaceInvoked
|
||||
}
|
||||
}
|
||||
|
||||
function Write-ProbeResult {
|
||||
param([Parameter(Mandatory = $true)]$Value)
|
||||
|
||||
$json = $Value | ConvertTo-Json -Compress
|
||||
[IO.File]::WriteAllText($ResultPath, $json)
|
||||
}
|
||||
|
||||
$exception = $null
|
||||
$status = "succeeded"
|
||||
try {
|
||||
switch ($Operation) {
|
||||
"read" { [void][IO.File]::ReadAllText($InputPath) }
|
||||
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||
"delete" { [IO.File]::Delete($InputPath) }
|
||||
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
|
||||
"prepare_swap" {
|
||||
if ([IO.File]::Exists($InputPath)) {
|
||||
throw [ArgumentException]::new(
|
||||
"swap source unexpectedly existed before caller creation"
|
||||
)
|
||||
}
|
||||
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
|
||||
[IO.File]::WriteAllText(
|
||||
$InputPath,
|
||||
"attacker-controlled-swap-source"
|
||||
)
|
||||
$sourceCreatedByCaller = $true
|
||||
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||
"attacker-controlled-swap-source"
|
||||
[IO.File]::AppendAllText($InputPath, "-modified")
|
||||
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||
"attacker-controlled-swap-source-modified"
|
||||
[IO.File]::Move($InputPath, $renameProof)
|
||||
[IO.File]::Move($renameProof, $InputPath)
|
||||
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
|
||||
-not [IO.File]::Exists($renameProof)
|
||||
|
||||
$requiredRights =
|
||||
[Security.AccessControl.FileSystemRights]::ReadData -bor
|
||||
[Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[Security.AccessControl.FileSystemRights]::Synchronize
|
||||
$rightsProbe = [IO.FileStream]::new(
|
||||
$InputPath,
|
||||
[IO.FileMode]::Open,
|
||||
$requiredRights,
|
||||
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
|
||||
4096,
|
||||
[IO.FileOptions]::None
|
||||
)
|
||||
try {
|
||||
$sourceDeleteAccessVerified = $true
|
||||
$sourceReplaceAccessVerified = $true
|
||||
}
|
||||
finally {
|
||||
$rightsProbe.Dispose()
|
||||
}
|
||||
}
|
||||
"swap" {
|
||||
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
|
||||
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
|
||||
if ($inputFullPath.Equals(
|
||||
$destinationFullPath,
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw [ArgumentException]::new(
|
||||
"swap source and destination are the same path"
|
||||
)
|
||||
}
|
||||
|
||||
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
|
||||
$destinationExistedBeforeAttack =
|
||||
[IO.File]::Exists($destinationFullPath)
|
||||
if (-not $sourceExistedBeforeAttack) {
|
||||
throw [IO.FileNotFoundException]::new(
|
||||
"swap source does not exist",
|
||||
$inputFullPath
|
||||
)
|
||||
}
|
||||
if (-not $destinationExistedBeforeAttack) {
|
||||
throw [IO.FileNotFoundException]::new(
|
||||
"swap destination does not exist",
|
||||
$destinationFullPath
|
||||
)
|
||||
}
|
||||
|
||||
if ($BackupMode -ceq "None") {
|
||||
if (-not [string]::IsNullOrEmpty($BackupPath)) {
|
||||
throw [ArgumentException]::new(
|
||||
"backup path was supplied while backup mode is None"
|
||||
)
|
||||
}
|
||||
# A bare PowerShell $null is coerced to String.Empty for this
|
||||
# .NET string parameter. NullString is the explicit native
|
||||
# null required to request no ReplaceFile backup.
|
||||
$backupArgumentWasNativeNull = $true
|
||||
}
|
||||
else {
|
||||
throw [ArgumentException]::new(
|
||||
"backup-producing swap fixtures are prohibited because " +
|
||||
"they can expose protected destination content"
|
||||
)
|
||||
}
|
||||
|
||||
$replaceInvoked = $true
|
||||
[IO.File]::Replace(
|
||||
$inputFullPath,
|
||||
$destinationFullPath,
|
||||
[NullString]::Value
|
||||
)
|
||||
}
|
||||
default { throw [ArgumentException]::new("unknown operation") }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$exception = $_.Exception
|
||||
$status = Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation $Operation -Exception $exception
|
||||
}
|
||||
|
||||
$result = Get-ProbeMetadata -Status $status -Exception $exception
|
||||
Write-ProbeResult -Value $result
|
||||
exit (Get-MinerOpsNativeStatusExitCode -Status $status)
|
||||
@@ -0,0 +1,475 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-MinerOpsNativeGetItemState {
|
||||
$effective = Get-Command Get-Item -ErrorAction Stop
|
||||
$function = if (
|
||||
$effective.CommandType -eq
|
||||
[Management.Automation.CommandTypes]::Function
|
||||
) {
|
||||
$effective
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
FunctionExisted = $null -ne $function
|
||||
FunctionScriptBlock = if ($null -ne $function) {
|
||||
$function.ScriptBlock
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
EffectiveCommandType = [string]$effective.CommandType
|
||||
EffectiveName = [string]$effective.Name
|
||||
EffectiveModuleName = [string]$effective.ModuleName
|
||||
EffectiveSource = [string]$effective.Source
|
||||
EffectiveDefinition = [string]$effective.Definition
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeGetItemState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Expected,
|
||||
[string]$Context = "Get-Item state check"
|
||||
)
|
||||
|
||||
$actual = Get-MinerOpsNativeGetItemState
|
||||
if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) {
|
||||
throw "$Context changed the global Get-Item function's existence."
|
||||
}
|
||||
if (
|
||||
[bool]$Expected.FunctionExisted -and
|
||||
(
|
||||
-not [object]::ReferenceEquals(
|
||||
$actual.FunctionScriptBlock,
|
||||
$Expected.FunctionScriptBlock
|
||||
) -or
|
||||
$actual.FunctionScriptBlock.ToString() -cne
|
||||
$Expected.FunctionScriptBlock.ToString()
|
||||
)
|
||||
) {
|
||||
throw "$Context changed the global Get-Item function's ScriptBlock."
|
||||
}
|
||||
foreach ($property in @(
|
||||
"EffectiveCommandType",
|
||||
"EffectiveName",
|
||||
"EffectiveModuleName",
|
||||
"EffectiveSource",
|
||||
"EffectiveDefinition"
|
||||
)) {
|
||||
if ([string]$actual.$property -cne [string]$Expected.$property) {
|
||||
throw "$Context changed effective Get-Item resolution ($property)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsNativeGetItemTrace {
|
||||
param([Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
$observedGetItemPaths = [System.Collections.Generic.List[string]]::new()
|
||||
$invokeWithLocalHook = {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][scriptblock]$TraceAction,
|
||||
[Parameter(Mandatory = $true)]$ObservedPaths
|
||||
)
|
||||
|
||||
# Use a child scope because PowerShell can continue resolving a
|
||||
# removed active function until the scope that defined it exits.
|
||||
# Exiting this child scope makes cleanup deterministic before the
|
||||
# caller-state assertion runs.
|
||||
Set-Item -LiteralPath "Function:\local:Get-Item" -Value {
|
||||
[CmdletBinding(DefaultParameterSetName = "Path")]
|
||||
param(
|
||||
[Parameter(Position = 0, ParameterSetName = "Path")]
|
||||
[string[]]$Path,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")]
|
||||
[Alias("PSPath")]
|
||||
[string[]]$LiteralPath,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") {
|
||||
$LiteralPath
|
||||
}
|
||||
else {
|
||||
$Path
|
||||
}
|
||||
foreach ($candidate in @($observed)) {
|
||||
$ObservedPaths.Add([string]$candidate)
|
||||
}
|
||||
Microsoft.PowerShell.Management\Get-Item @PSBoundParameters
|
||||
}
|
||||
try {
|
||||
& $TraceAction | Out-Null
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath "Function:\local:Get-Item" -Force `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
try {
|
||||
& $invokeWithLocalHook $Action $observedGetItemPaths
|
||||
return @($observedGetItemPaths)
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "Invoke-MinerOpsNativeGetItemTrace cleanup"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeWin32Error {
|
||||
param([Parameter(Mandatory = $true)][int]$HResult)
|
||||
|
||||
# HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat
|
||||
# the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error.
|
||||
$unsignedHResult = [int64]$HResult -band 4294967295L
|
||||
if (
|
||||
($unsignedHResult -band 4294901760L) -eq 2147942400L
|
||||
) {
|
||||
return [int]($unsignedHResult -band 65535L)
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeExceptionDetails {
|
||||
param([Parameter(Mandatory = $true)][Exception]$Exception)
|
||||
|
||||
$maximumDepth = 32
|
||||
$visited = [System.Collections.Generic.List[Exception]]::new()
|
||||
$chainTypes = [System.Collections.Generic.List[string]]::new()
|
||||
$current = $Exception
|
||||
$leaf = $Exception
|
||||
$truncated = $false
|
||||
|
||||
for ($depth = 0; $depth -lt $maximumDepth; $depth++) {
|
||||
if ($null -eq $current) { break }
|
||||
|
||||
$alreadyVisited = $false
|
||||
foreach ($candidate in $visited) {
|
||||
if ([object]::ReferenceEquals($candidate, $current)) {
|
||||
$alreadyVisited = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($alreadyVisited) {
|
||||
$truncated = $true
|
||||
break
|
||||
}
|
||||
|
||||
$visited.Add($current)
|
||||
$chainTypes.Add($current.GetType().FullName)
|
||||
$leaf = $current
|
||||
$current = $current.InnerException
|
||||
}
|
||||
if ($null -ne $current) {
|
||||
$truncated = $true
|
||||
}
|
||||
|
||||
$nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult)
|
||||
return [pscustomobject]@{
|
||||
OuterException = $Exception
|
||||
OuterExceptionType = $Exception.GetType().FullName
|
||||
OuterHResult = [int]$Exception.HResult
|
||||
LeafException = $leaf
|
||||
LeafExceptionType = $leaf.GetType().FullName
|
||||
LeafHResult = [int]$leaf.HResult
|
||||
NativeError = $nativeError
|
||||
ExceptionChainTypes = @($chainTypes)
|
||||
ExceptionChainTruncated = $truncated
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeExceptionStatus {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Operation,
|
||||
[Parameter(Mandatory = $true)][Exception]$Exception
|
||||
)
|
||||
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||
$meaningfulException = $details.LeafException
|
||||
$nativeError = $details.NativeError
|
||||
|
||||
if ($details.ExceptionChainTruncated) {
|
||||
return "unexpected_failure"
|
||||
}
|
||||
|
||||
if ($meaningfulException -is [UnauthorizedAccessException]) {
|
||||
return "security_denied"
|
||||
}
|
||||
|
||||
if (
|
||||
$meaningfulException -is [ArgumentException] -or
|
||||
$meaningfulException -is [IO.FileNotFoundException] -or
|
||||
$meaningfulException -is [IO.DirectoryNotFoundException] -or
|
||||
$meaningfulException -is [IO.DriveNotFoundException] -or
|
||||
$meaningfulException -is [IO.PathTooLongException] -or
|
||||
$meaningfulException -is [NotSupportedException]
|
||||
) {
|
||||
return "fixture_error"
|
||||
}
|
||||
|
||||
if (
|
||||
$meaningfulException -is [IO.IOException] -and
|
||||
$null -ne $nativeError
|
||||
) {
|
||||
if ([int]$nativeError -eq 5) {
|
||||
return "security_denied"
|
||||
}
|
||||
|
||||
if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) {
|
||||
# ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as
|
||||
# retaining both original names. The caller must still prove every
|
||||
# security and identity postcondition before treating this as a
|
||||
# passing denial.
|
||||
return "security_denied_requires_postconditions"
|
||||
}
|
||||
|
||||
if ([int]$nativeError -eq 17) {
|
||||
# ReplaceFile requires source and destination on the same volume.
|
||||
# This says nothing about the protected target's access boundary.
|
||||
return "api_precondition_failure"
|
||||
}
|
||||
}
|
||||
|
||||
return "unexpected_failure"
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeStatusExitCode {
|
||||
param([Parameter(Mandatory = $true)][string]$Status)
|
||||
|
||||
switch ($Status) {
|
||||
"succeeded" { return 0 }
|
||||
"security_denied" { return 5 }
|
||||
"fixture_error" { return 6 }
|
||||
"unexpected_failure" { return 7 }
|
||||
"security_denied_requires_postconditions" { return 8 }
|
||||
"api_precondition_failure" { return 9 }
|
||||
default { throw "Unknown standard-user helper status '$Status'." }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeSwapFixtureStatus {
|
||||
param([Parameter(Mandatory = $true)]$Fixture)
|
||||
|
||||
$fixtureFailures = [System.Collections.Generic.List[string]]::new()
|
||||
$requiredFixtureChecks = [ordered]@{
|
||||
SourceExists = "source is missing"
|
||||
TargetExists = "target is missing"
|
||||
PathsDistinct = "source and target are the same path"
|
||||
SourceCreatedByStandardUser = `
|
||||
"source was not created by the standard-user process"
|
||||
SourceOwnedByStandardUser = `
|
||||
"standard user does not own the source"
|
||||
SourceReadable = "standard user cannot read the source"
|
||||
SourceModifiable = "standard user cannot modify the source"
|
||||
SourceRenameable = "standard user cannot rename the source"
|
||||
SourceDeleteCapable = `
|
||||
"standard user does not have required delete access to the source"
|
||||
SourceReplaceAccessVerified = `
|
||||
"standard user lacks source access required by ReplaceFile"
|
||||
TargetAclTrusted = "target ACL was not trusted before attack"
|
||||
TargetOwnerTrusted = "target owner was not trusted before attack"
|
||||
TargetIdentityCaptured = "target identity was not captured"
|
||||
SourceIdentityCaptured = "source identity was not captured"
|
||||
FileIdentitiesDistinct = "source and target resolve to the same file"
|
||||
BackupArgumentValid = "backup argument is invalid"
|
||||
BackupPathsDistinct = `
|
||||
"backup path is not distinct from source and target"
|
||||
BackupExpectedStateVerified = `
|
||||
"backup pre-attack state was not verified"
|
||||
BackupConfidentialitySafe = `
|
||||
"backup choice could expose protected target content"
|
||||
}
|
||||
foreach ($entry in $requiredFixtureChecks.GetEnumerator()) {
|
||||
if (-not [bool]$Fixture.($entry.Key)) {
|
||||
$fixtureFailures.Add([string]$entry.Value)
|
||||
}
|
||||
}
|
||||
if ($fixtureFailures.Count -gt 0) {
|
||||
return [pscustomobject]@{
|
||||
Status = "fixture_error"
|
||||
Failures = @($fixtureFailures)
|
||||
}
|
||||
}
|
||||
|
||||
$apiFailures = [System.Collections.Generic.List[string]]::new()
|
||||
if (-not [bool]$Fixture.SameVolume) {
|
||||
$apiFailures.Add("source, target, and backup are not on one volume")
|
||||
}
|
||||
if (-not [bool]$Fixture.NtfsVolume) {
|
||||
$apiFailures.Add("attack fixture is not on NTFS")
|
||||
}
|
||||
if ($apiFailures.Count -gt 0) {
|
||||
return [pscustomobject]@{
|
||||
Status = "api_precondition_failure"
|
||||
Failures = @($apiFailures)
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Status = "ready"
|
||||
Failures = @()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeSwapFixtureReady {
|
||||
param([Parameter(Mandatory = $true)]$Fixture)
|
||||
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture
|
||||
switch ([string]$classification.Status) {
|
||||
"ready" { return }
|
||||
"fixture_error" {
|
||||
throw "Standard-user swap fixture is invalid: " +
|
||||
"$(@($classification.Failures) -join '; ')."
|
||||
}
|
||||
"api_precondition_failure" {
|
||||
throw "Standard-user swap has an API precondition failure: " +
|
||||
"$(@($classification.Failures) -join '; ')."
|
||||
}
|
||||
default {
|
||||
throw "Standard-user swap fixture classification failed closed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeSwapSecurityOutcome {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Result,
|
||||
[Parameter(Mandatory = $true)]$Postconditions
|
||||
)
|
||||
|
||||
if ([string]$Result.operation -cne "swap") {
|
||||
throw "Swap result recorded the wrong operation."
|
||||
}
|
||||
$detail = "outer=$([string]$Result.outer_exception_type), " +
|
||||
"outer_hresult=$([string]$Result.outer_hresult), " +
|
||||
"leaf=$([string]$Result.leaf_exception_type), " +
|
||||
"leaf_hresult=$([string]$Result.leaf_hresult), " +
|
||||
"native=$([string]$Result.native_error), " +
|
||||
"chain=$(@($Result.exception_chain_types) -join ' -> ')"
|
||||
switch ([string]$Result.status) {
|
||||
"succeeded" {
|
||||
if (-not [bool]$Result.replace_invoked) {
|
||||
throw "Successful swap result did not record File.Replace invocation."
|
||||
}
|
||||
throw "Standard user completed the protected replacement swap."
|
||||
}
|
||||
"security_denied" { }
|
||||
"security_denied_requires_postconditions" { }
|
||||
"fixture_error" {
|
||||
throw "Standard-user swap fixture was invalid ($detail)."
|
||||
}
|
||||
"api_precondition_failure" {
|
||||
throw "Standard-user swap hit an unrelated API precondition ($detail)."
|
||||
}
|
||||
default {
|
||||
throw "Standard-user swap failed unexpectedly ($detail)."
|
||||
}
|
||||
}
|
||||
if (-not [bool]$Result.replace_invoked) {
|
||||
throw "Standard-user swap did not reach System.IO.File.Replace."
|
||||
}
|
||||
|
||||
$required = [ordered]@{
|
||||
TargetExists = "protected target is missing"
|
||||
TargetIdentityUnchanged = "protected target identity changed"
|
||||
TargetContentUnchanged = "protected target content changed"
|
||||
TargetAclTrusted = "protected target ACL is not trusted"
|
||||
TargetAclUnchanged = "protected target ACL changed"
|
||||
TargetOwnerTrusted = "protected target owner is not trusted"
|
||||
TargetOwnerUnchanged = "protected target owner changed"
|
||||
TargetSingleLink = "protected target is not single-link"
|
||||
SourceExists = "attacker source is missing"
|
||||
SourceIdentityUnchanged = "attacker source identity changed"
|
||||
SourceContentUnchanged = "attacker source content changed"
|
||||
AttackerSourceNotPromoted = "attacker source was promoted to authority"
|
||||
TargetDeniedStandardUserRead = `
|
||||
"protected content became readable by the standard user"
|
||||
TargetDeniedStandardUserModify = `
|
||||
"protected target became modifiable by the standard user"
|
||||
TargetDeniedStandardUserDelete = `
|
||||
"protected target became deletable by the standard user"
|
||||
TargetDeniedStandardUserRename = `
|
||||
"protected target became renameable by the standard user"
|
||||
NoAttackerReadableBackup = `
|
||||
"replacement exposed protected content through a backup"
|
||||
}
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($entry in $required.GetEnumerator()) {
|
||||
if (-not [bool]$Postconditions.($entry.Key)) {
|
||||
$failures.Add([string]$entry.Value)
|
||||
}
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
throw "Swap security postconditions failed ($detail): " +
|
||||
"$($failures -join '; ')."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeFileIdentity {
|
||||
param([Parameter(Mandatory = $true)]$Stream)
|
||||
|
||||
if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public sealed class MinerOpsNativeHarnessFileIdentityResult
|
||||
{
|
||||
public string Identity { get; set; }
|
||||
public uint VolumeSerialNumber { get; set; }
|
||||
public ulong FileIndex { get; set; }
|
||||
public uint NumberOfLinks { get; set; }
|
||||
}
|
||||
|
||||
public static class MinerOpsNativeHarnessFileIdentity
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file, out ByHandleFileInformation information);
|
||||
|
||||
public static MinerOpsNativeHarnessFileIdentityResult Get(
|
||||
SafeFileHandle file)
|
||||
{
|
||||
ByHandleFileInformation information;
|
||||
if (!GetFileInformationByHandle(file, out information))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
ulong fileIndex = ((ulong)information.FileIndexHigh << 32) |
|
||||
information.FileIndexLow;
|
||||
return new MinerOpsNativeHarnessFileIdentityResult {
|
||||
Identity = String.Format(
|
||||
"{0:X8}:{1:X8}{2:X8}",
|
||||
information.VolumeSerialNumber,
|
||||
information.FileIndexHigh,
|
||||
information.FileIndexLow),
|
||||
VolumeSerialNumber = information.VolumeSerialNumber,
|
||||
FileIndex = fileIndex,
|
||||
NumberOfLinks = information.NumberOfLinks
|
||||
};
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
|
||||
. $scriptPath
|
||||
|
||||
$script:Passed = 0
|
||||
$script:BootEventRecords = @()
|
||||
$script:ToXmlCalls = 0
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory)][bool]$Condition,
|
||||
[Parameter(Mandatory)][string]$Message
|
||||
)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-Throws {
|
||||
param(
|
||||
[Parameter(Mandatory)][scriptblock]$Action,
|
||||
[string]$Pattern = ""
|
||||
)
|
||||
try { & $Action | Out-Null }
|
||||
catch {
|
||||
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
|
||||
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
|
||||
}
|
||||
return
|
||||
}
|
||||
throw "Expected action to fail."
|
||||
}
|
||||
|
||||
function Invoke-ParserTest {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][scriptblock]$Action
|
||||
)
|
||||
& $Action
|
||||
$script:Passed++
|
||||
Write-Host "PASS: $Name"
|
||||
}
|
||||
|
||||
function New-TestKernelBootXml {
|
||||
param(
|
||||
[string]$Namespace =
|
||||
"http://schemas.microsoft.com/win/2004/08/events/event",
|
||||
[string]$BootTypeContent = '<Data Name="BootType">0</Data>',
|
||||
[switch]$OmitSystem,
|
||||
[switch]$OmitEventData,
|
||||
[switch]$DuplicateEventData
|
||||
)
|
||||
|
||||
$system = if ($OmitSystem) { "" } else {
|
||||
@"
|
||||
<System>
|
||||
<Provider Name="Microsoft-Windows-Kernel-Boot" />
|
||||
<EventID>27</EventID>
|
||||
<Version>1</Version>
|
||||
<Level>4</Level>
|
||||
<Task>33</Task>
|
||||
<Opcode>0</Opcode>
|
||||
<EventRecordID>42</EventRecordID>
|
||||
<Channel>System</Channel>
|
||||
</System>
|
||||
"@
|
||||
}
|
||||
$eventData = if ($OmitEventData) { "" } else {
|
||||
" <EventData>$BootTypeContent</EventData>"
|
||||
}
|
||||
if ($DuplicateEventData) {
|
||||
$eventData += "`n <EventData>$BootTypeContent</EventData>"
|
||||
}
|
||||
return "<Event xmlns=`"$Namespace`">`n$system$eventData`n</Event>"
|
||||
}
|
||||
|
||||
function New-TestEventRecord {
|
||||
param(
|
||||
[AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot",
|
||||
[AllowNull()]$Id = [int]27,
|
||||
[AllowNull()]$RecordId = [long]42,
|
||||
[AllowNull()]$Version = [byte]1,
|
||||
[AllowNull()]$Xml = (New-TestKernelBootXml)
|
||||
)
|
||||
|
||||
$record = [pscustomobject]@{
|
||||
ProviderName = $ProviderName
|
||||
Id = $Id
|
||||
RecordId = $RecordId
|
||||
Version = $Version
|
||||
XmlPayload = $Xml
|
||||
}
|
||||
$record | Add-Member -MemberType ScriptMethod -Name ToXml -Value {
|
||||
$script:ToXmlCalls++
|
||||
return $this.XmlPayload
|
||||
}
|
||||
return $record
|
||||
}
|
||||
|
||||
function Get-WinEvent {
|
||||
param($FilterHashtable, $ErrorAction)
|
||||
return $script:BootEventRecords
|
||||
}
|
||||
|
||||
Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" {
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord)
|
||||
Assert-True ($parsed.ProviderName -ceq
|
||||
"Microsoft-Windows-Kernel-Boot") "Typed provider was not retained."
|
||||
Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) `
|
||||
"Typed event ID was not retained."
|
||||
Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) `
|
||||
"Typed record ID was not retained."
|
||||
Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) `
|
||||
"Typed event version was not retained."
|
||||
Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) `
|
||||
"BootType payload was not parsed."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "supported event version zero parses" {
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version ([byte]0))
|
||||
Assert-True ($parsed.Version -eq 0) "Version zero was not accepted."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" {
|
||||
$script:ToXmlCalls = 0
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -RecordId ([long]43)
|
||||
)
|
||||
$identifier = Get-MinerOpsBootIdentifier
|
||||
Assert-True ($identifier -ceq
|
||||
"windows-full-boot-event-v1:000000000000002b") `
|
||||
"Production event path returned the wrong identifier."
|
||||
Assert-True ($script:ToXmlCalls -eq 1) `
|
||||
"Production event path did not invoke ToXml exactly once."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" {
|
||||
$xml = (New-TestKernelBootXml).Replace(
|
||||
"<EventID>27</EventID>", "<EventID>999</EventID>"
|
||||
).Replace(
|
||||
"<EventRecordID>42</EventRecordID>",
|
||||
"<EventRecordID>999</EventRecordID>"
|
||||
)
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Xml $xml)
|
||||
Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) `
|
||||
"XML scalar metadata overrode typed EventRecord properties."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "null typed RecordId fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -RecordId $null)
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong typed provider fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -ProviderName "Unexpected-Provider")
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong typed event ID fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Id ([int]28))
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong RecordId type fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -RecordId ([UInt64]42))
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "null event version fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version $null)
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "unsupported event version fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version ([byte]2))
|
||||
} "unsupported"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "unexpected Event XML namespace fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-Namespace "urn:unexpected"))
|
||||
} "root"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "malformed Event XML fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Xml "<Event>")
|
||||
} "malformed"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing System element fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing EventData fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicated EventData fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml `
|
||||
(New-TestKernelBootXml -DuplicateEventData))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="LoadOptions"></Data>'))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicate BootType fails closed" {
|
||||
$payload = '<Data Name="BootType">0</Data>' +
|
||||
'<Data Name="BootType">0</Data>'
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent $payload))
|
||||
} "ambiguous"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "nonnumeric BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">full</Data>'))
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "noncanonical BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">00</Data>'))
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "structured BootType content fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent `
|
||||
'<Data Name="BootType"><Value>0</Value></Data>'))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "BootType outside UInt32 fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent `
|
||||
'<Data Name="BootType">4294967296</Data>'))
|
||||
} "invalid"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "nonzero BootType is not a full-boot anchor" {
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">1</Data>')
|
||||
)
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "empty event query fails closed" {
|
||||
$script:BootEventRecords = @()
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicate qualifying record IDs fail closed" {
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -RecordId ([long]42)
|
||||
New-TestEventRecord -RecordId ([long]43)
|
||||
New-TestEventRecord -RecordId ([long]42)
|
||||
)
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "equal stored and current anchors deny reset" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a"
|
||||
} "without a verified full Restart"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "strictly greater current anchor passes ordering" {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "smaller current anchor fails closed" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029"
|
||||
} "older than the"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "malformed stored anchor fails closed" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Write-Host "$script:Passed boot-event parser tests passed."
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -32,6 +32,9 @@ function Assert-SourceContains {
|
||||
Assert-SourceContains 'enrollment\.json' `
|
||||
"Bootstrap enrollment file is not explicit."
|
||||
|
||||
Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' `
|
||||
"New-Agent bootstrap does not reject transition packages."
|
||||
|
||||
Assert-SourceContains '/api/agent/enroll' `
|
||||
"Bootstrap does not use the secure enrollment endpoint."
|
||||
|
||||
@@ -44,8 +47,20 @@ Assert-SourceContains 'agent-config\.json\.pending' `
|
||||
Assert-SourceContains 'agent-config\.recovery\.json' `
|
||||
"Post-redemption credential recovery path is missing."
|
||||
|
||||
Assert-SourceContains 'Protect-MinerOpsCredentialFile' `
|
||||
"Credential file ACL protection is missing."
|
||||
Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
|
||||
"Handle-based credential creation is missing."
|
||||
|
||||
Assert-SourceContains 'FileMode\]::CreateNew' `
|
||||
"Credential files are not created with fail-if-present semantics."
|
||||
|
||||
Assert-SourceContains 'FileShare\]::None' `
|
||||
"Credential files are not held exclusively while being created."
|
||||
|
||||
Assert-SourceContains 'Flush\(\$true\)' `
|
||||
"Credential writes are not durably flushed through the trusted handle."
|
||||
|
||||
Assert-SourceContains 'GetNumberOfLinks' `
|
||||
"Enrollment does not reject hard-linked credential targets."
|
||||
|
||||
Assert-SourceContains 'S-1-5-18' `
|
||||
"SYSTEM ACL entry is missing."
|
||||
@@ -53,12 +68,73 @@ Assert-SourceContains 'S-1-5-18' `
|
||||
Assert-SourceContains 'S-1-5-32-544' `
|
||||
"Administrators ACL entry is missing."
|
||||
|
||||
Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' `
|
||||
"Installing user ACL entry is missing."
|
||||
Assert-SourceContains 'SetOwner\(\$administrators\)' `
|
||||
"Stable Administrators ownership is missing from credential ACLs."
|
||||
|
||||
Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
|
||||
Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' `
|
||||
"Atomic final config move is missing."
|
||||
|
||||
Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' `
|
||||
"Enrollment does not revalidate the directory trust boundary."
|
||||
|
||||
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||
"Enrollment directory repair lacks an external durable trust blocker."
|
||||
|
||||
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||
"Enrollment lacks an explicit post-reboot trust-reset gate."
|
||||
|
||||
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||
"Enrollment blockers do not use canonical full-kernel-boot records."
|
||||
|
||||
if ($source -match 'LastBootUpTime') {
|
||||
throw "Enrollment must not use a wall-clock boot timestamp."
|
||||
}
|
||||
|
||||
if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') {
|
||||
throw "Enrollment must not have independent trust-blocker deletion authority."
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' `
|
||||
"Enrollment does not fail closed on retained filesystem trust blockers."
|
||||
|
||||
Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' `
|
||||
"Enrollment does not validate ancestor namespace protection."
|
||||
|
||||
foreach ($functionName in @(
|
||||
"Assert-MinerOpsEnrollmentNoReparsePath",
|
||||
"Assert-MinerOpsEnrollmentTrustedAncestors"
|
||||
)) {
|
||||
$functionSource = [regex]::Match(
|
||||
$source,
|
||||
"(?s)function $functionName \{.*?\n\}"
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||
throw "$functionName source was not found."
|
||||
}
|
||||
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||
throw "$functionName does not preserve the filesystem root."
|
||||
}
|
||||
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued full path from trimming."
|
||||
}
|
||||
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||
}
|
||||
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||
throw "$functionName trims the filesystem root before identifying it."
|
||||
}
|
||||
if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and
|
||||
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued parent from trimming."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' `
|
||||
"Enrollment does not lock an insecure target during repair."
|
||||
|
||||
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||
"Enrollment does not securely stage a new directory."
|
||||
|
||||
Assert-SourceContains '& \$Installer' `
|
||||
"Bootstrap does not hand off to the safe installer."
|
||||
|
||||
@@ -68,6 +144,78 @@ Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
|
||||
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
|
||||
"Consumed extracted enrollment token is not removed."
|
||||
|
||||
if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') {
|
||||
throw "Enrollment must not truncate or adopt a credential pathname."
|
||||
}
|
||||
|
||||
$createFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}'
|
||||
).Value
|
||||
$createIndex = $createFunction.IndexOf(
|
||||
'New-MinerOpsEnrollmentProtectedFileCreateNewStream'
|
||||
)
|
||||
$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl')
|
||||
$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream')
|
||||
if (
|
||||
$createIndex -lt 0 -or $verifyIndex -le $createIndex -or
|
||||
$linkIndex -le $verifyIndex -or
|
||||
$createFunction -match 'Set-MinerOpsEnrollmentStreamAcl'
|
||||
) {
|
||||
throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content."
|
||||
}
|
||||
|
||||
$createStreamFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredCreateContract in @(
|
||||
'\[IO\.FileStream\]::new',
|
||||
'FileMode\]::CreateNew',
|
||||
'FileSystemRights\]::WriteData',
|
||||
'FileSystemRights\]::ReadPermissions',
|
||||
'FileSystemRights\]::Synchronize',
|
||||
'FileShare\]::None',
|
||||
'\$Acl'
|
||||
)) {
|
||||
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||
throw "Enrollment protected CreateNew stream contract is incomplete."
|
||||
}
|
||||
}
|
||||
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||
throw "Enrollment protected creation requests excess rights or repairs by path."
|
||||
}
|
||||
|
||||
$repairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredRepairRight in @(
|
||||
'ReadPermissions',
|
||||
'ChangePermissions',
|
||||
'TakeOwnership',
|
||||
'Synchronize'
|
||||
)) {
|
||||
if ($repairFunction -notmatch $requiredRepairRight) {
|
||||
throw "Enrollment existing-config repair is missing $requiredRepairRight."
|
||||
}
|
||||
}
|
||||
if ($repairFunction -match 'Set-Acl|FileAccess') {
|
||||
throw "Enrollment existing-config repair must remain handle-bound."
|
||||
}
|
||||
|
||||
$writeFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}'
|
||||
).Value
|
||||
if (
|
||||
$writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or
|
||||
$writeFunction -notmatch '\$Stream\.Write' -or
|
||||
$writeFunction -notmatch '\$Stream\.Flush\(\$true\)'
|
||||
) {
|
||||
throw "Enrollment credential content is not written and flushed through the verified handle."
|
||||
}
|
||||
|
||||
$existingConfigGuard = $source.IndexOf(
|
||||
'Test-Path -LiteralPath $ConfigPath'
|
||||
)
|
||||
@@ -85,7 +233,7 @@ if (
|
||||
}
|
||||
|
||||
$configMove = $source.IndexOf(
|
||||
'-Destination $ConfigPath'
|
||||
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
|
||||
)
|
||||
|
||||
$installerCall = $source.IndexOf(
|
||||
|
||||
@@ -33,7 +33,7 @@ if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||
throw "Source preflight must happen before target changes."
|
||||
}
|
||||
|
||||
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
|
||||
Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' `
|
||||
"Existing task preservation guard is missing."
|
||||
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||
"Existing task preservation is not explicit."
|
||||
@@ -49,6 +49,136 @@ Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||
"Rollback does not remove tasks created by a failed install."
|
||||
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||
"Rollback restart failure is not surfaced."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' `
|
||||
"Installer does not validate ancestor namespace protection."
|
||||
|
||||
foreach ($functionName in @(
|
||||
"Assert-MinerOpsInstallerNoReparsePath",
|
||||
"Assert-MinerOpsInstallerTrustedAncestors"
|
||||
)) {
|
||||
$functionSource = [regex]::Match(
|
||||
$source,
|
||||
"(?s)function $functionName \{.*?\n\}"
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||
throw "$functionName source was not found."
|
||||
}
|
||||
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||
throw "$functionName does not preserve the filesystem root."
|
||||
}
|
||||
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued full path from trimming."
|
||||
}
|
||||
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||
}
|
||||
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||
throw "$functionName trims the filesystem root before identifying it."
|
||||
}
|
||||
if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and
|
||||
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued parent from trimming."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' `
|
||||
"Installer does not lock an insecure target against replacement."
|
||||
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||
"Installer directory repair lacks an external durable provenance blocker."
|
||||
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||
"Installer lacks an explicit post-reboot trust-reset gate."
|
||||
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||
"Installer does not stage a protected new directory before publication."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' `
|
||||
"Installer does not read back the final directory security invariant."
|
||||
Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' `
|
||||
"Installer does not contain the existing active-config ACL."
|
||||
Assert-SourceContains 'GetNumberOfLinks' `
|
||||
"Installer does not reject hard-linked runtime, config, or backup files."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' `
|
||||
"Installer does not verify existing runtime rollback provenance."
|
||||
Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' `
|
||||
"Installer does not secure and verify published runtime and rollback copies."
|
||||
Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' `
|
||||
"Installer trust reset can adopt pre-reset runtime as rollback authority."
|
||||
Assert-SourceContains 'parent-owned filesystem trust blocker remains' `
|
||||
"Installer does not explicitly retain the parent-owned trust blocker."
|
||||
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||
"Installer blockers do not use canonical full-kernel-boot records."
|
||||
if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') {
|
||||
throw "Installer must have no filesystem trust-blocker deletion path."
|
||||
}
|
||||
if ($source -match 'LastBootUpTime') {
|
||||
throw "Installer must not create wall-clock boot-epoch records."
|
||||
}
|
||||
|
||||
$createStreamFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredCreateContract in @(
|
||||
'\[IO\.FileStream\]::new',
|
||||
'FileMode\]::CreateNew',
|
||||
'FileSystemRights\]::WriteData',
|
||||
'FileSystemRights\]::ReadPermissions',
|
||||
'FileSystemRights\]::Synchronize',
|
||||
'FileShare\]::None',
|
||||
'\$Acl'
|
||||
)) {
|
||||
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||
throw "Installer protected CreateNew stream contract is incomplete."
|
||||
}
|
||||
}
|
||||
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||
throw "Installer protected creation requests excess rights or repairs by path."
|
||||
}
|
||||
|
||||
$trustBlockerFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}'
|
||||
).Value
|
||||
$trustCreateIndex = $trustBlockerFunction.IndexOf(
|
||||
'New-MinerOpsInstallerProtectedFileCreateNewStream'
|
||||
)
|
||||
$trustVerifyIndex = $trustBlockerFunction.IndexOf(
|
||||
'Test-MinerOpsInstallerProtectedFileAcl'
|
||||
)
|
||||
$trustContentIndex = $trustBlockerFunction.IndexOf('$record =')
|
||||
if (
|
||||
$trustCreateIndex -lt 0 -or
|
||||
$trustVerifyIndex -le $trustCreateIndex -or
|
||||
$trustContentIndex -le $trustVerifyIndex
|
||||
) {
|
||||
throw "Installer trust blockers must be protected and verified before content."
|
||||
}
|
||||
|
||||
$configRepairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredRepairRight in @(
|
||||
'ReadPermissions',
|
||||
'ChangePermissions',
|
||||
'TakeOwnership',
|
||||
'Synchronize'
|
||||
)) {
|
||||
if ($configRepairFunction -notmatch $requiredRepairRight) {
|
||||
throw "Installer existing-config repair is missing $requiredRepairRight."
|
||||
}
|
||||
}
|
||||
if ($configRepairFunction -match 'Set-Acl|FileAccess') {
|
||||
throw "Installer existing-config repair must remain handle-bound."
|
||||
}
|
||||
|
||||
$repairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}'
|
||||
).Value
|
||||
$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath')
|
||||
$abortIndex = $repairFunction.IndexOf('this invocation will not back up')
|
||||
if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) {
|
||||
throw "Insecure existing-directory containment must abort before publication."
|
||||
}
|
||||
|
||||
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
$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."
|
||||
@@ -0,0 +1,48 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("NoExistingFunction", "ExistingFunction")]
|
||||
[string]$Mode,
|
||||
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. $SupportPath
|
||||
|
||||
if ($Mode -ceq "NoExistingFunction") {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
if ([bool]$before.FunctionExisted) {
|
||||
throw "Child process unexpectedly began with a global Get-Item function."
|
||||
}
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $SupportPath
|
||||
})
|
||||
if ($observed -cnotcontains $SupportPath) {
|
||||
throw "Trace did not observe Get-Item."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "no-preexisting-function child test"
|
||||
return
|
||||
}
|
||||
|
||||
$existing = { param($LiteralPath) "preexisting:$LiteralPath" }
|
||||
Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $SupportPath
|
||||
})
|
||||
if ($observed -cnotcontains $SupportPath) {
|
||||
throw "Trace did not observe Get-Item over an existing global function."
|
||||
}
|
||||
$after = Get-MinerOpsNativeGetItemState
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "preexisting-function child test"
|
||||
if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) {
|
||||
throw "Existing Get-Item ScriptBlock was not restored exactly."
|
||||
}
|
||||
if (-not [object]::ReferenceEquals(
|
||||
$after.FunctionScriptBlock,
|
||||
$before.FunctionScriptBlock
|
||||
)) {
|
||||
throw "Existing Get-Item ScriptBlock instance changed."
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
# Disposable Windows filesystem-trust validation
|
||||
|
||||
Native Windows evidence is required before deployment. The cross-platform
|
||||
behavioral harness proves deterministic state-machine decisions; it cannot
|
||||
prove NTFS access checks, directory-handle sharing, junction behavior,
|
||||
same-directory rename semantics, or Scheduled Task behavior.
|
||||
|
||||
## Isolation
|
||||
|
||||
Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join
|
||||
it to a production domain or connect it to a production MinerOps Hub, database,
|
||||
or miner network. Never use a production package, URL, Agent ID, transition
|
||||
token, shared secret, or API3 credential. Copy only the candidate
|
||||
`agents\windows` directory to the VM and take a snapshot first.
|
||||
|
||||
Run 64-bit Windows PowerShell 5.1 as Administrator:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 `
|
||||
-AcknowledgeDisposableVm
|
||||
```
|
||||
|
||||
Record the exact pass count printed by the candidate harness. Do not claim the
|
||||
native suite passed unless this command actually completed on Windows. Save the
|
||||
complete transcript plus:
|
||||
|
||||
```powershell
|
||||
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber
|
||||
$PSVersionTable
|
||||
```
|
||||
|
||||
The native primitive suite verifies:
|
||||
|
||||
- protected credential, enrollment, installer, and trust-blocker files are
|
||||
created with their final `FileSecurity`, held exclusively, and read back
|
||||
through the same still-empty handle before any content is written;
|
||||
- the directory and credential-file DACLs deny standard-user read, create,
|
||||
delete, rename, and replacement-swap operations;
|
||||
- ACL setup or read-back failure cannot leave secret-bearing content;
|
||||
- a pre-created pathname remains unchanged when `CreateNew` fails;
|
||||
- an insecure artifact is repaired through an exclusive handle with explicit
|
||||
security rights, then rejected; inability to acquire those rights fails
|
||||
closed without a path-based fallback;
|
||||
- the durable blocker exists before an ACL-repaired artifact could be
|
||||
reconsidered, and remains effective on the next check;
|
||||
- quarantine evidence is unreadable to the standard user;
|
||||
- the two-rename install leaves the new active file and a non-authoritative
|
||||
prior remnant, and an active-missing install does not invent prior authority;
|
||||
- a directory junction is rejected;
|
||||
- a permissive directory is hardened, gets an external durable blocker, and
|
||||
cannot continue in that process or an ordinary rerun;
|
||||
- pre-repair standard-user file and directory handles are exercised after ACL
|
||||
repair while automatic transition remains blocked regardless of whether the
|
||||
handle operation succeeds;
|
||||
- hard-linked enrollment, runtime, and backup targets are rejected; and
|
||||
- a real harmless expected Scheduled Task is positively verified for exact
|
||||
`Running` and `Ready`, while missing, query failure, disabled, malformed,
|
||||
wrong-action, and wrong-principal cases fail closed.
|
||||
|
||||
## Full-kernel-boot epoch matrix
|
||||
|
||||
The source uses the canonical value
|
||||
`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the
|
||||
Event Log `RecordId` anchor of the newest System-log event from
|
||||
`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload
|
||||
contains exactly one canonical `BootType` value equal to `0`. `ProviderName`,
|
||||
`Id`, nullable `RecordId`, and nullable `Version` must have the real typed
|
||||
EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an
|
||||
immutable boot GUID. A reset requires a valid current anchor strictly greater
|
||||
than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous
|
||||
anchors fail closed. The implementation deliberately does not use wall-clock
|
||||
time or the Windows `BootId` counter: Fast Startup is a hibernated kernel
|
||||
restoration and must not authorize reset merely because Windows reports a new
|
||||
startup sequence.
|
||||
|
||||
On a disposable VM, dot-source the transition script and record
|
||||
`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event
|
||||
object and `ToXml()` output from `Get-WinEvent`. For each event, record the
|
||||
runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the
|
||||
root namespace, and the `System/EventData/Data[@Name='BootType']` shape.
|
||||
|
||||
| Transition | Required canonical identity |
|
||||
| --- | --- |
|
||||
| Change system clock within the running session | unchanged |
|
||||
| Change timezone within the running session | unchanged |
|
||||
| Sleep and resume | unchanged |
|
||||
| Hibernate and resume, where supported | unchanged |
|
||||
| Shutdown and power-on with Fast Startup, where supported | unchanged |
|
||||
| Restart from the Windows power menu | strictly greater record anchor |
|
||||
|
||||
For the Fast Startup row, independently confirm that Windows restored kernel
|
||||
session 0 and that the latest event 27 has a nonzero `BootType`; the newest
|
||||
full-boot (`BootType=0`) event record must remain the same. For the Restart row,
|
||||
confirm a new exact `BootType=0` event and a new canonical identity. Also test a
|
||||
missing System log/provider event, duplicated `BootType`, malformed XML value,
|
||||
wrong namespace, unsupported/null version, wrong provider/event ID,
|
||||
zero/malformed record ID, and query access failure; every case must fail
|
||||
closed.
|
||||
|
||||
Use a separate disposable snapshot for log-retention behavior. Preserve the
|
||||
pre-clear stored anchor and evidence first, then clear only the disposable VM's
|
||||
System log. Verify that missing history cannot authorize reset. After a full
|
||||
Restart, if Event Log numbering restarts at or below the stored anchor, verify
|
||||
that automatic reset remains blocked and requires operator intervention. Also
|
||||
restore a snapshot exposing only an older valid full-boot event and confirm the
|
||||
lower record ID is rejected. Do not add a timestamp fallback for any row. A
|
||||
native result is required because EventLogRecord property types, provider
|
||||
namespace/version, log-clear numbering, and power-state behavior cannot be
|
||||
proven by the Linux harness.
|
||||
|
||||
## Loopback workflow fixture
|
||||
|
||||
Use a fresh snapshot for full workflow testing. All values must be disposable.
|
||||
|
||||
1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent,
|
||||
Site, and Zone UUIDs plus placeholder secrets.
|
||||
2. Place a harmless `MinerOpsAgentService.ps1` in the target and register
|
||||
`MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action:
|
||||
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
|
||||
"C:\MinerOpsAgent\MinerOpsAgentService.ps1"`.
|
||||
3. Serve a loopback-only mock at `http://127.0.0.1:<port>`. Its
|
||||
`POST /api/agent/credential-transition` handler records only the token hash
|
||||
and submitted `agent_secret_hash`, returns the fixed disposable
|
||||
Agent/Site/Zone identity, returns the same response for the same token and
|
||||
hash, and rejects a changed hash.
|
||||
4. Build a local package with that loopback origin and a random disposable
|
||||
43-character token. Never download from or call a real Hub.
|
||||
5. For each case, restore the snapshot or recreate the fixture. Run once with
|
||||
the task initially running and once with it intentionally stopped.
|
||||
|
||||
The child-process shape for hard termination is:
|
||||
|
||||
```powershell
|
||||
$checkpoint = 'pending-written'
|
||||
$code = @"
|
||||
. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1'
|
||||
Invoke-MinerOpsAgentCredentialTransition `
|
||||
-TargetRoot 'C:\MinerOpsAgent' `
|
||||
-PackageRoot 'C:\validation\agents\windows' `
|
||||
-ScheduledTaskName 'MinerOpsAgent' `
|
||||
-CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } }
|
||||
"@
|
||||
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code))
|
||||
Start-Process powershell.exe `
|
||||
-ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait
|
||||
```
|
||||
|
||||
After termination, rerun normally without deleting, repairing, or editing any
|
||||
artifact. Every retry request must use the same token and
|
||||
`agent_secret_hash`; no retry may generate a replacement secret.
|
||||
|
||||
## Hard-termination matrix
|
||||
|
||||
| Checkpoint | State before rerun | Required rerun result |
|
||||
| --- | --- | --- |
|
||||
| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked |
|
||||
| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process |
|
||||
| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations |
|
||||
| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early |
|
||||
| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained |
|
||||
| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it |
|
||||
| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified |
|
||||
| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents |
|
||||
| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused |
|
||||
| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent |
|
||||
| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes |
|
||||
| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten |
|
||||
| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges |
|
||||
| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active |
|
||||
| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only |
|
||||
| `after-replace` | same as prior row | active is verified before task restoration |
|
||||
| `task-restored` | active and final task state verified | retry verifies again and cleans up |
|
||||
| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker |
|
||||
| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup |
|
||||
|
||||
For each row inspect ACLs and task state without displaying secret contents:
|
||||
|
||||
```powershell
|
||||
Get-Acl C:\MinerOpsAgent | Format-List
|
||||
Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json `
|
||||
-ErrorAction SilentlyContinue | Format-List
|
||||
Get-Acl C:\MinerOpsAgent\agent-config.json.pending `
|
||||
-ErrorAction SilentlyContinue | Format-List
|
||||
Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State
|
||||
```
|
||||
|
||||
## Provenance and quarantine cases
|
||||
|
||||
Run these as separate restored-snapshot cases:
|
||||
|
||||
1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add
|
||||
a Users read/write ACE to recovery, replace its secret and
|
||||
`original_task_running`, and run twice. The first run must create a blocker,
|
||||
quarantine without parsing, and send no request. The second must remain
|
||||
blocked, send no request, keep the real original task state, and omit the
|
||||
attacker secret from all output. A third ordinary run must remain blocked.
|
||||
2. **ACL-repair laundering attempt.** Terminate at
|
||||
`quarantine-blocker-written`, then repair the still-authoritative recovery
|
||||
file ACL by hand without deleting the blocker. The next run must quarantine
|
||||
it without reading or further ACL mutation and must not redeem it. This
|
||||
proves ACL correctness cannot override the durable provenance decision.
|
||||
3. **Malicious pending, three invocations.** Stop after `pending-written`, add a
|
||||
Users ACE and alter the credential/Agent data. All runs must send no new
|
||||
request and must never promote pending.
|
||||
4. **Permissive directory with attacker state.** Add Users Modify to the target
|
||||
directory, place recovery and pending before repair, and run. Both artifacts
|
||||
must be quarantined, a blocker retained, and no request sent. A hard stop at
|
||||
`directory-trust-established` must have the same second-run result.
|
||||
5. **Quarantine preservation.** A later successful, separately authorized
|
||||
transition must not silently delete unresolved blocker or evidence files.
|
||||
Operator review must remove them only after invalidating the exposed token
|
||||
and obtaining a new package.
|
||||
6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a
|
||||
handle opened before repair to attempt creation/rename of recovery and
|
||||
pending names, including a file whose ACL now looks correct. The current,
|
||||
second, and third ordinary runs must send no request and perform no
|
||||
activation regardless of the handle operation's Windows result.
|
||||
7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot
|
||||
event identity changed, clear suspicious transition state only under the
|
||||
approved procedure, and run with
|
||||
`-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication
|
||||
occurs while the external blocker exists, old runtime is never a rollback
|
||||
source, the task remains stopped until transition verification, and the
|
||||
blocker remains through credential/config convergence and final strict task
|
||||
verification. It may disappear only after all of those parent checks pass.
|
||||
The same switch before Restart must fail. Repeat after clock/timezone change,
|
||||
sleep, hibernate, and Fast Startup; each must still fail as the same epoch.
|
||||
8. **Direct installer reset attempts.** With a real external blocker present,
|
||||
invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a
|
||||
successful publication and an injected publication failure. Repeat with the
|
||||
task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The
|
||||
blocker must remain in every case. Then run the complete parent workflow and
|
||||
prove deletion occurs only after its final convergence checks.
|
||||
|
||||
In every quarantine case, test standard-user reads against every
|
||||
`minerops-quarantine-credential-transition-*.evidence` file. Access must be
|
||||
denied, and neither console nor error transcripts may contain its contents.
|
||||
|
||||
## Hard-link cases
|
||||
|
||||
On NTFS, create a second name for each candidate with `New-Item -ItemType
|
||||
HardLink`. Test attacker-created enrollment pending, recovery, and final config
|
||||
paths; installed runtime destinations; and rollback sources/destinations. Every
|
||||
case must fail before secret write, redemption, publication, or restore. Verify
|
||||
that no attacker-readable alias contains a permanent credential and that a
|
||||
failed `CreateNew` does not delete or adopt the pre-existing file.
|
||||
|
||||
## Strict Scheduled Task matrix
|
||||
|
||||
Use the exact installer-controlled action and principal as the valid fixture.
|
||||
Run both durable intents through `Running`, `Ready`, missing, provider/query
|
||||
exception, access denied where practical, null/empty state, `Disabled`,
|
||||
`Unknown`, `Queued`, malformed object/definition, multiple results, wrong
|
||||
executable/script arguments, and wrong principal. Only running plus `Running`
|
||||
and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and
|
||||
then positively verify `Ready`.
|
||||
|
||||
Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`,
|
||||
`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and
|
||||
`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled
|
||||
is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy
|
||||
string substitutions must fail; provider enum normalization is allowed only for
|
||||
`State`, `LogonType`, and `RunLevel`.
|
||||
|
||||
After the server-commit checkpoint, inject final task verification failure.
|
||||
Recovery must remain and no success output or cleanup may occur. Repair the task
|
||||
and rerun; the recorded token and `agent_secret_hash` must be identical and the
|
||||
retry must converge without generating a new secret.
|
||||
|
||||
## Active-missing and replacement cases
|
||||
|
||||
Run all of these with a cryptographically matching protected recovery record:
|
||||
|
||||
- active missing, protected matching pending present, no prior (legacy
|
||||
`ReplaceFile` partial state): converges with the same token and secret;
|
||||
- active missing, matching pending and valid legacy prior present (new first
|
||||
rename completed): converges through pending; prior is never authority;
|
||||
- committed active and valid prior present, pending absent (new second rename
|
||||
completed): verifies active and cleans prior last;
|
||||
- failure before the first rename: active and pending remain, rerun converges;
|
||||
- failure between the two renames: active is absent, pending and prior remain,
|
||||
rerun converges;
|
||||
- failure immediately after pending becomes active: active and prior remain,
|
||||
rerun verifies active before cleanup;
|
||||
- active missing with no recovery or with unknown pending: fails closed;
|
||||
- active missing with malformed pending: fails before redemption;
|
||||
- active missing with mismatched Agent, secret, or secret hash: fails before
|
||||
redemption; and
|
||||
- active missing with only a stale prior: fails closed and never promotes it.
|
||||
|
||||
For every successful active-missing case, confirm exact Agent/Hub/Site/Zone
|
||||
identity, the same token and secret hash on every request, active-file DACL,
|
||||
and final Scheduled Task intent. Initially running must end running;
|
||||
intentionally stopped must remain stopped.
|
||||
|
||||
## Completion and cleanup
|
||||
|
||||
At successful termination, recovery, pending, prior, and consumed enrollment
|
||||
files are absent; unresolved quarantine/blocker evidence is never silently
|
||||
removed. Console/error transcripts contain neither token nor generated secret.
|
||||
|
||||
Record any disposable account, Scheduled Task, junction, directory, or mock
|
||||
service left by an interrupted validation, remove only those validation
|
||||
resources, then restore the snapshot or destroy the VM.
|
||||
Reference in new issue
Block a user