287 lines
11 KiB
PowerShell
287 lines
11 KiB
PowerShell
$ErrorActionPreference = "Stop"
|
|
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
|
|
$tokens = $null
|
|
$parseErrors = $null
|
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
|
$installer, [ref]$tokens, [ref]$parseErrors
|
|
) | Out-Null
|
|
if ($parseErrors.Count -gt 0) {
|
|
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
|
}
|
|
|
|
$source = Get-Content -LiteralPath $installer -Raw
|
|
function Assert-SourceContains {
|
|
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
|
|
if ($source -notmatch $Pattern) { throw $Message }
|
|
}
|
|
|
|
$runtimeMatch = [regex]::Match(
|
|
$source,
|
|
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
|
|
)
|
|
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
|
|
$runtimeFiles = $runtimeMatch.Groups["files"].Value
|
|
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
|
|
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
|
|
throw "$forbidden must not be a managed deployment target."
|
|
}
|
|
}
|
|
|
|
$preflight = $source.IndexOf('# Validate the complete source')
|
|
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
|
|
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
|
throw "Source preflight must happen before target changes."
|
|
}
|
|
|
|
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."
|
|
Assert-SourceContains 'Test-MinerOpsInstallerShouldCreateMissingTask' `
|
|
"Missing-task trust-reset policy helper is absent."
|
|
Assert-SourceContains 'Deferring missing per-user Scheduled Task' `
|
|
"Missing per-user task deferral is not explicit."
|
|
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
|
"Fresh enrichment task arguments are missing."
|
|
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
|
"Bounded main Agent stop behavior is missing."
|
|
Assert-SourceContains '\$mainTaskWasRunning' `
|
|
"Main Agent prior running state is not represented."
|
|
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
|
|
"Rollback does not restore managed files from backup."
|
|
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."
|
|
|
|
$taskDefinitionsStart = $source.IndexOf('$definitions = @(')
|
|
$taskLoopStart = $source.IndexOf(
|
|
'foreach ($definition in $definitions)', $taskDefinitionsStart
|
|
)
|
|
if ($taskDefinitionsStart -lt 0 -or $taskLoopStart -le $taskDefinitionsStart) {
|
|
throw "Scheduled Task definitions or reconciliation loop were not found."
|
|
}
|
|
$taskDefinitions = $source.Substring(
|
|
$taskDefinitionsStart, $taskLoopStart - $taskDefinitionsStart
|
|
)
|
|
foreach ($policy in @(
|
|
@{ Name = "MinerOpsAgent"; Expected = '\$true' },
|
|
@{ Name = "MinerOpsAgentTray"; Expected = '\$false' },
|
|
@{ Name = "MinerOpsApi3Enrichment"; Expected = '\$true' },
|
|
@{ Name = "MinerOpsPoolPoller"; Expected = '\$true' }
|
|
)) {
|
|
$definition = [regex]::Match(
|
|
$taskDefinitions,
|
|
('(?s)@\{{.*?Name\s*=\s*"{0}".*?\}}' -f
|
|
[regex]::Escape($policy.Name))
|
|
).Value
|
|
if ([string]::IsNullOrWhiteSpace($definition) -or
|
|
$definition -notmatch (
|
|
'CreateWhenMissingDuringTrustReset\s*=\s*{0}' -f
|
|
$policy.Expected
|
|
)) {
|
|
throw "Scheduled Task $($policy.Name) has the wrong trust-reset creation policy."
|
|
}
|
|
}
|
|
|
|
$taskLoopEnd = $source.IndexOf(
|
|
'if ($mainTaskWasRunning', $taskLoopStart
|
|
)
|
|
if ($taskLoopEnd -le $taskLoopStart) {
|
|
throw "Scheduled Task reconciliation loop end was not found."
|
|
}
|
|
$taskLoop = $source.Substring($taskLoopStart, $taskLoopEnd - $taskLoopStart)
|
|
$preserveIndex = $taskLoop.IndexOf('$null -ne $existingTask')
|
|
$deferIndex = $taskLoop.IndexOf(
|
|
'Test-MinerOpsInstallerShouldCreateMissingTask'
|
|
)
|
|
$actionIndex = $taskLoop.IndexOf('New-ScheduledTaskAction')
|
|
if (
|
|
$preserveIndex -lt 0 -or
|
|
$deferIndex -le $preserveIndex -or
|
|
$actionIndex -le $deferIndex
|
|
) {
|
|
throw "Existing-task preservation and missing-task deferral are ordered unsafely."
|
|
}
|
|
if ($taskLoop -match '80070534|No mapping between account names') {
|
|
throw "Missing per-user task handling must be policy-based, not HRESULT-based."
|
|
}
|
|
|
|
$taskPolicyFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function Test-MinerOpsInstallerShouldCreateMissingTask \{.*?\n\}'
|
|
).Value
|
|
if ([string]::IsNullOrWhiteSpace($taskPolicyFunction)) {
|
|
throw "Missing-task trust-reset policy function source was not found."
|
|
}
|
|
Invoke-Expression $taskPolicyFunction
|
|
$trayPolicy = [pscustomobject]@{
|
|
CreateWhenMissingDuringTrustReset = $false
|
|
}
|
|
$systemTaskPolicy = [pscustomobject]@{
|
|
CreateWhenMissingDuringTrustReset = $true
|
|
}
|
|
if (Test-MinerOpsInstallerShouldCreateMissingTask `
|
|
-Definition $trayPolicy -TrustResetMode $true) {
|
|
throw "Trust reset would create a missing per-user tray task."
|
|
}
|
|
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
|
-Definition $trayPolicy -TrustResetMode $false)) {
|
|
throw "Normal interactive installation would not repair a missing tray task."
|
|
}
|
|
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
|
-Definition $systemTaskPolicy -TrustResetMode $true)) {
|
|
throw "Trust reset would skip a missing explicit-SYSTEM task."
|
|
}
|
|
$missingPolicyRejected = $false
|
|
try {
|
|
[void](Test-MinerOpsInstallerShouldCreateMissingTask `
|
|
-Definition ([pscustomobject]@{}) -TrustResetMode $true)
|
|
}
|
|
catch {
|
|
$missingPolicyRejected = $_.Exception.Message -match
|
|
'trust-reset creation policy is missing'
|
|
}
|
|
if (-not $missingPolicyRejected) {
|
|
throw "Trust reset accepted a task definition without explicit policy."
|
|
}
|
|
|
|
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."
|
|
}
|
|
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
|
|
throw "Installer must not inspect or print credentials."
|
|
}
|
|
|
|
Write-Host "MinerOps installer source safety tests passed."
|