1279 lines
55 KiB
PowerShell
1279 lines
55 KiB
PowerShell
#Requires -RunAsAdministrator
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[switch]$AcknowledgeDisposableVm
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
if (-not $IsWindows -and $PSVersionTable.PSEdition -ne "Desktop") {
|
|
throw "This validation must run on native Windows."
|
|
}
|
|
if (-not $AcknowledgeDisposableVm) {
|
|
throw "Pass -AcknowledgeDisposableVm on an isolated disposable Windows VM."
|
|
}
|
|
|
|
$root = Split-Path $PSScriptRoot -Parent
|
|
$transitionScript = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
|
|
$enrollmentScript = Join-Path $root "Enroll-MinerOpsAgent.ps1"
|
|
$installerScript = Join-Path $root "install.ps1"
|
|
$harnessSupportScript = Join-Path $PSScriptRoot `
|
|
"MinerOpsNativeHarnessSupport.ps1"
|
|
$standardUserProbeScript = Join-Path $PSScriptRoot `
|
|
"Invoke-MinerOpsStandardUserProbe.ps1"
|
|
. $harnessSupportScript
|
|
$originalGetItemState = Get-MinerOpsNativeGetItemState
|
|
. $transitionScript
|
|
|
|
function Import-NativeFunctionDefinition {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$ScriptPath,
|
|
[Parameter(Mandatory = $true)][string]$Name
|
|
)
|
|
|
|
$tokens = $null
|
|
$parseErrors = $null
|
|
$ast = [Management.Automation.Language.Parser]::ParseFile(
|
|
$ScriptPath,
|
|
[ref]$tokens,
|
|
[ref]$parseErrors
|
|
)
|
|
if ($parseErrors.Count -gt 0) {
|
|
throw "$ScriptPath parser errors: $($parseErrors.Message -join '; ')"
|
|
}
|
|
$definitions = @($ast.FindAll({
|
|
param($node)
|
|
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
|
$node.Name -ceq $Name
|
|
}, $true))
|
|
if ($definitions.Count -ne 1) {
|
|
throw "Expected exactly one $Name definition in $ScriptPath."
|
|
}
|
|
Set-Item -Path ("Function:\script:{0}" -f $Name) `
|
|
-Value $definitions[0].Body.GetScriptBlock()
|
|
}
|
|
|
|
foreach ($functionImport in @(
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "Assert-MinerOpsEnrollmentNoReparsePath"
|
|
},
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "Assert-MinerOpsEnrollmentTrustedAncestors"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "Assert-MinerOpsInstallerNoReparsePath"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "Assert-MinerOpsInstallerTrustedAncestors"
|
|
},
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "Get-MinerOpsEnrollmentDirectorySids"
|
|
},
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "New-MinerOpsEnrollmentCredentialAcl"
|
|
},
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "New-MinerOpsEnrollmentProtectedFileCreateNewStream"
|
|
},
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "Get-MinerOpsEnrollmentStreamAcl"
|
|
},
|
|
@{
|
|
Path = $enrollmentScript
|
|
Name = "Test-MinerOpsEnrollmentCredentialAcl"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "Get-MinerOpsInstallerDirectorySids"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "New-MinerOpsInstallerProtectedFileAcl"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "New-MinerOpsInstallerProtectedFileCreateNewStream"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "Get-MinerOpsInstallerStreamAcl"
|
|
},
|
|
@{
|
|
Path = $installerScript
|
|
Name = "Test-MinerOpsInstallerProtectedFileAcl"
|
|
}
|
|
)) {
|
|
Import-NativeFunctionDefinition `
|
|
-ScriptPath ([string]$functionImport.Path) `
|
|
-Name ([string]$functionImport.Name)
|
|
}
|
|
|
|
$validationId = [guid]::NewGuid().ToString("N").Substring(0, 10)
|
|
$workRoot = Join-Path $env:ProgramData "MinerOpsCredentialValidation-$validationId"
|
|
$taskName = "MinerOpsCredentialValidation-$validationId"
|
|
$userName = "mopsval$($validationId.Substring(0, 8))"
|
|
$publicResult = Join-Path $env:PUBLIC "minerops-acl-result-$validationId.txt"
|
|
$readerPath = Join-Path $env:PUBLIC "minerops-acl-reader-$validationId.ps1"
|
|
$readerSupportPath = Join-Path $env:PUBLIC `
|
|
"minerops-native-harness-support-$validationId.ps1"
|
|
$testUser = $null
|
|
$taskCreated = $false
|
|
$passed = 0
|
|
$originalPowerShellLocation = (Get-Location).Path
|
|
$originalProcessCurrentDirectory = [Environment]::CurrentDirectory
|
|
|
|
function Assert-Native {
|
|
param(
|
|
[Parameter(Mandatory = $true)][bool]$Condition,
|
|
[Parameter(Mandatory = $true)][string]$Message
|
|
)
|
|
if (-not $Condition) { throw $Message }
|
|
}
|
|
|
|
function Invoke-NativeTest {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[Parameter(Mandatory = $true)][scriptblock]$Action
|
|
)
|
|
& $Action
|
|
$script:passed++
|
|
Write-Host "PASS: $Name"
|
|
}
|
|
|
|
function Invoke-NativeGetItemTrace {
|
|
param([Parameter(Mandatory = $true)][scriptblock]$Action)
|
|
|
|
return @(Invoke-MinerOpsNativeGetItemTrace -Action $Action)
|
|
}
|
|
|
|
function Assert-AllNativeRootValidators {
|
|
param([Parameter(Mandatory = $true)][string]$Path)
|
|
|
|
[void](Assert-MinerOpsNoReparseDirectoryPath -Path $Path)
|
|
[void](Assert-MinerOpsTrustedAncestorNamespace -Path $Path)
|
|
[void](Assert-MinerOpsEnrollmentNoReparsePath -Path $Path)
|
|
[void](Assert-MinerOpsEnrollmentTrustedAncestors -Path $Path)
|
|
[void](Assert-MinerOpsInstallerNoReparsePath -Path $Path)
|
|
[void](Assert-MinerOpsInstallerTrustedAncestors -Path $Path)
|
|
}
|
|
|
|
function Invoke-StandardUserOperation {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet(
|
|
"read", "create", "modify", "delete", "rename", "prepare_swap",
|
|
"swap"
|
|
)]
|
|
[string]$Operation,
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[string]$Destination = "",
|
|
[ValidateSet("None", "Path")][string]$BackupMode = "None",
|
|
[string]$BackupPath = ""
|
|
)
|
|
|
|
Remove-Item -LiteralPath $publicResult -Force -ErrorAction SilentlyContinue
|
|
$arguments = @(
|
|
"-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
|
"-File", ('"{0}"' -f $readerPath),
|
|
"-Operation", $Operation,
|
|
"-InputPath", ('"{0}"' -f $Path),
|
|
"-DestinationPath", ('"{0}"' -f $Destination),
|
|
"-BackupMode", $BackupMode,
|
|
"-BackupPath", ('"{0}"' -f $BackupPath),
|
|
"-ResultPath", ('"{0}"' -f $publicResult),
|
|
"-SupportPath", ('"{0}"' -f $readerSupportPath)
|
|
) -join " "
|
|
$process = Start-Process `
|
|
-FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
|
-Credential $testUser `
|
|
-ArgumentList $arguments `
|
|
-LoadUserProfile `
|
|
-Wait `
|
|
-PassThru
|
|
Assert-Native (Test-Path -LiteralPath $publicResult -PathType Leaf) `
|
|
"Standard-user $Operation helper did not write a result."
|
|
try {
|
|
$result = Get-Content -LiteralPath $publicResult -Raw | ConvertFrom-Json
|
|
}
|
|
catch {
|
|
throw "Standard-user $Operation helper wrote an invalid result."
|
|
}
|
|
Assert-Native ([string]$result.operation -ceq $Operation) `
|
|
"Standard-user helper recorded the wrong operation."
|
|
$expectedExit = Get-MinerOpsNativeStatusExitCode `
|
|
-Status ([string]$result.status)
|
|
Assert-Native ($process.ExitCode -eq $expectedExit) `
|
|
"Standard-user $Operation helper exit/result mismatch."
|
|
return $result
|
|
}
|
|
|
|
function Assert-StandardUserDenied {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet("read", "create", "modify", "delete", "rename")]
|
|
[string]$Operation,
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[string]$Destination = ""
|
|
)
|
|
|
|
$result = Invoke-StandardUserOperation `
|
|
-Operation $Operation -Path $Path -Destination $Destination
|
|
$detail = "outer=$($result.outer_exception_type), " +
|
|
"outer_hresult=$($result.outer_hresult), " +
|
|
"leaf=$($result.leaf_exception_type), " +
|
|
"leaf_hresult=$($result.leaf_hresult), " +
|
|
"native=$($result.native_error), " +
|
|
"chain=$(@($result.exception_chain_types) -join ' -> ')"
|
|
Assert-Native ([string]$result.status -ceq "security_denied") `
|
|
"Standard-user $Operation was not denied by security ($detail)."
|
|
}
|
|
|
|
function Get-NativeStreamSha256 {
|
|
param([Parameter(Mandatory = $true)]$Stream)
|
|
|
|
$originalPosition = $Stream.Position
|
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$Stream.Position = 0
|
|
return [BitConverter]::ToString(
|
|
$sha256.ComputeHash($Stream)
|
|
).Replace("-", "")
|
|
}
|
|
finally {
|
|
$Stream.Position = $originalPosition
|
|
$sha256.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-NativeUnprotectedFileEvidence {
|
|
param([Parameter(Mandatory = $true)][string]$Path)
|
|
|
|
$stream = [IO.File]::Open(
|
|
$Path,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete
|
|
)
|
|
try {
|
|
$identity = Get-MinerOpsNativeFileIdentity -Stream $stream
|
|
$acl = Get-MinerOpsStreamAcl -Stream $stream
|
|
return [pscustomobject]@{
|
|
Identity = [string]$identity.Identity
|
|
VolumeSerialNumber = [uint32]$identity.VolumeSerialNumber
|
|
FileIndex = [uint64]$identity.FileIndex
|
|
NumberOfLinks = [uint32]$identity.NumberOfLinks
|
|
OwnerSid = [string]$acl.GetOwner(
|
|
[Security.Principal.SecurityIdentifier]
|
|
).Value
|
|
AclSddl = [string]$acl.Sddl
|
|
ContentHash = Get-NativeStreamSha256 -Stream $stream
|
|
}
|
|
}
|
|
finally {
|
|
$stream.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-NativeProtectedFileEvidence {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent
|
|
)
|
|
|
|
$content = Read-MinerOpsProtectedCredentialText `
|
|
-Path $Path -ExpectedParent $ExpectedParent
|
|
$stream = Open-MinerOpsProtectedCredentialFile `
|
|
-Path $Path -ExpectedParent $ExpectedParent
|
|
try {
|
|
$identity = Get-MinerOpsNativeFileIdentity -Stream $stream
|
|
$acl = Get-MinerOpsStreamAcl -Stream $stream
|
|
$ownerSid = [string]$acl.GetOwner(
|
|
[Security.Principal.SecurityIdentifier]
|
|
).Value
|
|
return [pscustomobject]@{
|
|
Identity = [string]$identity.Identity
|
|
VolumeSerialNumber = [uint32]$identity.VolumeSerialNumber
|
|
FileIndex = [uint64]$identity.FileIndex
|
|
NumberOfLinks = [uint32]$identity.NumberOfLinks
|
|
AclTrusted = [bool](Test-MinerOpsCredentialAcl -Acl $acl)
|
|
AclSddl = [string]$acl.Sddl
|
|
OwnerSid = $ownerSid
|
|
OwnerTrusted = $ownerSid -in @("S-1-5-18", "S-1-5-32-544")
|
|
ContentHash = Get-NativeStreamSha256 -Stream $stream
|
|
}
|
|
}
|
|
finally {
|
|
$stream.Dispose()
|
|
}
|
|
}
|
|
|
|
try {
|
|
(New-Object IO.DirectoryInfo($workRoot)).Create(
|
|
(New-MinerOpsAgentDirectoryAcl)
|
|
)
|
|
[void](Assert-MinerOpsCredentialDirectory -Path $workRoot)
|
|
|
|
$driveRootTarget = Join-Path $workRoot "drive-root-target"
|
|
(New-Object IO.DirectoryInfo($driveRootTarget)).Create(
|
|
(New-MinerOpsAgentDirectoryAcl)
|
|
)
|
|
[void](Assert-MinerOpsCredentialDirectory -Path $driveRootTarget)
|
|
|
|
$driveRelativeTrap = Join-Path $workRoot "drive-relative-trap"
|
|
(New-Object IO.DirectoryInfo($driveRelativeTrap)).Create(
|
|
(New-MinerOpsAgentDirectoryAcl)
|
|
)
|
|
$trapAcl = Get-Acl -LiteralPath $driveRelativeTrap
|
|
$authenticatedUsers = New-Object `
|
|
System.Security.Principal.SecurityIdentifier("S-1-5-11")
|
|
$trapAcl.AddAccessRule((New-Object `
|
|
System.Security.AccessControl.FileSystemAccessRule(
|
|
$authenticatedUsers,
|
|
[System.Security.AccessControl.FileSystemRights]::Modify,
|
|
[System.Security.AccessControl.InheritanceFlags]::None,
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
)))
|
|
Set-Acl -LiteralPath $driveRelativeTrap -AclObject $trapAcl
|
|
|
|
Invoke-NativeTest "all security enumerators preserve C:\ as the first component" {
|
|
$expectedRoot = [IO.Path]::GetPathRoot(
|
|
[IO.Path]::GetFullPath($driveRootTarget)
|
|
)
|
|
Assert-Native ($expectedRoot -ceq "C:\") `
|
|
"Disposable validation path was not rooted at C:\."
|
|
$driveRelativeRoot = $expectedRoot.TrimEnd("\")
|
|
Assert-Native ($driveRelativeRoot -ceq "C:") `
|
|
"Drive-relative control value was not C:."
|
|
|
|
$validators = @(
|
|
@{ Name = "credential reparse"; Action = {
|
|
Assert-MinerOpsNoReparseDirectoryPath -Path $driveRootTarget
|
|
} },
|
|
@{ Name = "credential ancestors"; Action = {
|
|
Assert-MinerOpsTrustedAncestorNamespace -Path $driveRootTarget
|
|
} },
|
|
@{ Name = "enrollment reparse"; Action = {
|
|
Assert-MinerOpsEnrollmentNoReparsePath -Path $driveRootTarget
|
|
} },
|
|
@{ Name = "enrollment ancestors"; Action = {
|
|
Assert-MinerOpsEnrollmentTrustedAncestors -Path $driveRootTarget
|
|
} },
|
|
@{ Name = "installer reparse"; Action = {
|
|
Assert-MinerOpsInstallerNoReparsePath -Path $driveRootTarget
|
|
} },
|
|
@{ Name = "installer ancestors"; Action = {
|
|
Assert-MinerOpsInstallerTrustedAncestors -Path $driveRootTarget
|
|
} }
|
|
)
|
|
foreach ($validator in $validators) {
|
|
$action = [scriptblock]$validator.Action
|
|
$observed = @(Invoke-NativeGetItemTrace -Action $action)
|
|
Assert-Native ($observed.Count -gt 0) `
|
|
"$($validator.Name) did not enumerate filesystem components."
|
|
Assert-Native ($observed[0] -ceq $expectedRoot) `
|
|
"$($validator.Name) began at '$($observed[0])', not '$expectedRoot'."
|
|
Assert-Native ($observed -cnotcontains $driveRelativeRoot) `
|
|
"$($validator.Name) generated drive-relative root '$driveRelativeRoot'."
|
|
foreach ($component in $observed) {
|
|
Assert-Native (
|
|
[IO.Path]::IsPathRooted($component) -and
|
|
[IO.Path]::GetFullPath($component).StartsWith(
|
|
$expectedRoot,
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
)
|
|
) "$($validator.Name) generated non-root-qualified component '$component'."
|
|
}
|
|
}
|
|
|
|
$directRootChild = Join-Path $expectedRoot (
|
|
"MinerOpsDriveRootValidation-$validationId"
|
|
)
|
|
$rootEdgeValidators = @(
|
|
@{ Name = "credential reparse root"; Action = {
|
|
Assert-MinerOpsNoReparseDirectoryPath -Path $expectedRoot
|
|
} },
|
|
@{ Name = "credential direct-root ancestor"; Action = {
|
|
Assert-MinerOpsTrustedAncestorNamespace -Path $directRootChild
|
|
} },
|
|
@{ Name = "enrollment reparse root"; Action = {
|
|
Assert-MinerOpsEnrollmentNoReparsePath -Path $expectedRoot
|
|
} },
|
|
@{ Name = "enrollment direct-root ancestor"; Action = {
|
|
Assert-MinerOpsEnrollmentTrustedAncestors -Path $directRootChild
|
|
} },
|
|
@{ Name = "installer reparse root"; Action = {
|
|
Assert-MinerOpsInstallerNoReparsePath -Path $expectedRoot
|
|
} },
|
|
@{ Name = "installer direct-root ancestor"; Action = {
|
|
Assert-MinerOpsInstallerTrustedAncestors -Path $directRootChild
|
|
} }
|
|
)
|
|
foreach ($validator in $rootEdgeValidators) {
|
|
$action = [scriptblock]$validator.Action
|
|
$observed = @(Invoke-NativeGetItemTrace -Action $action)
|
|
Assert-Native ($observed.Count -eq 1) `
|
|
"$($validator.Name) did not enumerate exactly one root component."
|
|
Assert-Native ($observed[0] -ceq $expectedRoot) `
|
|
"$($validator.Name) enumerated '$($observed[0])', not '$expectedRoot'."
|
|
Assert-Native ($observed -cnotcontains $driveRelativeRoot) `
|
|
"$($validator.Name) generated drive-relative root '$driveRelativeRoot'."
|
|
}
|
|
Assert-MinerOpsNativeGetItemState -Expected $originalGetItemState `
|
|
-Context "root-qualified validator traces"
|
|
}
|
|
|
|
Invoke-NativeTest "absolute validation ignores a hostile PowerShell location" {
|
|
$trapRules = @((Get-Acl -LiteralPath $driveRelativeTrap).GetAccessRules(
|
|
$true,
|
|
$true,
|
|
[System.Security.Principal.SecurityIdentifier]
|
|
))
|
|
$trapRule = @($trapRules | Where-Object {
|
|
$_.IdentityReference.Value -eq "S-1-5-11" -and
|
|
($_.FileSystemRights -band
|
|
[System.Security.AccessControl.FileSystemRights]::Delete) -ne 0
|
|
})
|
|
Assert-Native ($trapRule.Count -gt 0) `
|
|
"Drive-relative trap does not grant Authenticated Users Delete rights."
|
|
$before = (Get-Location).Path
|
|
try {
|
|
Set-Location -LiteralPath $driveRelativeTrap
|
|
Assert-AllNativeRootValidators -Path $driveRootTarget
|
|
}
|
|
finally {
|
|
Set-Location -LiteralPath $before
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "absolute validation ignores process current directory" {
|
|
$beforeLocation = (Get-Location).Path
|
|
$beforeProcessDirectory = [Environment]::CurrentDirectory
|
|
try {
|
|
Set-Location -LiteralPath "C:\"
|
|
[Environment]::CurrentDirectory = $driveRelativeTrap
|
|
Assert-AllNativeRootValidators -Path $driveRootTarget
|
|
}
|
|
finally {
|
|
[Environment]::CurrentDirectory = $beforeProcessDirectory
|
|
Set-Location -LiteralPath $beforeLocation
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "untrusted ancestor namespace still fails closed" {
|
|
$untrustedAncestor = Join-Path $workRoot "untrusted-ancestor"
|
|
(New-Object IO.DirectoryInfo($untrustedAncestor)).Create(
|
|
(New-MinerOpsAgentDirectoryAcl)
|
|
)
|
|
$ancestorAcl = Get-Acl -LiteralPath $untrustedAncestor
|
|
$ancestorAcl.AddAccessRule((New-Object `
|
|
System.Security.AccessControl.FileSystemAccessRule(
|
|
$authenticatedUsers,
|
|
[System.Security.AccessControl.FileSystemRights]::Modify,
|
|
[System.Security.AccessControl.InheritanceFlags]::None,
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
)))
|
|
Set-Acl -LiteralPath $untrustedAncestor -AclObject $ancestorAcl
|
|
$untrustedChild = Join-Path $untrustedAncestor "child"
|
|
(New-Object IO.DirectoryInfo($untrustedChild)).Create(
|
|
(New-MinerOpsAgentDirectoryAcl)
|
|
)
|
|
|
|
foreach ($validator in @(
|
|
{ Assert-MinerOpsTrustedAncestorNamespace -Path $untrustedChild },
|
|
{ Assert-MinerOpsEnrollmentTrustedAncestors -Path $untrustedChild },
|
|
{ Assert-MinerOpsInstallerTrustedAncestors -Path $untrustedChild }
|
|
)) {
|
|
try {
|
|
& $validator
|
|
throw "Untrusted ancestor was accepted."
|
|
}
|
|
catch {
|
|
Assert-Native (
|
|
$_.Exception.Message -match "untrusted namespace replacement"
|
|
) "Untrusted ancestor produced an unexpected error."
|
|
}
|
|
}
|
|
}
|
|
|
|
$passwordPlain = "M0ps!" + [guid]::NewGuid().ToString("N") + "z9"
|
|
$password = ConvertTo-SecureString $passwordPlain -AsPlainText -Force
|
|
$passwordPlain = $null
|
|
New-LocalUser `
|
|
-Name $userName `
|
|
-Password $password `
|
|
-PasswordNeverExpires `
|
|
-UserMayNotChangePassword | Out-Null
|
|
$usersGroup = Get-LocalGroup -SID "S-1-5-32-545"
|
|
Add-LocalGroupMember -Group $usersGroup.Name -Member $userName
|
|
$testUser = New-Object Management.Automation.PSCredential(
|
|
"$env:COMPUTERNAME\$userName",
|
|
$password
|
|
)
|
|
|
|
Copy-Item -LiteralPath $standardUserProbeScript `
|
|
-Destination $readerPath -Force
|
|
Copy-Item -LiteralPath $harnessSupportScript `
|
|
-Destination $readerSupportPath -Force
|
|
|
|
Invoke-NativeTest "protected CreateNew applies and reads back ACL before content" {
|
|
$path = Join-Path $workRoot "protected-before-content.json"
|
|
$script:nativeAclReadbackSawEmptyFile = $false
|
|
$script:nativeOriginalGetStreamAcl =
|
|
(Get-Command Get-MinerOpsStreamAcl).ScriptBlock
|
|
try {
|
|
Set-Item Function:\Get-MinerOpsStreamAcl {
|
|
param($Stream)
|
|
Assert-Native ($Stream.Length -eq 0) `
|
|
"ACL read-back occurred after content was written."
|
|
$script:nativeAclReadbackSawEmptyFile = $true
|
|
& $script:nativeOriginalGetStreamAcl -Stream $Stream
|
|
}
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $path `
|
|
-ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "protected-after-verification" }
|
|
}
|
|
finally {
|
|
Set-Item Function:\Get-MinerOpsStreamAcl `
|
|
$script:nativeOriginalGetStreamAcl
|
|
}
|
|
Assert-Native $script:nativeAclReadbackSawEmptyFile `
|
|
"Protected creation did not read the ACL from the empty open file."
|
|
$content = Read-MinerOpsProtectedCredentialText `
|
|
-Path $path -ExpectedParent $workRoot
|
|
Assert-Native ($content -match "protected-after-verification") `
|
|
"Protected credential content was not durably written."
|
|
}
|
|
|
|
Invoke-NativeTest "enrollment and installer protected CreateNew mirrors work natively" {
|
|
$implementations = @(
|
|
@{
|
|
Name = "enrollment"
|
|
NewAcl = { New-MinerOpsEnrollmentCredentialAcl }
|
|
Create = {
|
|
param($Path, $Acl)
|
|
New-MinerOpsEnrollmentProtectedFileCreateNewStream `
|
|
-Path $Path -Acl $Acl
|
|
}
|
|
GetAcl = {
|
|
param($Stream)
|
|
Get-MinerOpsEnrollmentStreamAcl -Stream $Stream
|
|
}
|
|
TestAcl = {
|
|
param($Acl)
|
|
Test-MinerOpsEnrollmentCredentialAcl -Acl $Acl
|
|
}
|
|
},
|
|
@{
|
|
Name = "installer"
|
|
NewAcl = { New-MinerOpsInstallerProtectedFileAcl }
|
|
Create = {
|
|
param($Path, $Acl)
|
|
New-MinerOpsInstallerProtectedFileCreateNewStream `
|
|
-Path $Path -Acl $Acl
|
|
}
|
|
GetAcl = {
|
|
param($Stream)
|
|
Get-MinerOpsInstallerStreamAcl -Stream $Stream
|
|
}
|
|
TestAcl = {
|
|
param($Acl)
|
|
Test-MinerOpsInstallerProtectedFileAcl -Acl $Acl
|
|
}
|
|
}
|
|
)
|
|
foreach ($implementation in $implementations) {
|
|
$path = Join-Path $workRoot (
|
|
"{0}-protected-create.state" -f $implementation.Name
|
|
)
|
|
$stream = $null
|
|
$competing = $null
|
|
try {
|
|
$acl = & $implementation.NewAcl
|
|
$stream = & $implementation.Create $path $acl
|
|
Assert-Native ($stream.Length -eq 0) `
|
|
"$($implementation.Name) wrote before ACL verification."
|
|
$readBack = & $implementation.GetAcl $stream
|
|
Assert-Native ([bool](& $implementation.TestAcl $readBack)) `
|
|
"$($implementation.Name) ACL read-back failed."
|
|
try {
|
|
$competing = [IO.File]::Open(
|
|
$path,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::ReadWrite
|
|
)
|
|
throw "$($implementation.Name) protected file was not exclusive."
|
|
}
|
|
catch [IO.IOException] { }
|
|
}
|
|
finally {
|
|
if ($null -ne $competing) { $competing.Dispose() }
|
|
if ($null -ne $stream) { $stream.Dispose() }
|
|
}
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "recovery and pending DACLs deny a standard user" {
|
|
foreach ($leaf in @("recovery.json", "agent-config.json.pending")) {
|
|
$path = Join-Path $workRoot $leaf
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $path `
|
|
-ExpectedParent $workRoot `
|
|
-Value ([ordered]@{
|
|
agent_id = "00000000-0000-4000-8000-000000000111"
|
|
agent_secret = "disposable-validation-secret"
|
|
})
|
|
Assert-StandardUserDenied -Operation read -Path $path
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "directory DACL denies standard-user child creation" {
|
|
$untrustedChild = Join-Path $workRoot "standard-user-create.json"
|
|
Assert-StandardUserDenied -Operation create -Path $untrustedChild
|
|
Assert-Native (-not (Test-Path -LiteralPath $untrustedChild)) `
|
|
"Denied standard-user create left a child file."
|
|
}
|
|
|
|
Invoke-NativeTest "file DACL denies standard-user modify, delete, and rename" {
|
|
$protectedPath = Join-Path $workRoot "delete-rename-source.json"
|
|
$renamedPath = Join-Path $workRoot "delete-rename-destination.json"
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $protectedPath -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "disposable-delete-test" }
|
|
Assert-StandardUserDenied -Operation modify -Path $protectedPath
|
|
Assert-Native (
|
|
[IO.File]::ReadAllText($protectedPath) -match
|
|
"disposable-delete-test"
|
|
) "Denied standard-user modify changed the file."
|
|
Assert-StandardUserDenied -Operation delete -Path $protectedPath
|
|
Assert-Native (Test-Path -LiteralPath $protectedPath -PathType Leaf) `
|
|
"Denied standard-user delete removed the file."
|
|
Assert-StandardUserDenied -Operation rename -Path $protectedPath `
|
|
-Destination $renamedPath
|
|
Assert-Native (
|
|
(Test-Path -LiteralPath $protectedPath -PathType Leaf) -and
|
|
-not (Test-Path -LiteralPath $renamedPath)
|
|
) "Denied standard-user rename changed the namespace."
|
|
}
|
|
|
|
Invoke-NativeTest "standard-user replacement swap is denied" {
|
|
$activePath = Join-Path $workRoot "swap-active.json"
|
|
$replacementPath = Join-Path $env:PUBLIC (
|
|
"minerops-swap-source-$validationId.json"
|
|
)
|
|
$renamedTargetPath = Join-Path $workRoot "swap-active-renamed.json"
|
|
try {
|
|
Assert-Native (-not (Test-Path -LiteralPath $replacementPath)) `
|
|
"Swap source unexpectedly existed before standard-user creation."
|
|
$prepareResult = Invoke-StandardUserOperation `
|
|
-Operation prepare_swap -Path $replacementPath
|
|
Assert-Native (
|
|
[string]$prepareResult.status -ceq "succeeded" -and
|
|
[bool]$prepareResult.source_exists -and
|
|
[bool]$prepareResult.input_owned_by_caller -and
|
|
[bool]$prepareResult.source_created_by_caller -and
|
|
[bool]$prepareResult.source_read_verified -and
|
|
[bool]$prepareResult.source_modify_verified -and
|
|
[bool]$prepareResult.source_rename_verified -and
|
|
[bool]$prepareResult.source_delete_access_verified -and
|
|
[bool]$prepareResult.source_replace_access_verified
|
|
) "Standard user did not create and control the swap source."
|
|
$sourceBefore = Get-NativeUnprotectedFileEvidence `
|
|
-Path $replacementPath
|
|
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $activePath -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "protected-active" }
|
|
$targetBefore = Get-NativeProtectedFileEvidence `
|
|
-Path $activePath -ExpectedParent $workRoot
|
|
|
|
$sourceFullPath = [IO.Path]::GetFullPath($replacementPath)
|
|
$targetFullPath = [IO.Path]::GetFullPath($activePath)
|
|
$sourceDrive = [IO.DriveInfo]::new(
|
|
[IO.Path]::GetPathRoot($sourceFullPath)
|
|
)
|
|
$targetDrive = [IO.DriveInfo]::new(
|
|
[IO.Path]::GetPathRoot($targetFullPath)
|
|
)
|
|
$standardUserSid = [string](
|
|
Get-LocalUser -Name $userName -ErrorAction Stop
|
|
).SID.Value
|
|
$fixture = [ordered]@{
|
|
SourceExists = Test-Path `
|
|
-LiteralPath $sourceFullPath -PathType Leaf
|
|
TargetExists = Test-Path `
|
|
-LiteralPath $targetFullPath -PathType Leaf
|
|
PathsDistinct = -not $sourceFullPath.Equals(
|
|
$targetFullPath,
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
)
|
|
SourceCreatedByStandardUser =
|
|
[bool]$prepareResult.source_created_by_caller
|
|
SourceOwnedByStandardUser =
|
|
[bool]$prepareResult.input_owned_by_caller -and
|
|
$sourceBefore.OwnerSid -ceq $standardUserSid
|
|
SourceReadable = [bool]$prepareResult.source_read_verified
|
|
SourceModifiable = [bool]$prepareResult.source_modify_verified
|
|
SourceRenameable = [bool]$prepareResult.source_rename_verified
|
|
SourceDeleteCapable =
|
|
[bool]$prepareResult.source_delete_access_verified
|
|
SourceReplaceAccessVerified =
|
|
[bool]$prepareResult.source_replace_access_verified
|
|
TargetAclTrusted = [bool]$targetBefore.AclTrusted
|
|
TargetOwnerTrusted = [bool]$targetBefore.OwnerTrusted
|
|
TargetIdentityCaptured =
|
|
-not [string]::IsNullOrWhiteSpace($targetBefore.Identity)
|
|
SourceIdentityCaptured =
|
|
-not [string]::IsNullOrWhiteSpace($sourceBefore.Identity)
|
|
FileIdentitiesDistinct =
|
|
$sourceBefore.Identity -cne $targetBefore.Identity
|
|
# No backup is deliberately selected. It avoids copying the
|
|
# protected destination into an attacker-readable namespace.
|
|
BackupArgumentValid = $true
|
|
BackupPathsDistinct = $true
|
|
BackupExpectedStateVerified = $true
|
|
BackupConfidentialitySafe = $true
|
|
SameVolume = $sourceBefore.VolumeSerialNumber -eq
|
|
$targetBefore.VolumeSerialNumber
|
|
NtfsVolume = $sourceDrive.DriveFormat -ceq "NTFS" -and
|
|
$targetDrive.DriveFormat -ceq "NTFS"
|
|
}
|
|
Assert-MinerOpsNativeSwapFixtureReady -Fixture $fixture
|
|
|
|
$swapResult = Invoke-StandardUserOperation `
|
|
-Operation swap -Path $replacementPath `
|
|
-Destination $activePath -BackupMode None
|
|
Assert-Native (
|
|
[string]$swapResult.input_full_path -ceq $sourceFullPath -and
|
|
[string]$swapResult.destination_full_path -ceq $targetFullPath -and
|
|
[string]$swapResult.backup_mode -ceq "None" -and
|
|
[string]::IsNullOrEmpty(
|
|
[string]$swapResult.backup_full_path
|
|
) -and
|
|
[bool]$swapResult.backup_argument_was_native_null -and
|
|
[string]$swapResult.replace_overload -ceq
|
|
"System.IO.File.Replace(String,String,String)" -and
|
|
[bool]$swapResult.source_existed_before_attack -and
|
|
[bool]$swapResult.destination_existed_before_attack -and
|
|
$null -eq $swapResult.backup_existed_before_attack
|
|
) "Standard-user swap did not use the verified File.Replace arguments."
|
|
$postconditions = [ordered]@{
|
|
TargetExists = Test-Path -LiteralPath $activePath -PathType Leaf
|
|
TargetIdentityUnchanged = $false
|
|
TargetContentUnchanged = $false
|
|
TargetAclTrusted = $false
|
|
TargetAclUnchanged = $false
|
|
TargetOwnerTrusted = $false
|
|
TargetOwnerUnchanged = $false
|
|
TargetSingleLink = $false
|
|
SourceExists = Test-Path `
|
|
-LiteralPath $replacementPath -PathType Leaf
|
|
SourceIdentityUnchanged = $false
|
|
SourceContentUnchanged = $false
|
|
AttackerSourceNotPromoted = $false
|
|
TargetDeniedStandardUserRead = $false
|
|
TargetDeniedStandardUserModify = $false
|
|
TargetDeniedStandardUserDelete = $false
|
|
TargetDeniedStandardUserRename = $false
|
|
NoAttackerReadableBackup =
|
|
[bool]$swapResult.backup_argument_was_native_null -and
|
|
[string]::IsNullOrEmpty(
|
|
[string]$swapResult.backup_full_path
|
|
)
|
|
}
|
|
|
|
$readResult = Invoke-StandardUserOperation `
|
|
-Operation read -Path $activePath
|
|
$postconditions.TargetDeniedStandardUserRead =
|
|
[string]$readResult.status -ceq "security_denied"
|
|
$modifyResult = Invoke-StandardUserOperation `
|
|
-Operation modify -Path $activePath
|
|
$postconditions.TargetDeniedStandardUserModify =
|
|
[string]$modifyResult.status -ceq "security_denied"
|
|
$deleteResult = Invoke-StandardUserOperation `
|
|
-Operation delete -Path $activePath
|
|
$postconditions.TargetDeniedStandardUserDelete =
|
|
[string]$deleteResult.status -ceq "security_denied"
|
|
$renameResult = Invoke-StandardUserOperation `
|
|
-Operation rename -Path $activePath `
|
|
-Destination $renamedTargetPath
|
|
$postconditions.TargetDeniedStandardUserRename =
|
|
[string]$renameResult.status -ceq "security_denied"
|
|
|
|
$postconditions.TargetExists = Test-Path `
|
|
-LiteralPath $activePath -PathType Leaf
|
|
if ([bool]$postconditions.TargetExists) {
|
|
try {
|
|
$targetAfter = Get-NativeProtectedFileEvidence `
|
|
-Path $activePath -ExpectedParent $workRoot
|
|
$postconditions.TargetIdentityUnchanged =
|
|
$targetAfter.Identity -ceq $targetBefore.Identity
|
|
$postconditions.TargetContentUnchanged =
|
|
$targetAfter.ContentHash -ceq $targetBefore.ContentHash
|
|
$postconditions.TargetAclTrusted = $targetAfter.AclTrusted
|
|
$postconditions.TargetAclUnchanged =
|
|
$targetAfter.AclSddl -ceq $targetBefore.AclSddl
|
|
$postconditions.TargetOwnerTrusted =
|
|
[bool]$targetAfter.OwnerTrusted
|
|
$postconditions.TargetOwnerUnchanged =
|
|
$targetAfter.OwnerSid -ceq $targetBefore.OwnerSid
|
|
$postconditions.TargetSingleLink =
|
|
$targetAfter.NumberOfLinks -eq 1
|
|
$postconditions.AttackerSourceNotPromoted =
|
|
$targetAfter.Identity -cne $sourceBefore.Identity -and
|
|
$targetAfter.ContentHash -cne $sourceBefore.ContentHash
|
|
}
|
|
catch {
|
|
# The consolidated assertion below reports each failed
|
|
# target-security postcondition without exposing content.
|
|
}
|
|
}
|
|
|
|
$postconditions.SourceExists = Test-Path `
|
|
-LiteralPath $replacementPath -PathType Leaf
|
|
if ([bool]$postconditions.SourceExists) {
|
|
try {
|
|
$sourceAfter = Get-NativeUnprotectedFileEvidence `
|
|
-Path $replacementPath
|
|
$postconditions.SourceIdentityUnchanged =
|
|
$sourceAfter.Identity -ceq $sourceBefore.Identity
|
|
$postconditions.SourceContentUnchanged =
|
|
$sourceAfter.ContentHash -ceq $sourceBefore.ContentHash
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
Assert-MinerOpsNativeSwapSecurityOutcome `
|
|
-Result $swapResult -Postconditions $postconditions
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath $replacementPath -Force `
|
|
-ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "ACL setup and verification failures write no secret" {
|
|
$setupFailurePath = Join-Path $workRoot "acl-setup-failure.json"
|
|
$originalNewAcl = (Get-Command New-MinerOpsCredentialAcl).ScriptBlock
|
|
try {
|
|
Set-Item Function:\New-MinerOpsCredentialAcl {
|
|
throw "injected native ACL setup failure"
|
|
}
|
|
try {
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $setupFailurePath `
|
|
-ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "must-not-appear" }
|
|
throw "ACL setup injection unexpectedly succeeded."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "ACL setup failure") `
|
|
"Unexpected ACL setup injection error."
|
|
}
|
|
}
|
|
finally {
|
|
Set-Item Function:\New-MinerOpsCredentialAcl $originalNewAcl
|
|
}
|
|
Assert-Native (-not (Test-Path -LiteralPath $setupFailurePath)) `
|
|
"ACL setup failure left a credential file."
|
|
|
|
$verificationFailurePath = Join-Path $workRoot (
|
|
"acl-verification-failure.json"
|
|
)
|
|
$originalGetAcl = (Get-Command Get-MinerOpsStreamAcl).ScriptBlock
|
|
try {
|
|
Set-Item Function:\Get-MinerOpsStreamAcl {
|
|
param($Stream)
|
|
throw "injected native ACL verification failure"
|
|
}
|
|
try {
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $verificationFailurePath `
|
|
-ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "must-not-appear" }
|
|
throw "ACL verification injection unexpectedly succeeded."
|
|
}
|
|
catch {
|
|
Assert-Native (
|
|
$_.Exception.Message -match "ACL verification failure"
|
|
) "Unexpected ACL verification injection error."
|
|
}
|
|
}
|
|
finally {
|
|
Set-Item Function:\Get-MinerOpsStreamAcl $originalGetAcl
|
|
}
|
|
Assert-Native (-not (Test-Path -LiteralPath $verificationFailurePath)) `
|
|
"ACL verification failure left a credential file."
|
|
}
|
|
|
|
Invoke-NativeTest "pre-created credential pathname is never truncated" {
|
|
$path = Join-Path $workRoot "pre-created-pending.json"
|
|
$sentinel = '{"attacker_controlled":"preserve-exactly"}'
|
|
[IO.File]::WriteAllText($path, $sentinel)
|
|
try {
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $path -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "must-not-be-written" }
|
|
throw "Pre-created pathname was adopted."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "already exists|exist") `
|
|
"Pre-created pathname produced an unexpected error."
|
|
}
|
|
Assert-Native ([IO.File]::ReadAllText($path) -ceq $sentinel) `
|
|
"CreateNew failure modified the pre-created pathname."
|
|
}
|
|
|
|
Invoke-NativeTest "attacker-created hard-linked credential target is rejected" {
|
|
$sourcePath = Join-Path $workRoot "hard-link-source.json"
|
|
$targetPath = Join-Path $workRoot "hard-link-pending.json"
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $sourcePath -ExpectedParent $workRoot `
|
|
-Value @{ harmless = "preexisting" }
|
|
[void](New-Item -ItemType HardLink -Path $targetPath `
|
|
-Target $sourcePath)
|
|
try {
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $targetPath -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "must-not-be-written" }
|
|
throw "Hard-linked CreateNew target was adopted."
|
|
}
|
|
catch {
|
|
Assert-Native (
|
|
$_.Exception.Message -match "already exists|exist"
|
|
) "Hard-linked target produced an unexpected error."
|
|
}
|
|
Assert-Native (
|
|
(Test-Path -LiteralPath $sourcePath -PathType Leaf) -and
|
|
(Test-Path -LiteralPath $targetPath -PathType Leaf)
|
|
) "Failed CreateNew deleted hard-link evidence."
|
|
Assert-Native (
|
|
-not ([IO.File]::ReadAllText($sourcePath).Contains(
|
|
"must-not-be-written"
|
|
))
|
|
) "Credential content was written through a hard-link alias."
|
|
}
|
|
|
|
Invoke-NativeTest "insecure existing artifact is repaired then rejected" {
|
|
$insecurePath = Join-Path $workRoot "insecure-recovery.json"
|
|
[IO.File]::WriteAllText($insecurePath, '{"agent_secret":"untrusted"}')
|
|
$script:nativeRepairPath = $insecurePath
|
|
$script:nativeRepairWasExclusive = $false
|
|
$script:nativeOriginalSetStreamAcl =
|
|
(Get-Command Set-MinerOpsStreamAcl).ScriptBlock
|
|
try {
|
|
Set-Item Function:\Set-MinerOpsStreamAcl {
|
|
param($Stream, $Acl)
|
|
$competing = $null
|
|
try {
|
|
$competing = [IO.File]::Open(
|
|
$script:nativeRepairPath,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::ReadWrite
|
|
)
|
|
throw "Credential repair handle was not exclusive."
|
|
}
|
|
catch [IO.IOException] {
|
|
$script:nativeRepairWasExclusive = $true
|
|
}
|
|
finally {
|
|
if ($null -ne $competing) { $competing.Dispose() }
|
|
}
|
|
& $script:nativeOriginalSetStreamAcl `
|
|
-Stream $Stream -Acl $Acl
|
|
}
|
|
try {
|
|
[void](Read-MinerOpsProtectedCredentialText `
|
|
-Path $insecurePath -ExpectedParent $workRoot)
|
|
throw "Insecure artifact was trusted."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "will not be trusted") `
|
|
"Insecure artifact did not fail closed."
|
|
}
|
|
}
|
|
finally {
|
|
Set-Item Function:\Set-MinerOpsStreamAcl `
|
|
$script:nativeOriginalSetStreamAcl
|
|
}
|
|
Assert-Native $script:nativeRepairWasExclusive `
|
|
"Existing-artifact repair was not exclusive."
|
|
Assert-StandardUserDenied -Operation read -Path $insecurePath
|
|
}
|
|
|
|
Invoke-NativeTest "repair fails closed when security rights cannot be acquired" {
|
|
$path = Join-Path $workRoot "repair-rights-denied.json"
|
|
[IO.File]::WriteAllText($path, '{"agent_secret":"untrusted"}')
|
|
$beforeSddl = (Get-Acl -LiteralPath $path).Sddl
|
|
$originalOpen =
|
|
(Get-Command Open-MinerOpsFileStreamWithRights).ScriptBlock
|
|
try {
|
|
Set-Item Function:\Open-MinerOpsFileStreamWithRights {
|
|
param($Path, $Rights)
|
|
throw [System.UnauthorizedAccessException]::new(
|
|
"injected security-right acquisition failure"
|
|
)
|
|
}
|
|
try {
|
|
[void](Read-MinerOpsProtectedCredentialText `
|
|
-Path $path -ExpectedParent $workRoot)
|
|
throw "Security-right acquisition failure was ignored."
|
|
}
|
|
catch {
|
|
Assert-Native (
|
|
$_.Exception.Message -match "right acquisition failure"
|
|
) "Security-right acquisition failure did not propagate."
|
|
}
|
|
}
|
|
finally {
|
|
Set-Item Function:\Open-MinerOpsFileStreamWithRights $originalOpen
|
|
}
|
|
Assert-Native ((Get-Acl -LiteralPath $path).Sddl -ceq $beforeSddl) `
|
|
"Denied repair used a fallback ACL mutation."
|
|
Assert-Native (
|
|
[IO.File]::ReadAllText($path) -ceq '{"agent_secret":"untrusted"}'
|
|
) "Denied repair changed credential content."
|
|
}
|
|
|
|
Invoke-NativeTest "durable blocker prevents ACL-repair laundering" {
|
|
$recoveryPath = Join-Path $workRoot "agent-credential-transition.recovery.json"
|
|
[IO.File]::WriteAllText(
|
|
$recoveryPath,
|
|
'{"agent_secret":"must-never-be-authoritative"}'
|
|
)
|
|
$blocker = Ensure-MinerOpsQuarantineBlocker `
|
|
-TargetRoot $workRoot -Reason "native-acl-repair-test"
|
|
$opened = Open-MinerOpsProtectedCredentialFile `
|
|
-Path $recoveryPath -ExpectedParent $workRoot -AllowAclRepair
|
|
$opened.Dispose()
|
|
|
|
# This is the exact interrupted window: ACL repaired, authority name
|
|
# still present, and the process could terminate before quarantine.
|
|
Assert-Native (Test-Path -LiteralPath $recoveryPath -PathType Leaf) `
|
|
"ACL-repair window was not established."
|
|
Assert-Native (Test-Path -LiteralPath $blocker -PathType Leaf) `
|
|
"Blocked-state marker was not durable before ACL repair."
|
|
try {
|
|
Assert-MinerOpsNoQuarantineBlocker -TargetRoot $workRoot
|
|
throw "Durable blocker did not fail the second invocation closed."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "evidence is quarantined") `
|
|
"Durable blocker produced an unexpected error."
|
|
}
|
|
[void](Move-MinerOpsArtifactToQuarantine `
|
|
-Path $recoveryPath -ExpectedParent $workRoot -Role "recovery")
|
|
Assert-Native (-not (Test-Path -LiteralPath $recoveryPath)) `
|
|
"Repaired recovery remained authoritative."
|
|
try {
|
|
Assert-MinerOpsNoQuarantineBlocker -TargetRoot $workRoot
|
|
throw "Quarantine evidence did not keep the next run blocked."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "evidence is quarantined") `
|
|
"Quarantine evidence produced an unexpected error."
|
|
}
|
|
foreach ($evidence in @(Get-MinerOpsQuarantineArtifacts `
|
|
-TargetRoot $workRoot)) {
|
|
Assert-StandardUserDenied -Operation read -Path $evidence.FullName
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "native two-rename install preserves protected result" {
|
|
$activePath = Join-Path $workRoot "replace-active.json"
|
|
$pendingPath = Join-Path $workRoot "replace-pending.json"
|
|
$priorPath = Join-Path $workRoot "replace-prior.json"
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $activePath -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "legacy-test-secret" }
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $pendingPath -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "new-test-secret" }
|
|
Invoke-MinerOpsRecoverableConfigInstall `
|
|
-PendingPath $pendingPath -ConfigPath $activePath `
|
|
-PriorPath $priorPath -TargetRoot $workRoot
|
|
Assert-Native (-not (Test-Path -LiteralPath $pendingPath)) `
|
|
"Two-rename install did not consume pending source."
|
|
Assert-Native (Test-Path -LiteralPath $priorPath -PathType Leaf) `
|
|
"Two-rename install did not retain the prior remnant."
|
|
$result = Read-MinerOpsProtectedCredentialText `
|
|
-Path $activePath -ExpectedParent $workRoot
|
|
Assert-Native ($result -match "new-test-secret") `
|
|
"Two-rename install did not install the pending content."
|
|
Assert-StandardUserDenied -Operation read -Path $activePath
|
|
}
|
|
|
|
Invoke-NativeTest "active-missing pending install converges natively" {
|
|
$activePath = Join-Path $workRoot "missing-active.json"
|
|
$pendingPath = Join-Path $workRoot "missing-active.pending"
|
|
$priorPath = Join-Path $workRoot "missing-active.prior"
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $pendingPath -ExpectedParent $workRoot `
|
|
-Value @{ agent_secret = "authenticated-pending" }
|
|
Invoke-MinerOpsRecoverableConfigInstall `
|
|
-PendingPath $pendingPath -ConfigPath $activePath `
|
|
-PriorPath $priorPath -TargetRoot $workRoot
|
|
Assert-Native (Test-Path -LiteralPath $activePath -PathType Leaf) `
|
|
"Active-missing install did not restore active."
|
|
Assert-Native (-not (Test-Path -LiteralPath $priorPath)) `
|
|
"Active-missing install manufactured prior authority."
|
|
}
|
|
|
|
Invoke-NativeTest "directory junction is rejected by all reparse validators" {
|
|
$junctionTarget = Join-Path $workRoot "junction-target"
|
|
$junctionPath = Join-Path $workRoot "junction-path"
|
|
[void](New-Item -ItemType Directory -Path $junctionTarget)
|
|
[void](New-Item -ItemType Junction -Path $junctionPath `
|
|
-Target $junctionTarget)
|
|
foreach ($validator in @(
|
|
{ Assert-MinerOpsNoReparseDirectoryPath -Path $junctionPath },
|
|
{ Assert-MinerOpsEnrollmentNoReparsePath -Path $junctionPath },
|
|
{ Assert-MinerOpsInstallerNoReparsePath -Path $junctionPath }
|
|
)) {
|
|
try {
|
|
[void](& $validator)
|
|
throw "Directory junction was accepted."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "reparse point") `
|
|
"Directory junction produced an unexpected error."
|
|
}
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "permissive directory is hardened with a durable repair marker" {
|
|
$permissivePath = Join-Path $workRoot "permissive-directory"
|
|
[void](New-Item -ItemType Directory -Path $permissivePath)
|
|
$acl = Get-Acl -LiteralPath $permissivePath
|
|
$users = New-Object System.Security.Principal.SecurityIdentifier(
|
|
"S-1-5-32-545"
|
|
)
|
|
$acl.AddAccessRule((New-Object `
|
|
System.Security.AccessControl.FileSystemAccessRule(
|
|
$users,
|
|
[System.Security.AccessControl.FileSystemRights]::Modify,
|
|
[System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit",
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
)))
|
|
Set-Acl -LiteralPath $permissivePath -AclObject $acl
|
|
$state = Initialize-MinerOpsCredentialDirectory -Path $permissivePath
|
|
Assert-Native ([bool]$state.WasRepaired) `
|
|
"Permissive directory was not identified as repaired."
|
|
[void](Assert-MinerOpsCredentialDirectory -Path $permissivePath)
|
|
$blockers = @(Get-MinerOpsFilesystemTrustBlockerPaths `
|
|
-Path $permissivePath)
|
|
Assert-Native ($blockers.Count -gt 0) `
|
|
"External directory trust blocker was not durable."
|
|
foreach ($blocker in $blockers) {
|
|
$record = Read-MinerOpsFilesystemTrustBlocker `
|
|
-Path $blocker.FullName -TargetRoot $permissivePath
|
|
Assert-Native ([string]$record.state -ceq "blocked") `
|
|
"Filesystem trust blocker ACL/content verification failed."
|
|
}
|
|
Assert-Native ([bool]$state.TrustEpochNewlyBlocked) `
|
|
"Directory repair did not force a new blocked trust epoch."
|
|
}
|
|
|
|
$serviceScript = Join-Path $workRoot "MinerOpsAgentService.ps1"
|
|
[IO.File]::WriteAllText($serviceScript, "Start-Sleep -Seconds 600`r`n")
|
|
$action = New-ScheduledTaskAction `
|
|
-Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
|
-Argument ('-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
|
|
$serviceScript)
|
|
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
|
|
-LogonType ServiceAccount -RunLevel Highest
|
|
Register-ScheduledTask `
|
|
-TaskName $taskName `
|
|
-Action $action `
|
|
-Principal $principal | Out-Null
|
|
$taskCreated = $true
|
|
|
|
foreach ($originallyRunning in @($true, $false)) {
|
|
$label = if ($originallyRunning) { "running" } else { "stopped" }
|
|
Invoke-NativeTest "native Scheduled Task restores originally $label intent" {
|
|
Set-MinerOpsScheduledTaskState `
|
|
-Name $taskName -TargetRoot $workRoot `
|
|
-ShouldRun $originallyRunning
|
|
$statePath = Join-Path $workRoot "task-state-$label.json"
|
|
Write-MinerOpsProtectedCredentialJson `
|
|
-Path $statePath `
|
|
-ExpectedParent $workRoot `
|
|
-Value @{ original_task_running = $originallyRunning }
|
|
Stop-MinerOpsAgentTask -Name $taskName -TargetRoot $workRoot
|
|
$state = Read-MinerOpsProtectedCredentialText `
|
|
-Path $statePath -ExpectedParent $workRoot | ConvertFrom-Json
|
|
Set-MinerOpsScheduledTaskState `
|
|
-Name $taskName `
|
|
-TargetRoot $workRoot `
|
|
-ShouldRun ([bool]$state.original_task_running)
|
|
$final = Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $taskName -TargetRoot $workRoot
|
|
Assert-Native (
|
|
([string]$final.State -eq "Running") -eq $originallyRunning -and
|
|
([string]$final.State -eq "Ready") -eq (-not $originallyRunning)
|
|
) "Native Scheduled Task final state was wrong."
|
|
}
|
|
}
|
|
|
|
Invoke-NativeTest "strict verifier rejects missing and Disabled tasks" {
|
|
try {
|
|
[void](Get-MinerOpsVerifiedScheduledTask `
|
|
-Name "$taskName-missing" -TargetRoot $workRoot)
|
|
throw "Missing task was accepted."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "missing") `
|
|
"Missing task produced an unexpected error."
|
|
}
|
|
Disable-ScheduledTask -TaskName $taskName | Out-Null
|
|
try {
|
|
[void](Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $taskName -TargetRoot $workRoot)
|
|
throw "Disabled task was accepted."
|
|
}
|
|
catch {
|
|
Assert-Native ($_.Exception.Message -match "disabled") `
|
|
"Disabled task produced an unexpected error."
|
|
}
|
|
Enable-ScheduledTask -TaskName $taskName | Out-Null
|
|
}
|
|
|
|
Write-Host "$passed native Windows credential-transition tests passed."
|
|
Write-Host "This proves root-qualified drive enumeration, current-directory independence, creation-time protected FileStreams, same-handle ACL read-back, native NTFS boundary enforcement, fail-closed ACL repair, durable blocking, hard-link refusal, two-rename primitives, junction rejection, and strict Scheduled Task state restoration."
|
|
Write-Host "Run the companion validation procedure for hostile pre-repair handles, installer/enrollment hard links, full strict-task failures, and hard process-termination/rerun convergence."
|
|
}
|
|
finally {
|
|
[Environment]::CurrentDirectory = $originalProcessCurrentDirectory
|
|
Set-Location -LiteralPath $originalPowerShellLocation `
|
|
-ErrorAction SilentlyContinue
|
|
if ($taskCreated) {
|
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
Unregister-ScheduledTask `
|
|
-TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
}
|
|
if (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue) {
|
|
Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue
|
|
}
|
|
Remove-Item -LiteralPath $readerPath -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $readerSupportPath -Force `
|
|
-ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $publicResult -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue
|
|
Assert-MinerOpsNativeGetItemState -Expected $originalGetItemState `
|
|
-Context "native harness final cleanup"
|
|
}
|