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
File diff suppressed because it is too large.
Load diff
@@ -204,9 +204,11 @@ $testButton.Add_Click({
|
||||
}
|
||||
|
||||
$hub = [string]$config.hub_url
|
||||
$agentId = [string]$config.agent_id
|
||||
$secret = [string]$config.agent_secret
|
||||
|
||||
$headers = @{
|
||||
"X-MinerOps-Agent-ID" = $agentId
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
}
|
||||
|
||||
|
||||
+124
-3
@@ -74,9 +74,122 @@ credentials in screenshots, chat, tickets, logs, or source control.
|
||||
|
||||
### Existing Agent upgrade
|
||||
|
||||
For an already enrolled/configured Agent, do not run the enrollment
|
||||
bootstrapper. Copy the new canonical Agent files to a temporary directory and
|
||||
run install.ps1 from an elevated Windows PowerShell session.
|
||||
An existing legacy Agent must receive a per-Agent credential before the global
|
||||
shared-secret compatibility path can be retired for its identity. In Agent
|
||||
Management, use **Upgrade credential** on that Agent. Download the resulting
|
||||
Windows package, transfer it only to that Agent computer, and run:
|
||||
|
||||
.\Upgrade-MinerOpsAgentCredential.ps1
|
||||
|
||||
The authorization is bound server-side to the existing Agent ID and its
|
||||
current Site and Agent Zone. The script validates the local Agent ID, upgrades
|
||||
the canonical runtime, stops the main Agent task, generates the permanent
|
||||
secret locally, and recoverably replaces only the credential and durable routing
|
||||
fields in `agent-config.json` using protected same-directory pending and prior
|
||||
names. Existing API3 credentials, polling settings,
|
||||
network ranges, state, logs, and backups are preserved. Before any secret is
|
||||
written, the script creates and verifies a protected recovery file containing
|
||||
the original Scheduled Task state, exact transition token, and exact locally
|
||||
generated credential. The Hub accepts an ambiguous retry only for that same
|
||||
token and credential.
|
||||
|
||||
The Agent directory is a filesystem security boundary. The transition rejects
|
||||
junctions/reparse points, verifies trusted ancestors, denies standard users
|
||||
create/delete/rename rights, and revalidates the boundary before each rename.
|
||||
If an existing Agent directory is discovered insecure, the script first writes
|
||||
`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-<random>.state` under the trusted
|
||||
parent, then hardens the directory only to contain the incident. The blocker is
|
||||
outside the contaminated namespace because changing a DACL does not revoke
|
||||
file or directory handles opened before repair. The same process never redeems
|
||||
a token, creates or activates a permanent credential, runs the installer, or
|
||||
removes that blocker. Ordinary second, third, and later invocations remain
|
||||
blocked even when every current ACL looks correct.
|
||||
|
||||
Recovery, pending, replacement remnants, and late-arriving artifacts are
|
||||
non-authoritative while the external blocker exists. Known transition state is
|
||||
quarantined without parsing. A correct filename, syntax, or ACL cannot promote
|
||||
it on a later run. Invalidate any possibly exposed authorization and retain or
|
||||
remove evidence only under the approved incident procedure.
|
||||
|
||||
### Filesystem trust-epoch recovery
|
||||
|
||||
Use this procedure only after confirming the blocker was created by the
|
||||
containment event and preserving required evidence:
|
||||
|
||||
1. Do not delete or edit the external blocker. Do not run an ordinary retry.
|
||||
2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast
|
||||
Startup is not sufficient: Microsoft documents Fast Startup as hibernating
|
||||
and restoring kernel session 0. The script identifies the kernel epoch by
|
||||
the Event Log record anchor of the newest
|
||||
`Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`.
|
||||
Provider, event ID, record ID, and supported event version come from typed
|
||||
EventRecord properties; namespace-aware XML selection reads only the
|
||||
`BootType` payload. The new full-boot record ID must be strictly greater
|
||||
than the stored record ID. Equality, an older/restored record, log clearing
|
||||
or numbering reset, missing history, duplication, malformed XML, and an
|
||||
unsupported provider schema all fail closed and require safe operator
|
||||
intervention. There is no timestamp fallback. The anchor is unchanged by
|
||||
clock or timezone changes, sleep, hibernation, and Fast Startup. See
|
||||
Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states)
|
||||
and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail).
|
||||
3. For a credential transition, use a fresh transition package if any recovery,
|
||||
pending, remnant, quarantine, or internal blocker existed. Remove those
|
||||
artifacts only under the approved incident procedure after invalidating the
|
||||
old authorization.
|
||||
4. From the reviewed package, run:
|
||||
|
||||
.\Upgrade-MinerOpsAgentCredential.ps1 `
|
||||
-ApproveFilesystemTrustResetAfterReboot
|
||||
|
||||
The script verifies that Windows boot identity changed, rescans the protected
|
||||
namespace, positively verifies and stops the expected Scheduled Task, and asks the
|
||||
installer to replace managed runtime without backing up or restoring any
|
||||
pre-reset runtime. The installer leaves both the Agent task and external
|
||||
blocker untouched after publication; it has no blocker-deletion path. Before
|
||||
its first fallible stop/install step, the parent durably records the strictly
|
||||
validated original task intent as `reset-authorized`; after installer
|
||||
success it advances the blocker to `runtime-published`. It then verifies the
|
||||
config against the package/server identity, commits the credential, and
|
||||
restores the recorded task intent. Only the parent removes the blocker,
|
||||
after active config and final task state have both been positively verified
|
||||
and non-recovery transition cleanup has succeeded. Protected recovery stays
|
||||
until after blocker deletion so an interrupted final cleanup remains safe.
|
||||
5. For a genuinely new enrollment, preserve evidence and use the approved
|
||||
protected-recreation incident procedure to remove the contaminated namespace
|
||||
and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new
|
||||
protected directory. Enrollment has no in-script blocker-deletion path; its
|
||||
explicit compatibility switch cannot override a retained blocker.
|
||||
|
||||
The explicit switch without a strictly newer canonical full-kernel-boot anchor
|
||||
fails.
|
||||
Any suspicious transition artifact, failed runtime publication, failed
|
||||
credential convergence, invalid final task, or interrupted cleanup retains the
|
||||
blocker. Runtime publication alone never authorizes deletion. Protected
|
||||
directory recreation is the alternative when the retained namespace cannot be
|
||||
verified.
|
||||
|
||||
If the script is interrupted after the Hub commits, rerun the same extracted
|
||||
package without deleting or editing `agent-credential-transition.recovery.json`
|
||||
or `agent-config.json.pending`. A protected, matching pending config is resumed;
|
||||
an insecure, malformed, or mismatched artifact fails closed. The original task
|
||||
intent is durable: an Agent which was running before the first attempt ends
|
||||
running, while an intentionally stopped Agent remains stopped. Success is not
|
||||
reported and transition artifacts are not removed until both the active config
|
||||
and expected final task state are verified. The verifier requires exactly one
|
||||
enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal,
|
||||
the installed Windows PowerShell action and service-script arguments, and an
|
||||
exact final state of `Running` for running intent or `Ready` for stopped intent.
|
||||
Missing, query failure, access denied, malformed, multiple, disabled, queued,
|
||||
unknown, wrong-action, and wrong-principal results all fail closed and retain
|
||||
recovery.
|
||||
|
||||
After a successful transition, the existing Agent ID, Site, and Zone are
|
||||
unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the
|
||||
legacy shared secret is permanently disabled for that identity.
|
||||
|
||||
For later code-only upgrades of an Agent that already has a per-Agent
|
||||
credential, copy the new canonical Agent files to a temporary directory and run
|
||||
install.ps1 from an elevated Windows PowerShell session.
|
||||
|
||||
The installer copies only managed runtime code to C:\MinerOpsAgent and
|
||||
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
|
||||
@@ -90,6 +203,14 @@ again. A previously stopped Agent remains stopped. Before replacing runtime
|
||||
files, the installer copies them to a timestamped backup under
|
||||
C:\MinerOpsAgent\backups.
|
||||
|
||||
Runtime sources, installed destinations, active config, and rollback copies are
|
||||
rejected when they are reparse points or have more than one hard link. A normal
|
||||
trusted upgrade creates a fresh random backup directory and never adopts an
|
||||
existing backup destination. During an explicit post-reboot trust reset, old
|
||||
runtime is never copied into backup or restored; fresh package content replaces
|
||||
it while the external blocker remains, and the Agent task stays stopped for
|
||||
credential verification.
|
||||
|
||||
A failed deployment restores managed files, removes tasks created by that
|
||||
failed attempt, and restores the main Agent's prior running state.
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1039
-15
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -148,6 +148,7 @@ def download_windows_agent_package(
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="new_agent",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
@@ -166,6 +167,50 @@ def download_windows_agent_package(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agent-credential-transitions/windows-package")
|
||||
def download_windows_agent_credential_transition_package(
|
||||
package_request: AgentWindowsPackageRequest,
|
||||
http_request: Request,
|
||||
current_user: EnrollmentManager,
|
||||
) -> Response:
|
||||
del current_user
|
||||
|
||||
browser_origin = http_request.headers.get("origin", "").strip()
|
||||
try:
|
||||
hub_url = normalize_hub_url(browser_origin)
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Windows Agent package downloads require "
|
||||
"a valid browser Origin"
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="existing_agent_credential_transition",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return Response(
|
||||
content=package,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/agent/enroll",
|
||||
response_model=AgentEnrollResponse,
|
||||
|
||||
@@ -14,6 +14,7 @@ WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
|
||||
|
||||
WINDOWS_PACKAGE_FILES = (
|
||||
"Enroll-MinerOpsAgent.ps1",
|
||||
"Upgrade-MinerOpsAgentCredential.ps1",
|
||||
"install.ps1",
|
||||
"uninstall.ps1",
|
||||
"MinerOpsAgentService.ps1",
|
||||
@@ -80,6 +81,7 @@ def build_windows_agent_package(
|
||||
*,
|
||||
enrollment_token: str,
|
||||
hub_url: str,
|
||||
workflow: str = "new_agent",
|
||||
source_root: Path | None = None,
|
||||
) -> bytes:
|
||||
token = enrollment_token.strip()
|
||||
@@ -91,6 +93,12 @@ def build_windows_agent_package(
|
||||
|
||||
normalized_hub_url = normalize_hub_url(hub_url)
|
||||
|
||||
if workflow not in {
|
||||
"new_agent",
|
||||
"existing_agent_credential_transition",
|
||||
}:
|
||||
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
|
||||
|
||||
root = (
|
||||
Path(source_root)
|
||||
if source_root is not None
|
||||
@@ -117,18 +125,27 @@ def build_windows_agent_package(
|
||||
enrollment = {
|
||||
"hub_url": normalized_hub_url,
|
||||
"token": token,
|
||||
"workflow": workflow,
|
||||
}
|
||||
|
||||
instructions = """MinerOps Windows Agent
|
||||
if workflow == "new_agent":
|
||||
command = ".\\Enroll-MinerOpsAgent.ps1"
|
||||
description = "enrollment"
|
||||
else:
|
||||
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
|
||||
description = "existing-Agent credential transition"
|
||||
|
||||
instructions = f"""MinerOps Windows Agent
|
||||
|
||||
1. Extract this ZIP on the target Windows machine.
|
||||
2. Open Windows PowerShell as Administrator.
|
||||
3. Change to the extracted MinerOpsAgent-Windows folder.
|
||||
4. Run:
|
||||
|
||||
.\\Enroll-MinerOpsAgent.ps1
|
||||
{command}
|
||||
|
||||
The enrollment credential is short-lived and single-use.
|
||||
This package is for the {description} workflow. The credential is short-lived
|
||||
and single-use. Do not use the other bootstrap script with this package.
|
||||
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||
Delete the ZIP and extracted setup folder after successful installation.
|
||||
"""
|
||||
|
||||
@@ -74,7 +74,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<p id="agentEnrollmentResultCopy">
|
||||
This token is displayed only for this enrollment step.
|
||||
Keep it private. MinerOps does not store the plaintext token.
|
||||
</p>
|
||||
@@ -152,7 +152,9 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementContent" hidden>
|
||||
<div id="agentManagementContent"
|
||||
data-can-manage-agents="{str(can_manage_agents).lower()}"
|
||||
hidden>
|
||||
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Registered Agents</span>
|
||||
@@ -197,6 +199,7 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
<th>Last check-in</th>
|
||||
<th>Credential</th>
|
||||
<th>Routing</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentRows"></tbody>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"agentCreateSubmit",
|
||||
"agentCreateStatus",
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentResultTitle",
|
||||
"agentEnrollmentResultCopy",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentDownload",
|
||||
@@ -48,6 +50,8 @@
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
let packageEndpoint = "/api/agent-enrollments/windows-package";
|
||||
let packageFilename = "MinerOpsAgent-Windows.zip";
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
@@ -143,6 +147,27 @@
|
||||
cell(words(agent.routing_state))
|
||||
);
|
||||
|
||||
const action = document.createElement("td");
|
||||
const canManage =
|
||||
el.agentManagementContent.dataset.canManageAgents === "true";
|
||||
if (
|
||||
canManage &&
|
||||
agent.credential_state === "legacy" &&
|
||||
agent.routing_state === "assigned"
|
||||
) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "quiet-button";
|
||||
button.textContent = "Upgrade credential";
|
||||
button.addEventListener("click", function () {
|
||||
createCredentialTransition(agent);
|
||||
});
|
||||
action.appendChild(button);
|
||||
} else {
|
||||
action.textContent = "—";
|
||||
}
|
||||
row.appendChild(action);
|
||||
|
||||
el.agentRows.appendChild(row);
|
||||
});
|
||||
|
||||
@@ -188,7 +213,10 @@
|
||||
cell(words(enrollment.status)),
|
||||
cell(dateTime(enrollment.created_at)),
|
||||
cell(dateTime(enrollment.expires_at)),
|
||||
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
|
||||
cell(text(
|
||||
enrollment.enrolled_agent_id || enrollment.target_agent_id,
|
||||
"Not enrolled"
|
||||
))
|
||||
);
|
||||
|
||||
el.agentEnrollmentRows.appendChild(row);
|
||||
@@ -338,15 +366,15 @@
|
||||
});
|
||||
|
||||
const enrollment = payload.data;
|
||||
|
||||
el.agentEnrollmentToken.textContent =
|
||||
enrollment.enrollment_token;
|
||||
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(enrollment.expires_at);
|
||||
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
showOneTimeToken({
|
||||
token: enrollment.enrollment_token,
|
||||
expiresAt: enrollment.expires_at,
|
||||
title: "Enrollment created",
|
||||
copy: "This token creates a new Agent identity. Keep it private. " +
|
||||
"MinerOps does not store the plaintext token.",
|
||||
endpoint: "/api/agent-enrollments/windows-package",
|
||||
filename: "MinerOpsAgent-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||
|
||||
el.agentDisplayName.value = "";
|
||||
@@ -360,6 +388,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showOneTimeToken(options) {
|
||||
el.agentEnrollmentToken.textContent = options.token;
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(options.expiresAt);
|
||||
el.agentEnrollmentResultTitle.textContent = options.title;
|
||||
el.agentEnrollmentResultCopy.textContent = options.copy;
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
packageEndpoint = options.endpoint;
|
||||
packageFilename = options.filename;
|
||||
}
|
||||
|
||||
async function createCredentialTransition(agent) {
|
||||
const confirmed = window.confirm(
|
||||
"Create a one-time credential upgrade for " + agent.agent_id +
|
||||
"? Its Agent identity, Site, and Zone will remain unchanged."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-credential-transitions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({agent_id: agent.agent_id})
|
||||
});
|
||||
const transition = payload.data;
|
||||
showOneTimeToken({
|
||||
token: transition.transition_token,
|
||||
expiresAt: transition.expires_at,
|
||||
title: "Existing Agent credential upgrade created",
|
||||
copy: "This token is bound to the existing Agent identity " +
|
||||
transition.target_agent_id + ". It cannot enroll a new " +
|
||||
"Agent or transfer its Zone.",
|
||||
endpoint: "/api/agent-credential-transitions/windows-package",
|
||||
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent =
|
||||
"Credential upgrade created for " + transition.target_agent_id + ".";
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Credential upgrade could not be created.";
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWindowsPackage() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
@@ -375,7 +451,7 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
packageEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -408,7 +484,7 @@
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = "MinerOpsAgent-Windows.zip";
|
||||
link.download = packageFilename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -790,6 +790,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions":
|
||||
return "agents.manage"
|
||||
if path == "/api/agent-credential-transitions/windows-package":
|
||||
return "agents.manage"
|
||||
if repair_parts_request(path):
|
||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||
if path.startswith("/api/admin/"):
|
||||
|
||||
@@ -62,6 +62,7 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
self.assertIn('id="agentSite"', managed)
|
||||
self.assertIn('id="agentZone"', managed)
|
||||
self.assertIn('id="agentDisplayName"', managed)
|
||||
self.assertIn('data-can-manage-agents="true"', managed)
|
||||
self.assertIn("Linux x64 — coming later", managed)
|
||||
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
||||
|
||||
@@ -100,6 +101,11 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
'api("/api/agent-enrollments", {',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'api("/api/agent-credential-transitions", {',
|
||||
source,
|
||||
)
|
||||
self.assertIn("Upgrade credential", source)
|
||||
|
||||
for unsafe in (
|
||||
"innerHTML",
|
||||
@@ -153,9 +159,10 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'link.download = "MinerOpsAgent-Windows.zip"',
|
||||
'packageFilename = "MinerOpsAgent-Windows.zip"',
|
||||
source,
|
||||
)
|
||||
self.assertIn("link.download = packageFilename", source)
|
||||
self.assertIn(
|
||||
"window.URL.revokeObjectURL(downloadUrl)",
|
||||
source,
|
||||
|
||||
@@ -65,6 +65,7 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||
self.assertEqual(
|
||||
enrollment["hub_url"],
|
||||
"https://minerops.example.test",
|
||||
@@ -77,6 +78,50 @@ class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
self.assertNotIn("api3_account", serialized)
|
||||
self.assertNotIn("agent-config.json", "\n".join(names))
|
||||
|
||||
def test_existing_agent_transition_package_is_generic_and_distinct(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.source_tree(root)
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=TOKEN,
|
||||
hub_url="https://minerops.example.test",
|
||||
workflow="existing_agent_credential_transition",
|
||||
source_root=root,
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||
)
|
||||
)
|
||||
instructions = archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||
).decode("utf-8")
|
||||
names = set(archive.namelist())
|
||||
|
||||
self.assertEqual(
|
||||
enrollment,
|
||||
{
|
||||
"hub_url": "https://minerops.example.test",
|
||||
"token": TOKEN,
|
||||
"workflow": "existing_agent_credential_transition",
|
||||
},
|
||||
)
|
||||
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||
self.assertIn(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/Upgrade-MinerOpsAgentCredential.ps1",
|
||||
names,
|
||||
)
|
||||
serialized = json.dumps(enrollment)
|
||||
for forbidden in (
|
||||
"plexus-windows-agent",
|
||||
"site_id",
|
||||
"agent_zone_id",
|
||||
"agent_secret",
|
||||
):
|
||||
self.assertNotIn(forbidden, serialized)
|
||||
|
||||
def test_missing_canonical_source_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
@@ -134,6 +179,13 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST",
|
||||
"/api/agent-credential-transitions/windows-package",
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_view_only_user_cannot_download_package(self):
|
||||
self.authenticate(("agents.view",))
|
||||
@@ -180,6 +232,35 @@ class WindowsPackageRouteTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(enrollment["workflow"], "new_agent")
|
||||
|
||||
def test_manager_downloads_distinct_existing_agent_upgrade_package(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
response = self.client.post(
|
||||
"/api/agent-credential-transitions/windows-package",
|
||||
headers={"Origin": "http://testserver"},
|
||||
json={"token": TOKEN},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
self.assertIn(
|
||||
"MinerOpsAgent-Credential-Upgrade-Windows.zip",
|
||||
response.headers["content-disposition"],
|
||||
)
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(f"{WINDOWS_PACKAGE_ROOT}/enrollment.json")
|
||||
)
|
||||
instructions = archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt"
|
||||
).decode("utf-8")
|
||||
self.assertEqual(
|
||||
enrollment["workflow"],
|
||||
"existing_agent_credential_transition",
|
||||
)
|
||||
self.assertIn("Upgrade-MinerOpsAgentCredential.ps1", instructions)
|
||||
|
||||
def test_package_requires_browser_origin(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_CREDENTIAL_TRANSITION_PROJECT:-minerops-credential-transition-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 160 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
for source_file in \
|
||||
backend/tests/test_agent_credential_transition_postgres.py \
|
||||
agents/windows/Upgrade-MinerOpsAgentCredential.ps1; do
|
||||
permissions="$(stat -c '%a' "${source_file}")"
|
||||
(( (10#${permissions} % 10) & 4 )) || {
|
||||
echo "${source_file} is not readable by the non-root test container" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
"${compose[@]}" build backend
|
||||
expected_head="$("${compose[@]}" run --rm --no-deps backend alembic heads 2>/dev/null | awk 'NR == 1 { print $1 }')"
|
||||
[[ "${expected_head}" == "0020_existing_agent_transition" ]]
|
||||
|
||||
echo "Testing the additive 0019 -> 0020 transition-binding migration..."
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade 0019_agent_legacy_auth_retire
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
|
||||
echo "Testing production-shaped transactional credential transition..."
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
--volume "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
--env MINEROPS_CREDENTIAL_TRANSITION_DISPOSABLE=1 \
|
||||
--env MINEROPS_AGENT_ID=plexus-windows-agent \
|
||||
--env MINEROPS_AGENT_SHARED_SECRET=credential-transition-legacy-secret \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_credential_transition_postgres
|
||||
|
||||
echo "Testing downgrade refusal after transition history exists..."
|
||||
if output="$("${compose[@]}" run --rm --no-deps backend \
|
||||
alembic downgrade 0019_agent_legacy_auth_retire 2>&1)"; then
|
||||
echo "0020 downgrade unexpectedly discarded transition history" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"existing-Agent credential transition history exists"* ]]
|
||||
|
||||
echo "Existing-Agent credential transition checks passed."
|
||||
@@ -37,6 +37,7 @@ echo "=== VERIFY CANONICAL SOURCE IS IN IMAGE ==="
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
backend sh -ec '
|
||||
test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
test -f /app/agents/windows/Upgrade-MinerOpsAgentCredential.ps1
|
||||
test -f /app/agents/windows/install.ps1
|
||||
test -f /app/agents/windows/MinerOpsAgentService.ps1
|
||||
test -f /app/agents/windows/VERSION
|
||||
@@ -52,7 +53,9 @@ docker run --rm \
|
||||
|
||||
foreach ($script in @(
|
||||
"/work/Enroll-MinerOpsAgent.ps1",
|
||||
"/work/install.ps1"
|
||||
"/work/Upgrade-MinerOpsAgentCredential.ps1",
|
||||
"/work/install.ps1",
|
||||
"/work/MinerOpsAgentGUI.ps1"
|
||||
)) {
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
@@ -70,6 +73,8 @@ docker run --rm \
|
||||
}
|
||||
|
||||
& /work/tests/Test-MinerOpsEnrollment.ps1
|
||||
& /work/tests/Test-MinerOpsBootEventParser.ps1
|
||||
& /work/tests/Test-MinerOpsCredentialTransition.ps1
|
||||
& /work/tests/Test-MinerOpsInstaller.ps1
|
||||
'
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ set -euo pipefail
|
||||
service="agents/windows/MinerOpsAgentService.ps1"
|
||||
control="agents/windows/MinerOpsControl.ps1"
|
||||
installer="agents/windows/install.ps1"
|
||||
transition="agents/windows/Upgrade-MinerOpsAgentCredential.ps1"
|
||||
gui="agents/windows/MinerOpsAgentGUI.ps1"
|
||||
rg -q 'X-MinerOps-Agent-ID' "${service}"
|
||||
rg -q 'X-MinerOps-Agent-ID' "${gui}"
|
||||
rg -Fq '$config.agent_id' "${gui}"
|
||||
rg -q 'X-MinerOps-Agent-Secret' "${gui}"
|
||||
rg -Fq '$config.agent_zone_id' "${service}"
|
||||
rg -Fq '$body["agent_zone_id"]' "${service}"
|
||||
rg -q '"agent_zone_id"' agents/windows/config.example.json
|
||||
@@ -28,9 +33,39 @@ rg -q 'could not restart restored MinerOpsAgent' "${installer}"
|
||||
! rg -q 'Register-ScheduledTask.*-Force' "${installer}"
|
||||
! rg -q 'agent_secret|api3_password|api3_account' "${installer}"
|
||||
|
||||
rg -q 'existing_agent_credential_transition' "${transition}"
|
||||
rg -q '/api/agent/credential-transition' "${transition}"
|
||||
rg -q 'RandomNumberGenerator' "${transition}"
|
||||
rg -q 'agent-credential-transition.recovery.json' "${transition}"
|
||||
rg -q 'minerops-credential-transition\.blocked' "${transition}"
|
||||
rg -q 'Assert-MinerOpsTrustedAncestorNamespace' "${transition}"
|
||||
rg -q 'Open-MinerOpsDirectoryLock' "${transition}"
|
||||
rg -q 'Invoke-MinerOpsRecoverableConfigInstall' "${transition}"
|
||||
rg -q 'active-moved-to-prior' "${transition}"
|
||||
rg -q 'pending-moved-to-active' "${transition}"
|
||||
! rg -q '\[IO\.File\]::Replace|Invoke-MinerOpsAtomicConfigReplace' "${transition}"
|
||||
rg -q 'minerops-filesystem-trust\.blocked-' "${installer}"
|
||||
rg -q 'ApproveFilesystemTrustResetAfterReboot' "${installer}"
|
||||
rg -q 'windows-full-boot-event-v1:' "${installer}"
|
||||
rg -q 'windows-full-boot-event-v1:' "${transition}"
|
||||
! rg -q 'LastBootUpTime' "${installer}" "${transition}" \
|
||||
agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
! rg -q 'Remove-MinerOpsInstallerTrustBlocker' "${installer}"
|
||||
! rg -q 'Remove-MinerOpsEnrollmentTrustBlocker' \
|
||||
agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
rg -q 'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence' "${transition}"
|
||||
rg -q 'GetNumberOfLinks' "${installer}"
|
||||
rg -q 'Assert-MinerOpsInstallerTrustedAncestors' "${installer}"
|
||||
! rg -q 'plexus-windows-agent|Plexus Mining Site' "${transition}"
|
||||
rg -q 'Get-MinerOpsVerifiedScheduledTask' "${transition}"
|
||||
! rg -q 'Get-ScheduledTask[^[:cntrl:]]*SilentlyContinue' "${transition}"
|
||||
|
||||
if command -v pwsh >/dev/null 2>&1; then
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsBootEventParser.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsCredentialTransition.ps1
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsNativeHarness.ps1
|
||||
else
|
||||
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user