266 lines
8.5 KiB
PowerShell
266 lines
8.5 KiB
PowerShell
$ErrorActionPreference = "Stop"
|
|
|
|
$root = Split-Path $PSScriptRoot -Parent
|
|
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
|
|
|
|
$tokens = $null
|
|
$parseErrors = $null
|
|
|
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
|
$bootstrap,
|
|
[ref] $tokens,
|
|
[ref] $parseErrors
|
|
) | Out-Null
|
|
|
|
if ($parseErrors.Count -gt 0) {
|
|
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
|
}
|
|
|
|
$source = Get-Content -LiteralPath $bootstrap -Raw
|
|
|
|
function Assert-SourceContains {
|
|
param(
|
|
[Parameter(Mandatory)][string] $Pattern,
|
|
[Parameter(Mandatory)][string] $Message
|
|
)
|
|
|
|
if ($source -notmatch $Pattern) {
|
|
throw $Message
|
|
}
|
|
}
|
|
|
|
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."
|
|
|
|
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
|
|
"API3 password is not prompted as a SecureString."
|
|
|
|
Assert-SourceContains 'agent-config\.json\.pending' `
|
|
"Atomic pending config path is missing."
|
|
|
|
Assert-SourceContains 'agent-config\.recovery\.json' `
|
|
"Post-redemption credential recovery path 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."
|
|
|
|
Assert-SourceContains 'S-1-5-32-544' `
|
|
"Administrators ACL entry is missing."
|
|
|
|
Assert-SourceContains 'SetOwner\(\$administrators\)' `
|
|
"Stable Administrators ownership is missing from credential ACLs."
|
|
|
|
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."
|
|
|
|
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
|
|
"Fresh Agent startup is missing."
|
|
|
|
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'
|
|
)
|
|
|
|
$redeemCall = $source.IndexOf(
|
|
'"$HubUrl/api/agent/enroll"'
|
|
)
|
|
|
|
if (
|
|
$existingConfigGuard -lt 0 -or
|
|
$redeemCall -lt 0 -or
|
|
$existingConfigGuard -gt $redeemCall
|
|
) {
|
|
throw "Existing Agent identity must be rejected before token redemption."
|
|
}
|
|
|
|
$configMove = $source.IndexOf(
|
|
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
|
|
)
|
|
|
|
$installerCall = $source.IndexOf(
|
|
'& $Installer'
|
|
)
|
|
|
|
if (
|
|
$configMove -lt 0 -or
|
|
$installerCall -lt 0 -or
|
|
$configMove -gt $installerCall
|
|
) {
|
|
throw "Permanent Agent credentials must be persisted before installation."
|
|
}
|
|
|
|
foreach ($sensitiveVariable in @(
|
|
'EnrollmentToken',
|
|
'Api3PasswordPlain'
|
|
)) {
|
|
if (
|
|
$source -match (
|
|
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
|
|
$sensitiveVariable
|
|
)
|
|
) {
|
|
throw "$sensitiveVariable must not be written to output."
|
|
}
|
|
}
|
|
|
|
Write-Host "MinerOps enrollment bootstrap source safety tests passed."
|