mirror of https://github.com/raandree/NTFSSecurity
Browse Source
Tests\Lab\Acceptance gets the scripts of Decision 24, which extends the acceptance of the live tests from one lab to several operating-system builds: - Deploy-OsMatrixLab.ps1, Add-OsMatrixMachine.ps1, Complete-OsMatrixLab.ps1 and Repair-OsMatrixBoot.ps1 build NtfsSecurityOsMatrixLab with AutomatedLab (Server 2019, 2022 and 2025 file servers, Windows 11 clients) and repair a base image whose host-side bcdboot left an empty EFI system partition. - Test-MatrixReadiness.ps1 and Test-MatrixCleanup.ps1 gate each run and prove that it left nothing behind (fixture accounts, shares, folders, group members, orphaned SIDs, profiles); -Mode Repair removes what is left. - Run-MatrixSequence.ps1 runs the live controller for every cell of the matrix and stops after an infrastructure failure. - Run-MatrixLocalSuite.ps1 and Invoke-LocalSuite.ps1 run the module's own Pester suite on each machine in both editions, elevated and as a basic user, as scheduled tasks: a process started from a remoting session gets every privilege enabled, which eight of the tests do not expect. - Export-MatrixResults.ps1 collates the cells, the skipped tests and the package hashes into the results table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant <ai@example.com>pull/119/head
10 changed files with 1111 additions and 0 deletions
@ -0,0 +1,123 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $Name, |
|||
[Parameter(Mandatory)] [string] $OperatingSystem, |
|||
[Parameter(Mandatory)] [string] $IpAddress, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[ValidateRange(2, 16)] [int] $MemoryGB = 4, |
|||
[ValidateRange(1, 8)] [int] $Processors = 2, |
|||
[ValidateRange(5, 240)] [int] $StartTimeoutMinutes = 40, |
|||
[string] $BackupRoot = 'C:\ProgramData\AutomatedLab\Backups' |
|||
) |
|||
|
|||
# Adds one machine to the already deployed matrix lab (Decision 24) and creates only that machine. AutomatedLab 5.61 has no supported way to |
|||
# extend a deployed lab: Add-LabMachineDefinition refuses while a lab is imported or exported, and Install-Lab creates every machine of the |
|||
# lab again. This script copies the lab metadata first (the copy is readable by administrators only, because the files hold the lab |
|||
# credentials), reloads the definition with Import-LabDefinition (never Import-Lab), adds the machine, exports the definition, and then runs |
|||
# the same steps Install-Lab runs for a single machine: base image, hosts entries, virtual machine, start. The other machines are neither |
|||
# created, started, nor changed. Windows PowerShell 5.1 on the host; run it elevated. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START add-os-matrix-machine lab=$LabName name=$Name os='$OperatingSystem' ip=$IpAddress" | Set-Content -LiteralPath $LogPath |
|||
$lockPath = $null |
|||
try { |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
if ((Get-Lab -List) -notcontains $LabName) { throw "The lab '$LabName' does not exist." } |
|||
if (Get-VM -Name $Name -ErrorAction SilentlyContinue) { throw "A virtual machine named '$Name' exists already." } |
|||
$hostsText = Get-Content -LiteralPath (Join-Path -Path $env:SystemRoot -ChildPath 'System32\drivers\etc\hosts') -Raw |
|||
if ($hostsText -match ('(?im)^\s*[^#\s]+\s+{0}(\.|\s|$)' -f [regex]::Escape($Name)) -or $hostsText -match ('(?im)^\s*{0}\s' -f [regex]::Escape($IpAddress))) { |
|||
throw "The hosts file mentions '$Name' or $IpAddress already." |
|||
} |
|||
|
|||
$labFolder = Join-Path -Path (Get-LabConfigurationItem -Name LabAppDataRoot) -ChildPath "Labs\$LabName" |
|||
$backup = Join-Path -Path $BackupRoot -ChildPath ('{0}-{1:yyyyMMdd-HHmmss}' -f $LabName, [DateTime]::UtcNow) |
|||
$null = New-Item -ItemType Directory -Path $backup -Force |
|||
$null = & icacls.exe $backup /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' |
|||
if ($LASTEXITCODE -ne 0) { throw "icacls failed on the backup folder (exit code $LASTEXITCODE)." } |
|||
Copy-Item -LiteralPath $labFolder -Destination $backup -Recurse |
|||
Write-Step "lab metadata copied to $backup" |
|||
|
|||
Import-LabDefinition -Name $LabName |
|||
$definition = Get-LabDefinition |
|||
$before = @(Get-LabMachineDefinition | ForEach-Object -Process { $_.Name }) |
|||
$domainName = $definition.Domains[0].Name |
|||
$rootDc = Get-LabMachineDefinition | Where-Object -FilterScript { 'RootDC' -in $_.Roles.Name } | Select-Object -First 1 |
|||
$dcAddress = ($rootDc.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString |
|||
$dcPrefix = ($dcAddress -split '\.')[0..2] -join '.' |
|||
$newPrefix = ($IpAddress -split '\.')[0..2] -join '.' |
|||
if ($dcPrefix -ne $newPrefix) { throw "$IpAddress isn't in the /24 of the domain controller ($dcAddress)." } |
|||
Write-Step ("definition loaded: domain {0}; machines {1}; installation account {2}" -f $domainName, ($before -join ','), $definition.DefaultInstallationCredential.UserName) |
|||
|
|||
$parameters = @{ |
|||
Name = $Name; DomainName = $domainName; OperatingSystem = $OperatingSystem; Memory = ($MemoryGB * 1GB) |
|||
Processors = $Processors; Network = $LabName; IpAddress = $IpAddress |
|||
} |
|||
if ($OperatingSystem -like 'Windows 11*') { |
|||
$parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' } |
|||
} |
|||
|
|||
Add-LabMachineDefinition @parameters |
|||
Export-LabDefinition -Force -ExportDefaultUnattendedXml |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$after = @(Get-LabVM -IncludeLinux | ForEach-Object -Process { $_.Name }) |
|||
$difference = @(Compare-Object -ReferenceObject ($before + $Name) -DifferenceObject $after) |
|||
if ($difference.Count -gt 0) { throw "The exported lab doesn't hold exactly the old machines plus $Name. Restore the metadata from $backup." } |
|||
Write-Step 'definition extended and exported' |
|||
|
|||
$lockPath = Get-LabConfigurationItem -Name DiskDeploymentInProgressPath |
|||
if (Test-Path -LiteralPath $lockPath) { throw "Another lab disk deployment seems to be in progress ($lockPath)." } |
|||
$null = New-Item -Path $lockPath -ItemType File -Value $LabName |
|||
New-LabBaseImages |
|||
Write-Step 'base images ready' |
|||
|
|||
$machine = Get-LabVM -ComputerName $Name |
|||
$address = ($machine.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString |
|||
$null = Add-HostEntry -HostName $machine.Name -IpAddress $address -Section $LabName |
|||
$null = Add-HostEntry -HostName $machine.FQDN -IpAddress $address -Section $LabName |
|||
New-LabVM -Name $Name |
|||
Set-LabDefinition -Machines (Get-Lab).Machines |
|||
Export-LabDefinition -Force -ExportDefaultUnattendedXml -Silent |
|||
Write-Step 'virtual machine created and definition exported' |
|||
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue |
|||
$lockPath = $null |
|||
|
|||
Start-LabVM -ComputerName $Name -ProgressIndicator 30 -TimeoutInMinutes $StartTimeoutMinutes -Wait |
|||
Write-Step 'machine started and reachable with the lab credentials' |
|||
|
|||
$userName = (Get-Lab).DefaultInstallationCredential.UserName |
|||
Invoke-LabCommand -ActivityName 'Setting PasswordNeverExpires for local deployment accounts' -ComputerName $Name -NoDisplay -Variable (Get-Variable -Name userName) -ScriptBlock { |
|||
Get-CimInstance -Query "Select * from Win32_UserAccount where name = '$userName' and localaccount='true'" | Set-CimInstance -Property @{ PasswordExpires = $false } |
|||
} |
|||
|
|||
$evidence = Invoke-LabCommand -ComputerName $Name -ActivityName 'Readiness of the new member' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $domainName -ScriptBlock { |
|||
param ($Domain) |
|||
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
[pscustomobject]@{ |
|||
Build = '{0}.{1}' -f $current.CurrentBuildNumber, $current.UBR |
|||
Product = $current.ProductName |
|||
Domain = (Get-CimInstance -ClassName Win32_ComputerSystem).Domain |
|||
SecureChannel = [bool] (Test-ComputerSecureChannel) |
|||
Verify = (@(& nltest.exe "/sc_verify:$Domain" 2>&1) -join ' | ') |
|||
} |
|||
} |
|||
Write-Step ('new member: build {0} ({1}); domain {2}; secure channel {3}; nltest: {4}' -f $evidence.Build, $evidence.Product, $evidence.Domain, $evidence.SecureChannel, $evidence.Verify) |
|||
if (-not $evidence.SecureChannel) { throw "The secure channel of $Name is broken." } |
|||
|
|||
Write-Step 'add-os-matrix-machine-DONE' |
|||
exit 0 |
|||
} |
|||
catch { |
|||
Write-Step ('add-os-matrix-machine-FAILED: {0}' -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
exit 1 |
|||
} |
|||
finally { |
|||
if ($lockPath) { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string[]] $Member = @('OSDC1', 'OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E'), |
|||
[string[]] $LocalCredentialMember = @(), |
|||
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', |
|||
[string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi' |
|||
) |
|||
|
|||
# Finishes machines of the matrix lab after a deployment that stopped in AutomatedLab's file server step (a job that never completed |
|||
# although its remote side was idle) or after Add-OsMatrixMachine.ps1: detaches the installation ISO from the file servers, installs |
|||
# PowerShell 7 from the MSI of the host, and copies Pester 5.7.1 into the module folders of both editions. It uses no AutomatedLab job |
|||
# (no -AsJob), only synchronous remoting. A member in -LocalCredentialMember is reached with the local installation account through a |
|||
# session, for a machine whose secure channel to the domain controller fails. Windows PowerShell 5.1 on the host; run it elevated. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$LocalCredentialMember = @($LocalCredentialMember | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$installBlock = { |
|||
param ($Msi) |
|||
$msiPath = Join-Path -Path 'C:\Windows\Temp' -ChildPath $Msi |
|||
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f $msiPath), '/quiet', '/norestart', 'ADD_PATH=1', '/l*v', 'C:\Windows\Temp\pwsh-install.log') -Wait -PassThru |
|||
$pwsh = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' |
|||
[pscustomobject]@{ ExitCode = $process.ExitCode; Pwsh = $(if (Test-Path -LiteralPath $pwsh) { (Get-Item -LiteralPath $pwsh).VersionInfo.ProductVersion } else { 'missing' }) } |
|||
} |
|||
$createBlock = { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } |
|||
$checkBlock = { param ($Path) '{0}: {1}' -f $env:COMPUTERNAME, (Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath '5.7.1\Pester.psd1')) } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START complete-os-matrix-lab lab=$LabName members=$($Member -join ',') localCredential=$($LocalCredentialMember -join ',')" | Set-Content -LiteralPath $LogPath |
|||
try { |
|||
foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } } |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$fileServers = @($Member | Where-Object -FilterScript { $_ -like 'OSFile*' -and $_ -notin $LocalCredentialMember }) |
|||
if ($fileServers) { |
|||
Dismount-LabIsoImage -ComputerName $fileServers -SupressOutput |
|||
Write-Step "installation ISO detached from $($fileServers -join ',')" |
|||
} |
|||
|
|||
$msiName = Split-Path -Path $PowerShell7Msi -Leaf |
|||
$domainMembers = @($Member | Where-Object -FilterScript { $_ -notin $LocalCredentialMember }) |
|||
foreach ($name in $Member) { |
|||
if ($name -in $LocalCredentialMember) { |
|||
$session = New-LabPSSession -ComputerName $name -UseLocalCredential |
|||
try { |
|||
Copy-Item -LiteralPath $PowerShell7Msi -Destination 'C:\Windows\Temp\' -ToSession $session -Force |
|||
$outcome = Invoke-Command -Session $session -ScriptBlock $installBlock -ArgumentList $msiName |
|||
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { |
|||
$destination = Join-Path -Path $modulesRoot -ChildPath 'Pester' |
|||
Invoke-Command -Session $session -ScriptBlock $createBlock -ArgumentList $destination |
|||
Copy-Item -LiteralPath $PesterModulePath -Destination $destination -ToSession $session -Recurse -Force |
|||
Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (Invoke-Command -Session $session -ScriptBlock $checkBlock -ArgumentList $destination)) |
|||
} |
|||
} |
|||
finally { |
|||
Remove-PSSession -Session $session -ErrorAction SilentlyContinue |
|||
} |
|||
} |
|||
else { |
|||
Copy-LabFileItem -Path $PowerShell7Msi -ComputerName $name -DestinationFolderPath 'C:\Windows\Temp' |
|||
$outcome = Invoke-LabCommand -ComputerName $name -ActivityName "Install PowerShell 7 on $name" -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $msiName -ScriptBlock $installBlock |
|||
} |
|||
|
|||
Write-Step ("PowerShell 7 on {0}: msiexec exit code {1}; pwsh {2}" -f $name, $outcome.ExitCode, $outcome.Pwsh) |
|||
if ($outcome.ExitCode -notin 0, 3010 -or $outcome.Pwsh -eq 'missing') { throw "PowerShell 7 isn't installed on $name (exit code $($outcome.ExitCode))." } |
|||
} |
|||
|
|||
if ($domainMembers) { |
|||
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { |
|||
$destination = Join-Path -Path $modulesRoot -ChildPath 'Pester' |
|||
Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Create the Pester module directory' -NoDisplay -ErrorAction Stop -ArgumentList $destination -ScriptBlock $createBlock |
|||
Copy-LabFileItem -Path $PesterModulePath -ComputerName $domainMembers -DestinationFolderPath $destination -Recurse |
|||
$found = Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Check Pester' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $destination -ScriptBlock $checkBlock |
|||
Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (@($found) -join '; ')) |
|||
if (@($found | Where-Object -FilterScript { $_ -notmatch ': True$' }).Count -gt 0) { throw "Pester 5.7.1 isn't in $destination on every member." } |
|||
} |
|||
} |
|||
|
|||
Write-Step 'complete-os-matrix-lab-DONE' |
|||
exit 0 |
|||
} |
|||
catch { |
|||
Write-Step ("complete-os-matrix-lab-FAILED: {0}" -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
exit 1 |
|||
} |
|||
@ -0,0 +1,96 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $DomainName = 'osmatrix.net', |
|||
[string] $VmPath = 'V:\AutomatedLab-VMs', |
|||
[string] $AddressSpace = '192.168.12.0/24', |
|||
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', |
|||
[string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi' |
|||
) |
|||
|
|||
# Deploys an isolated AutomatedLab lab for the NTFSSecurity operating-system matrix (Decision 24): one domain controller, three file |
|||
# servers (Server 2019, 2022, 2025), and a Windows 11 client, in a domain and on a switch of their own. It touches none of the |
|||
# existing labs, machines, switches, or domains, never calls Remove-Lab, and refuses to run when the lab or a machine name exists. |
|||
# The installation password is generated here, kept in memory, and stored only where AutomatedLab stores it for every lab. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START deploy-os-matrix-lab lab=$LabName" | Set-Content -LiteralPath $LogPath |
|||
try { |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } } |
|||
|
|||
$machines = @( |
|||
@{ Name = 'OSDC1'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('RootDC'); Memory = 4GB; Address = '192.168.12.10' } |
|||
@{ Name = 'OSFile25'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.25' } |
|||
@{ Name = 'OSFile22'; Os = 'Windows Server 2022 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.22' } |
|||
@{ Name = 'OSFile19'; Os = 'Windows Server 2019 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.19' } |
|||
@{ Name = 'OSWin11'; Os = 'Windows 11 Pro'; Roles = @(); Memory = 4GB; Address = '192.168.12.11' } |
|||
) |
|||
|
|||
# Collision checks from AutomatedLab metadata and from Hyper-V; the existing labs are only read. |
|||
$existingNames = New-Object System.Collections.Generic.List[string] |
|||
$labs = @(Get-Lab -List) |
|||
if ($labs -contains $LabName) { throw "The lab '$LabName' exists already. Refusing to redefine it." } |
|||
foreach ($existing in $labs) { |
|||
Import-Lab -Name $existing -NoValidation -NoDisplay -ErrorAction Stop |
|||
foreach ($vm in Get-LabVM -IncludeLinux) { $existingNames.Add($vm.Name) } |
|||
} |
|||
foreach ($vm in Get-VM) { $existingNames.Add($vm.Name) } |
|||
$collisions = @($machines.Name | Where-Object { $_ -in $existingNames }) |
|||
if ($collisions) { throw "Machine name collision: $($collisions -join ', ')" } |
|||
if (Get-VMSwitch -Name $LabName -ErrorAction SilentlyContinue) { throw "A virtual switch named '$LabName' exists already." } |
|||
$usedAddresses = @(Get-NetIPAddress -AddressFamily IPv4 | ForEach-Object { $_.IPAddress }) |
|||
if ($usedAddresses | Where-Object { $_ -like '192.168.12.*' }) { throw 'The address space 192.168.12.0/24 is in use on the host.' } |
|||
Write-Step ('preflight ok; existing labs: {0}; existing machine names: {1}' -f ($labs -join ', '), $existingNames.Count) |
|||
|
|||
$characters = ([char[]](48..57) + [char[]](65..90) + [char[]](97..122) + '!', '#', '%', '+', '-', '=') |
|||
$password = -join (1..24 | ForEach-Object { $characters | Get-Random }) |
|||
$password = 'Aa1!' + $password |
|||
|
|||
New-LabDefinition -Name $LabName -DefaultVirtualizationEngine HyperV -VmPath $VmPath |
|||
Add-LabVirtualNetworkDefinition -Name $LabName -AddressSpace $AddressSpace |
|||
Add-LabDomainDefinition -Name $DomainName -AdminUser 'install' -AdminPassword $password |
|||
Set-LabInstallationCredential -Username 'install' -Password $password |
|||
foreach ($definition in $machines) { |
|||
$parameters = @{ |
|||
Name = $definition.Name; DomainName = $DomainName; OperatingSystem = $definition.Os; Memory = $definition.Memory |
|||
Processors = 2; Network = $LabName; IpAddress = $definition.Address |
|||
} |
|||
if ($definition.Roles.Count -gt 0) { $parameters.Roles = $definition.Roles } |
|||
if ($definition.Os -like 'Windows 11*') { |
|||
$parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' } |
|||
} |
|||
Add-LabMachineDefinition @parameters |
|||
} |
|||
Write-Step 'lab defined; installing network switches and base images' |
|||
|
|||
Install-Lab -NetworkSwitches -BaseImages |
|||
Write-Step 'network switches and base images done' |
|||
Install-Lab |
|||
Write-Step 'machines, domain, and roles done' |
|||
|
|||
$labMachines = Get-LabVM |
|||
Install-LabSoftwarePackage -ComputerName $labMachines -Path $PowerShell7Msi -CommandLine '/quiet /norestart ADD_PATH=1' -Timeout 30 |
|||
Write-Step 'PowerShell 7 installed' |
|||
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { |
|||
$destination = Join-Path $modulesRoot 'Pester' |
|||
Invoke-LabCommand -ComputerName $labMachines -ActivityName 'Create the Pester module directory' -ScriptBlock { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } -ArgumentList $destination -NoDisplay |
|||
Copy-LabFileItem -Path $PesterModulePath -ComputerName $labMachines -DestinationFolderPath $destination -Recurse |
|||
} |
|||
Write-Step 'Pester 5.7.1 copied' |
|||
Show-LabDeploymentSummary -Summary |
|||
Write-Step "deploy-os-matrix-lab-DONE" |
|||
exit 0 |
|||
} |
|||
catch { |
|||
Write-Step ("deploy-os-matrix-lab-FAILED: {0}" -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
exit 1 |
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $OutputPrefix, |
|||
[string] $MatrixRoot, |
|||
[string] $Label, |
|||
[string[]] $LocalSuiteFolder = @(), |
|||
[string] $ReferenceMachine = 'LOCAL' |
|||
) |
|||
|
|||
# Turns the raw results of the operating-system matrix (Decision 24) into the tables of the acceptance record, in Windows PowerShell 5.1. |
|||
# -MatrixRoot and -Label name the sequences of Run-MatrixSequence.ps1 (folders <Label>-<file server>): per cell, edition, and role the |
|||
# counts, the skipped tests, and the operating systems of the readiness log. -LocalSuiteFolder lists the result folders of |
|||
# Run-MatrixLocalSuite.ps1 (folders <label>-<machine> with one JSON file per run): per machine, mode, and edition the counts, and the |
|||
# difference of the skipped tests to the reference machine (a skipped test that only one side skips is a difference). Every input file is |
|||
# listed with its SHA-256 in <OutputPrefix>-hashes.csv. Nothing is changed in the lab. |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$LocalSuiteFolder = @($LocalSuiteFolder | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$hashRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
function Add-Hash { param ([string] $Path) $hashRows.Add([pscustomobject]@{ File = $Path; Sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash; Bytes = (Get-Item -LiteralPath $Path).Length }) } |
|||
function Get-Multiset { param ([string[]] $Name) $set = @{}; foreach ($item in @($Name | Where-Object -FilterScript { $_ })) { $set[$item] = 1 + [int] $set[$item] }; $set } |
|||
function Get-Excess { |
|||
param ([hashtable] $Left, [hashtable] $Right) |
|||
foreach ($key in ($Left.Keys | Sort-Object)) { $extra = [int] $Left[$key] - [int] $Right[$key]; if ($extra -gt 0) { '{0} (x{1})' -f $key, $extra } } |
|||
} |
|||
|
|||
if ($MatrixRoot) { |
|||
$cellRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
$skipRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
foreach ($folder in (Get-ChildItem -LiteralPath $MatrixRoot -Directory -Filter "$Label-*" | Sort-Object -Property Name)) { |
|||
$server = $folder.Name.Substring($Label.Length + 1) |
|||
$readiness = Join-Path -Path $folder.FullName -ChildPath 'readiness.log' |
|||
$operatingSystems = @{} |
|||
foreach ($match in (Select-String -LiteralPath $readiness -Pattern '^(\S+)\s+wsman=ok .* os=(.+?) type=(\d)')) { |
|||
$groups = $match.Matches[0].Groups |
|||
$operatingSystems[$groups[1].Value] = '{0} ({1})' -f ($groups[2].Value -replace '^Microsoft ', ''), $(if ($groups[3].Value -eq '1') { 'client' } else { 'server' }) |
|||
} |
|||
|
|||
$clientName = @($operatingSystems.Keys | Where-Object -FilterScript { $operatingSystems[$_] -like '*client*' })[0] |
|||
$counts = Import-Csv -LiteralPath (Join-Path -Path $folder.FullName -ChildPath "$Label-$server-counts.csv") |
|||
foreach ($row in $counts) { |
|||
$cellRows.Add([pscustomobject]@{ |
|||
FileServer = $server; FileServerOs = $operatingSystems[$server]; Client = $clientName; ClientOs = $operatingSystems[$clientName] |
|||
Edition = $row.Edition; Role = $row.Role; Account = $row.Account; ExitCode = $row.ExitCode; Passed = $row.Passed; Failed = $row.Failed; Skipped = $row.Skipped |
|||
}) |
|||
} |
|||
|
|||
foreach ($test in (Import-Csv -LiteralPath (Join-Path -Path $folder.FullName -ChildPath "$Label-$server-tests.csv") | Where-Object -FilterScript { $_.Result -ne 'Passed' })) { |
|||
$skipRows.Add([pscustomobject]@{ FileServer = $server; Edition = $test.Edition; Role = $test.Role; Result = $test.Result; Test = $test.Test; Message = $test.Message }) |
|||
} |
|||
|
|||
foreach ($name in "$Label-$server-counts.csv", "$Label-$server-tests.csv", "$Label-$server-failures.csv", 'readiness.log', 'validation.log', 'run.log', 'fixture-sids.json', 'cleanup-1-snapshot.log', 'cleanup-2-remove.log', 'cleanup-3-verify.log') { |
|||
$path = Join-Path -Path $folder.FullName -ChildPath $name |
|||
if (Test-Path -LiteralPath $path) { Add-Hash -Path $path } |
|||
} |
|||
|
|||
foreach ($summary in (Get-ChildItem -LiteralPath (Join-Path -Path $folder.FullName -ChildPath 'Results') -Filter 'Summary.json' -Recurse -ErrorAction SilentlyContinue)) { Add-Hash -Path $summary.FullName } |
|||
} |
|||
|
|||
$cellRows | Export-Csv -LiteralPath "$OutputPrefix-cells.csv" -NoTypeInformation |
|||
$skipRows | Export-Csv -LiteralPath "$OutputPrefix-cells-skipped.csv" -NoTypeInformation |
|||
'{0} role rows and {1} tests that did not pass, in {2} cell(s)' -f $cellRows.Count, $skipRows.Count, @($cellRows | Select-Object -ExpandProperty FileServer -Unique).Count |
|||
} |
|||
|
|||
if ($LocalSuiteFolder) { |
|||
$runs = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
foreach ($folder in $LocalSuiteFolder) { |
|||
$machine = ((Split-Path -Path $folder -Leaf) -split '-', 2)[1] |
|||
foreach ($json in (Get-ChildItem -LiteralPath $folder -Filter '*.json' | Sort-Object -Property Name)) { |
|||
$summary = Get-Content -LiteralPath $json.FullName -Raw | ConvertFrom-Json |
|||
Add-Hash -Path $json.FullName |
|||
$log = [IO.Path]::ChangeExtension($json.FullName, '.log') |
|||
if (Test-Path -LiteralPath $log) { Add-Hash -Path $log } |
|||
$runs.Add([pscustomobject]@{ |
|||
Machine = $machine; Os = $summary.Os; Mode = $(if ($summary.Elevated) { 'Elevated' } else { 'Basic' }); Edition = $summary.Edition; PowerShell = $summary.PowerShell |
|||
Result = $summary.Result; Passed = $summary.Passed; Failed = $summary.Failed; Skipped = $summary.Skipped; Total = $summary.Total; Seconds = $summary.Seconds |
|||
SkippedTests = @($summary.SkippedTests | Where-Object -FilterScript { $_ }); FailedTests = @($summary.FailedTests | Where-Object -FilterScript { $_ }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
$runs | Select-Object -Property Machine, Os, Mode, Edition, PowerShell, Result, Passed, Failed, Skipped, Total, Seconds | Sort-Object -Property Machine, Mode, Edition | |
|||
Export-Csv -LiteralPath "$OutputPrefix-localsuite.csv" -NoTypeInformation |
|||
$differences = foreach ($run in ($runs | Where-Object -FilterScript { $_.Machine -ne $ReferenceMachine })) { |
|||
$reference = $runs | Where-Object -FilterScript { $_.Machine -eq $ReferenceMachine -and $_.Mode -eq $run.Mode -and $_.Edition -eq $run.Edition } | Select-Object -First 1 |
|||
if (-not $reference) { continue } |
|||
$mine = Get-Multiset -Name $run.SkippedTests |
|||
$theirs = Get-Multiset -Name $reference.SkippedTests |
|||
[pscustomobject]@{ |
|||
Machine = $run.Machine; Mode = $run.Mode; Edition = $run.Edition; Skipped = $run.Skipped; ReferenceSkipped = $reference.Skipped |
|||
OnlyOnMachine = (@(Get-Excess -Left $mine -Right $theirs) -join ' | '); OnlyOnReference = (@(Get-Excess -Left $theirs -Right $mine) -join ' | ') |
|||
Failed = $run.Failed; FailedTests = ($run.FailedTests -join ' | ') |
|||
} |
|||
} |
|||
|
|||
$differences | Sort-Object -Property Machine, Mode, Edition | Export-Csv -LiteralPath "$OutputPrefix-localsuite-skipdiff.csv" -NoTypeInformation |
|||
'{0} local-suite run(s) of {1} machine(s)' -f $runs.Count, @($runs | Select-Object -ExpandProperty Machine -Unique).Count |
|||
} |
|||
|
|||
$hashRows | Export-Csv -LiteralPath "$OutputPrefix-hashes.csv" -NoTypeInformation |
|||
'{0} input file(s) hashed' -f $hashRows.Count |
|||
@ -0,0 +1,66 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $Root, |
|||
[Parameter(Mandatory)] [string] $OutDir, |
|||
[string] $PesterVersion = '5.7.1', |
|||
[string] $PesterModulePath |
|||
) |
|||
|
|||
# Runs the Pester files of <Root>\Tests in this process, Windows PowerShell 5.1 or PowerShell 7, against the module in |
|||
# <Root>\NTFSSecurity\bin\Release, like .github\scripts\Invoke-Tests.ps1 does for the repository. It writes the log, the NUnit result, a JSON |
|||
# summary, and an exit code file to <OutDir>. Run it in a new process for every edition. The tests keep to their own sandbox folders |
|||
# below $env:TEMP. |
|||
$ErrorActionPreference = 'Stop' |
|||
$null = New-Item -ItemType Directory -Path $OutDir -Force |
|||
$log = Join-Path -Path $OutDir -ChildPath "$Label.log" |
|||
$script:exitCode = 1 |
|||
if ($PSVersionTable.PSEdition -eq 'Desktop') { |
|||
# A Windows PowerShell process started by PowerShell 7 would inherit the module path of PowerShell 7. |
|||
$env:PSModulePath = @( |
|||
(Join-Path -Path ([Environment]::GetFolderPath('MyDocuments')) -ChildPath 'WindowsPowerShell\Modules'), |
|||
(Join-Path -Path $env:ProgramFiles -ChildPath 'WindowsPowerShell\Modules'), |
|||
(Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\Modules') |
|||
) -join ';' |
|||
} |
|||
|
|||
& { |
|||
$principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList ([Security.Principal.WindowsIdentity]::GetCurrent()) |
|||
$elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
|||
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] START {1} edition={2} {3} elevated={4} os={5} build={6}.{7} user={8}' -f [DateTime]::UtcNow, $Label, $PSVersionTable.PSEdition, |
|||
$PSVersionTable.PSVersion, $elevated, $current.ProductName, $current.CurrentBuildNumber, $current.UBR, [Security.Principal.WindowsIdentity]::GetCurrent().Name |
|||
try { |
|||
$watch = [Diagnostics.Stopwatch]::StartNew() |
|||
if ($PesterModulePath) { Import-Module -Name (Join-Path -Path $PesterModulePath -ChildPath 'Pester.psd1') -Force -ErrorAction Stop } |
|||
else { Import-Module -Name Pester -RequiredVersion $PesterVersion -Force -ErrorAction Stop } |
|||
$configuration = New-PesterConfiguration |
|||
$configuration.Run.Path = @(Join-Path -Path $Root -ChildPath 'Tests') |
|||
$configuration.Run.PassThru = $true |
|||
$configuration.Output.Verbosity = 'Normal' |
|||
# The NUnit file is written below, after the summary: its writer asks WMI for the environment, which a restricted token may not do. |
|||
$configuration.TestResult.Enabled = $false |
|||
$result = Invoke-Pester -Configuration $configuration |
|||
'RESULT result={0} passed={1} failed={2} skipped={3} notrun={4} total={5} failedContainers={6} seconds={7:N0}' -f $result.Result, $result.PassedCount, |
|||
$result.FailedCount, $result.SkippedCount, $result.NotRunCount, $result.TotalCount, $result.FailedContainersCount, $watch.Elapsed.TotalSeconds |
|||
foreach ($test in $result.Failed) { 'FAILED: {0}: {1}' -f $test.ExpandedPath, ("$(@($test.ErrorRecord)[0])" -replace '\s+', ' ') } |
|||
foreach ($test in $result.Skipped) { 'SKIPPED: {0}' -f $test.ExpandedPath } |
|||
[pscustomobject]@{ |
|||
Label = $Label; Edition = $PSVersionTable.PSEdition; PowerShell = $PSVersionTable.PSVersion.ToString(); Elevated = $elevated |
|||
Os = '{0} {1}.{2}' -f $current.ProductName, $current.CurrentBuildNumber, $current.UBR; Result = [string] $result.Result |
|||
Passed = $result.PassedCount; Failed = $result.FailedCount; Skipped = $result.SkippedCount; NotRun = $result.NotRunCount; Total = $result.TotalCount |
|||
FailedContainers = $result.FailedContainersCount; Seconds = [int] $watch.Elapsed.TotalSeconds |
|||
FailedTests = @($result.Failed | ForEach-Object -Process { $_.ExpandedPath }); SkippedTests = @($result.Skipped | ForEach-Object -Process { $_.ExpandedPath }) |
|||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath "$Label.json") -Encoding UTF8 |
|||
try { Export-NUnitReport -Result $result -Path (Join-Path -Path $OutDir -ChildPath "$Label.xml") } |
|||
catch { 'NUnit report not written: {0}' -f $_.Exception.Message } |
|||
if ($result.Result -eq 'Passed') { $script:exitCode = 0 } |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] {1}-DONE' -f [DateTime]::UtcNow, $Label |
|||
} |
|||
catch { |
|||
'ERROR: {0}' -f $_ |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] {1}-FAILED' -f [DateTime]::UtcNow, $Label |
|||
} |
|||
} *>&1 | Out-File -FilePath $log -Encoding utf8 -Width 400 |
|||
Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath "$Label.exit") -Value $script:exitCode |
|||
exit $script:exitCode |
|||
@ -0,0 +1,103 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $VmName, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[switch] $Start |
|||
) |
|||
|
|||
# Repairs the boot files of one virtual machine of the matrix lab. AutomatedLab builds a base image with the bcdboot of the host and |
|||
# ignores its exit code; when the host's bcdboot can't process the boot files of an older image (it fails with exit code 193 on Windows |
|||
# Server 2019 and on Windows 11 22H2), the EFI system partition stays empty and the generation 2 virtual machine fails with Hyper-V event |
|||
# 18603. This script turns the machine off, mounts the machine's own differencing disk (never the shared base image), runs the bcdboot of |
|||
# the image itself, adds the removable-media path EFI\Boot\bootx64.efi that a new virtual machine boots from, checks the files, and |
|||
# dismounts the disk. It refuses a machine that isn't connected to the switch of the lab. Windows PowerShell 5.1 on the host, elevated. |
|||
$ErrorActionPreference = 'Stop' |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START repair-os-matrix-boot vm=$VmName lab=$LabName" | Set-Content -LiteralPath $LogPath |
|||
$diskPath = $null |
|||
$letters = @() |
|||
try { |
|||
$vm = Get-VM -Name $VmName |
|||
if ($vm.Generation -ne 2) { throw "$VmName isn't a generation 2 machine." } |
|||
$switches = @(Get-VMNetworkAdapter -VMName $VmName | ForEach-Object -Process { $_.SwitchName }) |
|||
if ($switches -ne $LabName) { throw "$VmName isn't connected only to the switch '$LabName' (switches: $($switches -join ', ')). Refusing." } |
|||
if ($vm.State -ne 'Off') { |
|||
Stop-VM -Name $VmName -TurnOff -Force |
|||
Write-Step "$VmName turned off" |
|||
} |
|||
|
|||
$drive = Get-VMHardDiskDrive -VMName $VmName | Select-Object -First 1 |
|||
$diskPath = $drive.Path |
|||
$vhd = Get-VHD -Path $diskPath |
|||
if ($vhd.VhdType -ne 'Differencing') { throw "$diskPath isn't a differencing disk; refusing to change a base image." } |
|||
Write-Step "disk $diskPath (parent $($vhd.ParentPath))" |
|||
|
|||
$image = Mount-VHD -Path $diskPath -Passthru |
|||
$disk = $image | Get-Disk |
|||
$partitions = @(Get-Partition -DiskNumber $disk.Number) |
|||
$esp = $partitions | Where-Object -FilterScript { $_.GptType -eq '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' } | Select-Object -First 1 |
|||
$system = $partitions | Where-Object -FilterScript { $_.Type -eq 'Basic' } | Sort-Object -Property Size -Descending | Select-Object -First 1 |
|||
if (-not $esp -or -not $system) { throw 'The disk has no EFI system partition or no Windows partition.' } |
|||
foreach ($partition in $esp, $system) { |
|||
# The host may have assigned a letter to the Windows partition on mount already. |
|||
if (-not (Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber).DriveLetter) { |
|||
Add-PartitionAccessPath -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber -AssignDriveLetter |
|||
} |
|||
|
|||
$letters += (Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber).DriveLetter |
|||
} |
|||
|
|||
$espLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $esp.PartitionNumber).DriveLetter |
|||
$systemLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $system.PartitionNumber).DriveLetter |
|||
$windows = '{0}:\Windows' -f $systemLetter |
|||
$bcdboot = Join-Path -Path $windows -ChildPath 'System32\bcdboot.exe' |
|||
if (-not (Test-Path -LiteralPath $bcdboot)) { throw "$bcdboot is missing." } |
|||
$before = @(Get-ChildItem -LiteralPath ('{0}:\' -f $espLetter) -Recurse -Force -File -ErrorAction SilentlyContinue).Count |
|||
Write-Step ("system partition {0}: {1}; ESP {2}: holds {3} files; image build {4}" -f $systemLetter, $windows, $espLetter, $before, (Get-Item -LiteralPath $bcdboot).VersionInfo.ProductVersion) |
|||
|
|||
$output = & $bcdboot $windows /s ('{0}:' -f $espLetter) /f UEFI 2>&1 | Out-String |
|||
Write-Step ("bcdboot of the image: exit code {0}: {1}" -f $LASTEXITCODE, ($output -replace '\s+', ' ').Trim()) |
|||
if ($LASTEXITCODE -ne 0) { throw "bcdboot of the image failed with exit code $LASTEXITCODE." } |
|||
|
|||
$bootManager = '{0}:\EFI\Microsoft\Boot\bootmgfw.efi' -f $espLetter |
|||
if (-not (Test-Path -LiteralPath $bootManager)) { throw "$bootManager is missing after bcdboot." } |
|||
$removable = '{0}:\EFI\Boot' -f $espLetter |
|||
$null = New-Item -ItemType Directory -Path $removable -Force |
|||
Copy-Item -LiteralPath $bootManager -Destination (Join-Path -Path $removable -ChildPath 'bootx64.efi') -Force |
|||
$after = @(Get-ChildItem -LiteralPath ('{0}:\' -f $espLetter) -Recurse -Force -File -ErrorAction SilentlyContinue).Count |
|||
$hasBcd = Test-Path -LiteralPath ('{0}:\EFI\Microsoft\Boot\BCD' -f $espLetter) |
|||
Write-Step ("ESP now holds {0} files; BCD present: {1}; bootx64.efi present: {2}" -f $after, $hasBcd, (Test-Path -LiteralPath (Join-Path -Path $removable -ChildPath 'bootx64.efi'))) |
|||
if ($after -lt 20 -or -not $hasBcd) { throw "The EFI system partition still looks empty ($after files, BCD $hasBcd)." } |
|||
} |
|||
catch { |
|||
Write-Step ('repair-os-matrix-boot-FAILED: {0}' -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
$failed = $true |
|||
} |
|||
finally { |
|||
if ($diskPath) { |
|||
try { |
|||
foreach ($partition in @(Get-Partition -DiskNumber (Get-VHD -Path $diskPath).DiskNumber -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.DriveLetter })) { |
|||
Remove-PartitionAccessPath -DiskNumber $partition.DiskNumber -PartitionNumber $partition.PartitionNumber -AccessPath ('{0}:\' -f $partition.DriveLetter) -ErrorAction SilentlyContinue |
|||
} |
|||
} |
|||
catch { Write-Step ('access path cleanup: {0}' -f $_) } |
|||
Dismount-VHD -Path $diskPath -ErrorAction SilentlyContinue |
|||
Write-Step 'disk dismounted' |
|||
} |
|||
} |
|||
|
|||
if ($failed) { exit 1 } |
|||
if ($Start) { |
|||
Start-VM -Name $VmName |
|||
Write-Step "$VmName started" |
|||
} |
|||
|
|||
Write-Step 'repair-os-matrix-boot-DONE' |
|||
exit 0 |
|||
@ -0,0 +1,236 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $Machine, |
|||
[Parameter(Mandatory)] [string] $ModulePath, |
|||
[Parameter(Mandatory)] [string] $OutputRoot, |
|||
[string] $Edition = 'Desktop,Core', |
|||
[string] $Mode = 'Elevated', |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $LocalCredentialMachine = '', |
|||
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', |
|||
[string] $RepositoryRoot, |
|||
[ValidateRange(5, 480)] [int] $TimeoutMinutes = 90 |
|||
) |
|||
|
|||
# The module's own Pester suite on the machines of the operating-system matrix (Decision 24), in Windows PowerShell 5.1 on the Hyper-V |
|||
# host. The live controller proves the behavior against a domain and remote servers; this proves the module and its tests run on each |
|||
# operating system and edition. It stages the behavior test files of the repository and the module under test (the same bits for every |
|||
# machine), copies them to the machine, runs Invoke-LocalSuite.ps1 in a new Windows PowerShell and a new PowerShell 7 process one after |
|||
# the other, and copies the log, the NUnit result, and the JSON summary back. -Mode Basic runs each edition with the token of a basic |
|||
# user, the way .github\scripts\Invoke-TestsAsBasicUser.ps1 does (the class of that script is extracted, not copied): the tests that |
|||
# need a missing privilege skip in the elevated mode and run in this one. The machine name LOCAL runs the same stage on this host, as |
|||
# the reference. A machine that can't use the domain account (a Windows 11 build whose secure channel to the domain controller fails) is |
|||
# listed in -LocalCredentialMachine and reached with the local installation account. Nothing secret is written. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. |
|||
if (-not $RepositoryRoot) { $RepositoryRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent } |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
$targets = @($Machine -split ',' | Where-Object -FilterScript { $_ }) |
|||
$editions = @($Edition -split ',' | Where-Object -FilterScript { $_ }) |
|||
$modes = @($Mode -split ',' | Where-Object -FilterScript { $_ }) |
|||
if ($modes | Where-Object -FilterScript { $_ -notin 'Elevated', 'Basic' }) { throw "-Mode takes Elevated, Basic, or both, separated by a comma." } |
|||
$localCredential = @($LocalCredentialMachine -split ',' | Where-Object -FilterScript { $_ }) |
|||
$behaviorFiles = 'Access', 'Audit', 'DriveRoot', 'FileHash', 'Inheritance', 'ItemCmdlets', 'Links', 'ObjectApis', 'OutputTypes', 'Owner', 'PathErrors', |
|||
'PermissionScopes', 'PipelineControl', 'Privileges', 'Remove-Item2', 'SecurityDescriptor', 'SecurityDescriptorSets', 'TestHelpers' |
|||
$null = New-Item -ItemType Directory -Path $OutputRoot -Force |
|||
$sequenceLog = Join-Path -Path $OutputRoot -ChildPath "$Label-localsuite.log" |
|||
function Write-Sequence { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $sequenceLog } |
|||
|
|||
$stage = Join-Path -Path $env:TEMP -ChildPath "ntfs-localsuite-$Label" |
|||
if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } |
|||
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'Tests') -Force |
|||
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'NTFSSecurity\bin\Release') -Force |
|||
foreach ($name in $behaviorFiles) { |
|||
$file = if ($name -eq 'TestHelpers') { 'TestHelpers.Tests.ps1' } else { "$name.Tests.ps1" } |
|||
Copy-Item -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath "Tests\$file") -Destination (Join-Path -Path $stage -ChildPath 'Tests') |
|||
} |
|||
|
|||
Copy-Item -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath 'Tests\TestHelpers.psm1') -Destination (Join-Path -Path $stage -ChildPath 'Tests') |
|||
Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination (Join-Path -Path $stage -ChildPath 'NTFSSecurity\bin\Release') -Recurse |
|||
Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-LocalSuite.ps1') -Destination $stage |
|||
if ('Basic' -in $modes) { |
|||
$wrapper = Get-Content -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath '.github\scripts\Invoke-TestsAsBasicUser.ps1') -Raw |
|||
$class = [regex]::Match($wrapper, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@").Groups[1].Value |
|||
if (-not $class) { throw 'The class of the basic-user wrapper was not found in .github\scripts\Invoke-TestsAsBasicUser.ps1.' } |
|||
$helperHead = @' |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $Executable, |
|||
[Parameter(Mandatory)] [string] $Root, |
|||
[Parameter(Mandatory)] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $OutDir, |
|||
[string] $PesterModulePath |
|||
) |
|||
|
|||
# Generated by Run-MatrixLocalSuite.ps1: starts Invoke-LocalSuite.ps1 with the token of a basic user (SAFER level Normal User) through the |
|||
# class of .github\scripts\Invoke-TestsAsBasicUser.ps1, waits for it, and writes its exit code. |
|||
$ErrorActionPreference = 'Stop' |
|||
'@ |
|||
$helperTail = @' |
|||
$runnerArguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Label {1} -Root "{2}" -OutDir "{3}"' -f (Join-Path -Path $Root -ChildPath 'Invoke-LocalSuite.ps1'), $Label, $Root, $OutDir |
|||
if ($PesterModulePath) { $runnerArguments += ' -PesterModulePath "{0}"' -f $PesterModulePath } |
|||
$console = Join-Path -Path $OutDir -ChildPath ('{0}.console.txt' -f $Label) |
|||
$commandLine = 'cmd.exe /d /s /c ""{0}" {1} > "{2}" 2>&1"' -f $Executable, $runnerArguments, $console |
|||
$exitCode = [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $Root) |
|||
Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath ('{0}.basic.exit' -f $Label)) -Value $exitCode |
|||
exit $exitCode |
|||
'@ |
|||
$helperText = $helperHead + "`r`nAdd-Type -TypeDefinition @'`r`n" + $class + "`r`n'@`r`n" + $helperTail |
|||
Set-Content -LiteralPath (Join-Path -Path $stage -ChildPath 'Start-BasicUserProcess.ps1') -Value $helperText -Encoding UTF8 |
|||
} |
|||
$dllHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash |
|||
$testHashes = Get-ChildItem -LiteralPath (Join-Path -Path $stage -ChildPath 'Tests') -File | Sort-Object -Property Name | ForEach-Object -Process { '{0}={1}' -f $_.Name, (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.Substring(0, 12) } |
|||
($stamp -f [DateTime]::UtcNow) + " START localsuite-$Label machines=$($targets -join ',') editions=$($editions -join ',') dll=$dllHash" | Set-Content -LiteralPath $sequenceLog |
|||
Write-Sequence ('staged test files: {0}' -f ($testHashes -join ' ')) |
|||
$summaryRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
|
|||
if ($targets | Where-Object -FilterScript { $_ -ne 'LOCAL' }) { |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
} |
|||
|
|||
foreach ($name in $targets) { |
|||
$cellFolder = Join-Path -Path $OutputRoot -ChildPath "$Label-$name" |
|||
$null = New-Item -ItemType Directory -Path $cellFolder -Force |
|||
Write-Sequence "machine $name START" |
|||
$session = $null |
|||
$runCredential = $null |
|||
try { |
|||
if ($name -eq 'LOCAL') { |
|||
$root = Join-Path -Path $env:TEMP -ChildPath "ntfs-localsuite-run-$Label" |
|||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } |
|||
Copy-Item -LiteralPath $stage -Destination $root -Recurse |
|||
} |
|||
else { |
|||
$sessionParameters = @{ ComputerName = $name } |
|||
if ($name -in $localCredential) { $sessionParameters.UseLocalCredential = $true } |
|||
$session = New-LabPSSession @sessionParameters |
|||
# The account for the scheduled tasks: the lab account of the machine, or its local installation account. AutomatedLab keeps the |
|||
# installation password in clear text in the lab file; here it stays in memory. |
|||
$machineDefinition = Get-LabVM -ComputerName $name |
|||
$runCredential = if ($name -in $localCredential) { |
|||
New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ('{0}\{1}' -f $name, $machineDefinition.InstallationUser.UserName), (ConvertTo-SecureString -String $machineDefinition.InstallationUser.Password -AsPlainText -Force) |
|||
} |
|||
else { |
|||
$machineDefinition.GetCredential((Get-Lab)) |
|||
} |
|||
|
|||
$root = 'C:\NtfsMatrixLocal\' + $Label |
|||
Invoke-Command -Session $session -ArgumentList $root -ScriptBlock { |
|||
param ($Path) |
|||
if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force } |
|||
$null = New-Item -ItemType Directory -Path $Path -Force |
|||
} |
|||
|
|||
Copy-Item -Path (Join-Path -Path $stage -ChildPath '*') -Destination $root -ToSession $session -Recurse -Force |
|||
Write-Sequence "machine $name stage copied to $root" |
|||
} |
|||
|
|||
foreach ($modeName in $modes) { |
|||
foreach ($editionName in $editions) { |
|||
$runLabel = ('{0}-{1}-{2}{3}' -f $Label, $name, $editionName, $(if ($modeName -eq 'Basic') { '-basic' } else { '' })).ToLowerInvariant() |
|||
$arguments = @{ Root = $root; Edition = $editionName; RunLabel = $runLabel; Pester = $(if ($name -eq 'LOCAL') { $PesterModulePath } else { '' }); Mode = $modeName } |
|||
$start = { |
|||
param ($Root, $Edition, $RunLabel, $Pester, $ModeName, $Credential) |
|||
$exe = if ($Edition -eq 'Desktop') { Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' } else { Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' } |
|||
$out = Join-Path -Path $Root -ChildPath 'Results' |
|||
$null = New-Item -ItemType Directory -Path $out -Force |
|||
if ($ModeName -eq 'Basic') { |
|||
# The basic-user token writes the results, so the account of the run needs Modify on the folder. |
|||
$null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f [Security.Principal.WindowsIdentity]::GetCurrent().Name) |
|||
$list = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f (Join-Path -Path $Root -ChildPath 'Start-BasicUserProcess.ps1')), |
|||
'-Executable', ('"{0}"' -f $exe), '-Root', ('"{0}"' -f $Root), '-Label', $RunLabel, '-OutDir', ('"{0}"' -f $out)) |
|||
if ($Pester) { $list += '-PesterModulePath', ('"{0}"' -f $Pester) } |
|||
$exe = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' |
|||
} |
|||
else { |
|||
$list = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f (Join-Path -Path $Root -ChildPath 'Invoke-LocalSuite.ps1')), |
|||
'-Label', $RunLabel, '-Root', ('"{0}"' -f $Root), '-OutDir', ('"{0}"' -f $out)) |
|||
if ($Pester) { $list += '-PesterModulePath', ('"{0}"' -f $Pester) } |
|||
} |
|||
|
|||
if ($Credential) { |
|||
# A process started from a remoting session inherits a token with every privilege enabled and no credentials of its own, |
|||
# which the tests don't expect (eight of them fail). A scheduled task with a batch logon at the highest run level gets |
|||
# the token of an elevated interactive session: privileges present but disabled, and the credentials of the account. |
|||
$taskName = 'NtfsMatrixLocal-' + $RunLabel |
|||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue |
|||
$action = New-ScheduledTaskAction -Execute $exe -Argument ($list -join ' ') |
|||
$null = Register-ScheduledTask -TaskName $taskName -Action $action -RunLevel Highest -User $Credential.UserName -Password $Credential.GetNetworkCredential().Password |
|||
Start-ScheduledTask -TaskName $taskName |
|||
$taskName |
|||
} |
|||
else { |
|||
(Start-Process -FilePath $exe -ArgumentList $list -PassThru -WindowStyle Hidden).Id |
|||
} |
|||
} |
|||
$isRunning = { |
|||
param ($Handle) |
|||
if ($Handle -is [string]) { $task = Get-ScheduledTask -TaskName $Handle -ErrorAction SilentlyContinue; [bool] ($task -and $task.State -eq 'Running') } |
|||
else { [bool] (Get-Process -Id $Handle -ErrorAction SilentlyContinue) } |
|||
} |
|||
$stop = { |
|||
param ($Handle) |
|||
if ($Handle -is [string]) { Stop-ScheduledTask -TaskName $Handle -ErrorAction SilentlyContinue } else { Stop-Process -Id $Handle -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
$finish = { |
|||
param ($Handle) |
|||
if ($Handle -is [string]) { |
|||
$result = (Get-ScheduledTaskInfo -TaskName $Handle -ErrorAction SilentlyContinue).LastTaskResult |
|||
Unregister-ScheduledTask -TaskName $Handle -Confirm:$false -ErrorAction SilentlyContinue |
|||
"task result $result" |
|||
} |
|||
} |
|||
$startArguments = $arguments.Root, $arguments.Edition, $arguments.RunLabel, $arguments.Pester, $arguments.Mode, $runCredential |
|||
$handle = if ($session) { Invoke-Command -Session $session -ScriptBlock $start -ArgumentList $startArguments } else { & $start @startArguments } |
|||
Write-Sequence "machine $name $modeName $editionName started ($handle)" |
|||
$deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) |
|||
do { |
|||
Start-Sleep -Seconds 20 |
|||
$alive = if ($session) { Invoke-Command -Session $session -ScriptBlock $isRunning -ArgumentList $handle } else { & $isRunning $handle } |
|||
} while ($alive -and [DateTime]::UtcNow -lt $deadline) |
|||
if ($alive) { |
|||
if ($session) { Invoke-Command -Session $session -ScriptBlock $stop -ArgumentList $handle } else { & $stop $handle } |
|||
Write-Sequence "machine $name $modeName $editionName TIMED OUT after $TimeoutMinutes minutes; stopped" |
|||
} |
|||
|
|||
$outcome = if ($session) { Invoke-Command -Session $session -ScriptBlock $finish -ArgumentList $handle } else { & $finish $handle } |
|||
Write-Sequence "machine $name $modeName $editionName finished $outcome" |
|||
} |
|||
} |
|||
|
|||
if ($session) { Copy-Item -FromSession $session -Path (Join-Path -Path $root -ChildPath 'Results\*') -Destination $cellFolder -Recurse -Force } |
|||
else { Copy-Item -Path (Join-Path -Path $root -ChildPath 'Results\*') -Destination $cellFolder -Recurse -Force } |
|||
foreach ($modeName in $modes) { |
|||
foreach ($editionName in $editions) { |
|||
$expected = ('{0}-{1}-{2}{3}' -f $Label, $name, $editionName, $(if ($modeName -eq 'Basic') { '-basic' } else { '' })).ToLowerInvariant() |
|||
if (-not (Test-Path -LiteralPath (Join-Path -Path $cellFolder -ChildPath "$expected.json"))) { |
|||
Write-Sequence "machine ${name}: NO RESULT FILE for $expected" |
|||
$summaryRows.Add([pscustomobject]@{ Machine = $name; Edition = $editionName; Os = ''; PowerShell = ''; Elevated = ''; Result = 'NoResult'; Passed = ''; Failed = ''; Skipped = ''; Total = ''; Seconds = '' }) |
|||
} |
|||
} |
|||
} |
|||
foreach ($json in Get-ChildItem -LiteralPath $cellFolder -Filter '*.json') { |
|||
$summary = Get-Content -LiteralPath $json.FullName -Raw | ConvertFrom-Json |
|||
$summaryRows.Add([pscustomobject]@{ |
|||
Machine = $name; Edition = $summary.Edition; Os = $summary.Os; PowerShell = $summary.PowerShell; Elevated = $summary.Elevated; Result = $summary.Result |
|||
Passed = $summary.Passed; Failed = $summary.Failed; Skipped = $summary.Skipped; Total = $summary.Total; Seconds = $summary.Seconds |
|||
}) |
|||
Write-Sequence ('machine {0} {1}: {2} passed={3} failed={4} skipped={5} total={6} elevated={7} os={8}' -f $name, $summary.Edition, $summary.Result, $summary.Passed, $summary.Failed, $summary.Skipped, $summary.Total, $summary.Elevated, $summary.Os) |
|||
} |
|||
} |
|||
catch { |
|||
Write-Sequence "machine $name FAILED: $_" |
|||
} |
|||
finally { |
|||
if ($session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } |
|||
} |
|||
|
|||
Write-Sequence "machine $name END" |
|||
} |
|||
|
|||
$summaryRows | Export-Csv -LiteralPath (Join-Path -Path $OutputRoot -ChildPath "$Label-localsuite-summary.csv") -NoTypeInformation |
|||
Write-Sequence "localsuite-$Label-DONE" |
|||
exit 0 |
|||
@ -0,0 +1,104 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $FileServer, |
|||
[string] $Client = 'OSWin11', |
|||
[string] $ModulePath, |
|||
[string] $Version, |
|||
[string] $Edition = 'Desktop,Core', |
|||
[Parameter(Mandatory)] [string] $OutputRoot, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $DomainController = 'OSDC1', |
|||
[string] $LabFolder, |
|||
[string] $Machines = 'OSFile19,OSFile22,OSFile25,OSWin11' |
|||
) |
|||
|
|||
# One detached sequence of the operating-system matrix (Decision 24) in Windows PowerShell 5.1 on the Hyper-V host. For each cell, a file |
|||
# server with the client, it runs: readiness of every machine, the unmodified controller of the repository for the source (a build |
|||
# folder or an exact Gallery version) in both editions, the validation of every role from the result files, a snapshot of the fixture |
|||
# SIDs, the removal of the fixture, and an independent check of the end state. An infrastructure failure (no summary of the controller) |
|||
# stops the later cells; failing tests don't. Case 9 (accounts of other forests) needs trusts that this lab doesn't have, so the cells |
|||
# run with -ForeignDomainController @(). Nothing secret is written: the controller keeps the passwords in memory. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. |
|||
if (-not $LabFolder) { $LabFolder = Split-Path -Path $PSScriptRoot -Parent } |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
$kit = $PSScriptRoot |
|||
$cells = @($FileServer -split ',' | Where-Object -FilterScript { $_ }) |
|||
$editions = @($Edition -split ',' | Where-Object -FilterScript { $_ }) |
|||
$allMachines = @($Machines -split ',' | Where-Object -FilterScript { $_ }) |
|||
if ([bool] $ModulePath -eq [bool] $Version) { throw 'Pass exactly one of -ModulePath and -Version.' } |
|||
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null |
|||
$sequenceLog = Join-Path -Path $OutputRoot -ChildPath "$Label-sequence.log" |
|||
function Write-Sequence { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $sequenceLog } |
|||
$source = if ($ModulePath) { "module=$ModulePath dll=$((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash)" } else { "version=$Version" } |
|||
($stamp -f [DateTime]::UtcNow) + " START matrix-sequence-$Label client=$Client cells=$($cells -join ',') editions=$($editions -join ',') $source" | Set-Content -LiteralPath $sequenceLog |
|||
Write-Sequence ('controller blob {0}' -f ((& git -C (Split-Path -Path (Split-Path -Path $LabFolder -Parent) -Parent) hash-object (Join-Path -Path $LabFolder -ChildPath 'Invoke-NTFSSecurityLabTest.ps1')) -join '')) |
|||
Write-Sequence ('live tests blob {0}' -f ((& git -C (Split-Path -Path (Split-Path -Path $LabFolder -Parent) -Parent) hash-object (Join-Path -Path $LabFolder -ChildPath 'NTFSSecurity.Live.Tests.ps1')) -join '')) |
|||
$controller = Join-Path -Path $LabFolder -ChildPath 'Invoke-NTFSSecurityLabTest.ps1' |
|||
$infrastructureFailed = $false |
|||
|
|||
foreach ($fileServerName in $cells) { |
|||
if ($infrastructureFailed) { Write-Sequence "cell $fileServerName SKIPPED after an infrastructure failure"; continue } |
|||
$cell = Join-Path -Path $OutputRoot -ChildPath "$Label-$fileServerName" |
|||
New-Item -ItemType Directory -Path $cell -Force | Out-Null |
|||
$runLog = Join-Path -Path $cell -ChildPath 'run.log' |
|||
$ran = $false |
|||
Write-Sequence "cell $fileServerName START (client $Client)" |
|||
try { |
|||
$readinessLog = Join-Path -Path $cell -ChildPath 'readiness.log' |
|||
& (Join-Path -Path $kit -ChildPath 'Test-MatrixReadiness.ps1') -LabName $LabName -DomainController @($DomainController) -Member @($allMachines) -OutFile $readinessLog |
|||
$readiness = Get-Content -LiteralPath $readinessLog -Raw |
|||
$notReady = 'wsman=failed|secure channel False|PowerShell 7 missing|Core missing|Pester Desktop (?!5\.7\.1)|=False|kerberos: .*Error' |
|||
$problem = [regex]::Match($readiness, $notReady).Value |
|||
if ($readiness -notmatch 'matrix-readiness-DONE' -or $problem) { throw "Readiness of cell $fileServerName failed ('$problem'); see $readinessLog" } |
|||
Write-Sequence "cell $fileServerName readiness ok" |
|||
|
|||
$arguments = @{ |
|||
LabName = $LabName; DomainController = $DomainController; FileServer = $fileServerName; Client = $Client |
|||
ForeignDomainController = @(); Edition = $editions; OutputPath = $cell; Confirm = $false |
|||
} |
|||
if ($ModulePath) { $arguments.Version = @(); $arguments.ModulePath = $ModulePath } else { $arguments.Version = @($Version) } |
|||
($stamp -f [DateTime]::UtcNow) + " START controller cell=$fileServerName" | Set-Content -LiteralPath $runLog |
|||
$ran = $true |
|||
& { & $controller @arguments } *>&1 | ForEach-Object -Process { '{0}' -f $_ } | Out-File -LiteralPath $runLog -Append -Encoding utf8 -Width 500 |
|||
$summaryPath = Get-ChildItem -LiteralPath (Join-Path -Path $cell -ChildPath 'Results') -Filter 'Summary.json' -Recurse -ErrorAction SilentlyContinue | |
|||
Sort-Object -Property LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName |
|||
if (-not $summaryPath) { throw "The controller wrote no Summary.json for cell $fileServerName; see $runLog" } |
|||
Write-Sequence "cell $fileServerName controller done: $summaryPath" |
|||
|
|||
$validationLog = Join-Path -Path $cell -ChildPath 'validation.log' |
|||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path -Path $kit -ChildPath 'Validate-LabResults.ps1') -ResultsFolder (Split-Path -Path $summaryPath -Parent) -OutputPrefix (Join-Path -Path $cell -ChildPath "$Label-$fileServerName") -Edition ($editions -join ',') -Expect Candidate *>&1 | |
|||
Out-File -LiteralPath $validationLog -Encoding utf8 -Width 400 |
|||
Write-Sequence "cell $fileServerName validation exit code $LASTEXITCODE (see validation.log)" |
|||
} |
|||
catch { |
|||
Write-Sequence "cell $fileServerName FAILED before the cleanup: $_" |
|||
if (-not $ran) { Write-Sequence "cell ${fileServerName}: the controller did not start" } |
|||
$infrastructureFailed = $true |
|||
} |
|||
|
|||
try { |
|||
$sidFile = Join-Path -Path $cell -ChildPath 'fixture-sids.json' |
|||
$common = @{ LabName = $LabName; DomainController = @($DomainController); Machine = @($allMachines) } |
|||
if ($ran) { |
|||
& (Join-Path -Path $kit -ChildPath 'Test-MatrixCleanup.ps1') -Mode Snapshot -SidFile $sidFile -OutFile (Join-Path -Path $cell -ChildPath 'cleanup-1-snapshot.log') @common |
|||
& { & $controller -RemoveFixture -LabName $LabName -DomainController $DomainController -FileServer $fileServerName -Client $Client -ForeignDomainController @() -Confirm:$false } *>&1 | |
|||
ForEach-Object -Process { '{0}' -f $_ } | Out-File -LiteralPath (Join-Path -Path $cell -ChildPath 'cleanup-2-remove.log') -Encoding utf8 -Width 500 |
|||
& (Join-Path -Path $kit -ChildPath 'Test-MatrixCleanup.ps1') -Mode Verify -SidFile $sidFile -OutFile (Join-Path -Path $cell -ChildPath 'cleanup-3-verify.log') @common |
|||
$verify = Get-Content -LiteralPath (Join-Path -Path $cell -ChildPath 'cleanup-3-verify.log') -Raw |
|||
$clean = ($verify -match 'matrix-cleanup-Verify-DONE') -and ($verify -notmatch 'OU NTFSSecurityLive: True') -and ($verify -notmatch 'accounts: NtfsLive') -and |
|||
($verify -notmatch 'share=True|C:\\NTFSSecurityLive=True|C:\\NTFSSecurityLab=True|NtfsLiveLocal=True') -and ($verify -notmatch 'profiles=[1-9]') -and ($verify -notmatch ': [1-9]\d* fixture member') |
|||
Write-Sequence ("cell $fileServerName cleanup verdict from the verify log: {0}" -f $(if ($clean) { 'CLEAN' } else { 'DIRTY (read cleanup-3-verify.log)' })) |
|||
} |
|||
} |
|||
catch { |
|||
Write-Sequence "cell $fileServerName cleanup FAILED: $_" |
|||
} |
|||
|
|||
Write-Sequence "cell $fileServerName END" |
|||
} |
|||
|
|||
Write-Sequence "matrix-sequence-$Label-DONE" |
|||
exit 0 |
|||
@ -0,0 +1,112 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidateSet('Snapshot', 'Verify', 'Repair')] [string] $Mode, |
|||
[Parameter(Mandatory)] [string] $SidFile, |
|||
[Parameter(Mandatory)] [string] $OutFile, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string[]] $DomainController = @('OSDC1'), |
|||
[string[]] $Machine = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11') |
|||
) |
|||
|
|||
# Independent end-state check of the fixture of Invoke-NTFSSecurityLabTest.ps1 in a lab (Windows PowerShell 5.1, on the host). Snapshot |
|||
# records the SIDs of the NtfsLive* accounts while the fixture exists. Verify reads the domains and every machine again, and reports |
|||
# the organizational unit, the accounts, the share, the folders, the local group, the memberships of Administrators, Access Control |
|||
# Assistance Operators, and Remote Management Users, and the profiles of those SIDs. The result is judged from this log, never from |
|||
# the wrapper of the controller or a global error count. Repair is for a run whose removal failed: with the SIDs of the snapshot, it |
|||
# removes what that run left on the machines (the memberships, also of orphaned SIDs, which net localgroup deletes by SID; the share; the |
|||
# local group; the folders) and then reports like Verify. |
|||
& { |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$DomainController = @($DomainController | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$Machine = @($Machine | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] START matrix-cleanup-{1} lab={2}' -f [DateTime]::UtcNow, $Mode, $LabName |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$labCommand = @{ NoDisplay = $true; PassThru = $true; ErrorAction = 'Stop' } |
|||
$directoryScript = { |
|||
Import-Module -Name ActiveDirectory |
|||
$domain = Get-ADDomain |
|||
$unit = Get-ADOrganizationalUnit -LDAPFilter '(ou=NTFSSecurityLive)' -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator |
|||
[pscustomobject]@{ |
|||
Domain = $domain.DNSRoot |
|||
Unit = [bool] $unit |
|||
Sids = @(Get-ADObject -LDAPFilter '(sAMAccountName=NtfsLive*)' -SearchBase $domain.DistinguishedName -Server $domain.PDCEmulator -Properties objectSid, sAMAccountName | |
|||
ForEach-Object -Process { '{0}={1}' -f $_.sAMAccountName, $_.objectSid.Value }) |
|||
} |
|||
} |
|||
|
|||
$directory = @(foreach ($name in $DomainController) { Invoke-LabCommand -ComputerName $name -ActivityName "Read the fixture of $name" -ScriptBlock $directoryScript @labCommand }) |
|||
foreach ($state in $directory) { |
|||
'{0,-14} OU NTFSSecurityLive: {1,-5} NtfsLive* accounts: {2}' -f $state.Domain, $state.Unit, ($(if ($state.Sids) { $state.Sids -join ', ' } else { 'none' })) |
|||
} |
|||
|
|||
if ($Mode -eq 'Snapshot') { |
|||
$sids = @($directory | ForEach-Object -Process { $_.Sids } | ForEach-Object -Process { ($_ -split '=', 2)[1] }) |
|||
ConvertTo-Json -InputObject $sids | Set-Content -LiteralPath $SidFile -Encoding utf8 |
|||
"saved $($sids.Count) SIDs to $SidFile" |
|||
} |
|||
else { |
|||
$sids = [string[]] (Get-Content -LiteralPath $SidFile -Raw | ConvertFrom-Json) |
|||
"checking $($sids.Count) SIDs of the snapshot" |
|||
$machineScript = { |
|||
param ($Sid) |
|||
# net localgroup lists an orphaned SID, which Get-LocalGroupMember in Windows PowerShell 5.1 fails on and skips. |
|||
$groups = foreach ($groupSid in 'S-1-5-32-544', 'S-1-5-32-579', 'S-1-5-32-580') { |
|||
$groupName = ([System.Security.Principal.SecurityIdentifier] $groupSid).Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', '' |
|||
$members = @(& net.exe localgroup $groupName 2>&1 | ForEach-Object -Process { "$_".Trim() }) |
|||
$hits = @($members | Where-Object -FilterScript { $_ -match 'NtfsLive' -or $_ -in $Sid }) |
|||
'{0}: {1} fixture member(s)' -f $groupSid, $hits.Count |
|||
} |
|||
|
|||
[pscustomobject]@{ |
|||
Share = [bool] (Get-SmbShare -Name 'NTFSSecurityLive' -ErrorAction SilentlyContinue) |
|||
ShareRoot = Test-Path -LiteralPath 'C:\NTFSSecurityLive' |
|||
Payload = Test-Path -LiteralPath 'C:\NTFSSecurityLab' |
|||
LocalGroup = [bool] (Get-LocalGroup -Name 'NtfsLiveLocal' -ErrorAction SilentlyContinue) |
|||
Groups = $groups -join '; ' |
|||
Profiles = @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid }).Count |
|||
} |
|||
} |
|||
|
|||
foreach ($name in $Machine) { |
|||
if ($Mode -eq 'Repair') { |
|||
$repairScript = { |
|||
param ($Sid) |
|||
$messages = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
foreach ($groupSid in 'S-1-5-32-544', 'S-1-5-32-579', 'S-1-5-32-580') { |
|||
$groupName = ([System.Security.Principal.SecurityIdentifier] $groupSid).Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', '' |
|||
$named = @(& net.exe localgroup $groupName 2>&1 | ForEach-Object -Process { "$_".Trim() } | Where-Object -FilterScript { $_ -match 'NtfsLive' }) |
|||
foreach ($member in @($Sid) + $named) { $null = & net.exe localgroup $groupName $member /delete 2>&1 } |
|||
} |
|||
|
|||
if (Get-SmbShare -Name 'NTFSSecurityLive' -ErrorAction SilentlyContinue) { Remove-SmbShare -Name 'NTFSSecurityLive' -Force } |
|||
$null = & net.exe localgroup 'NtfsLiveLocal' /delete 2>&1 |
|||
foreach ($path in 'C:\NTFSSecurityLive', 'C:\NTFSSecurityLab') { |
|||
$command = '$errors = @(); Remove-Item -LiteralPath ''__PATH__'' -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $path) |
|||
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) |
|||
$attempt = 0 |
|||
while ((Test-Path -LiteralPath $path) -and $attempt -lt 6) { |
|||
$attempt++ |
|||
if ($attempt -gt 1) { Start-Sleep -Seconds 5 } |
|||
$null = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 |
|||
} |
|||
|
|||
$messages.Add(('{0}: present after {1} attempt(s): {2}' -f $path, $attempt, (Test-Path -LiteralPath $path))) |
|||
} |
|||
|
|||
$messages |
|||
} |
|||
|
|||
foreach ($message in @(Invoke-LabCommand -ComputerName $name -ActivityName "Repair $name" -ScriptBlock $repairScript -ArgumentList (, $sids) @labCommand)) { |
|||
'{0,-9} repair: {1}' -f $name, $message |
|||
} |
|||
} |
|||
|
|||
$state = Invoke-LabCommand -ComputerName $name -ActivityName "Check $name" -ScriptBlock $machineScript -ArgumentList (, $sids) @labCommand |
|||
'{0,-9} share={1} C:\NTFSSecurityLive={2} C:\NTFSSecurityLab={3} NtfsLiveLocal={4} profiles={5}' -f $name, $state.Share, $state.ShareRoot, $state.Payload, $state.LocalGroup, $state.Profiles |
|||
' {0}' -f $state.Groups |
|||
} |
|||
} |
|||
|
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] matrix-cleanup-{1}-DONE' -f [DateTime]::UtcNow, $Mode |
|||
} *>&1 | Out-File -FilePath $OutFile -Encoding utf8 -Width 400 |
|||
@ -0,0 +1,79 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string[]] $DomainController = @('OSDC1'), |
|||
[string[]] $Member = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11'), |
|||
[Parameter(Mandatory)] [string] $OutFile |
|||
) |
|||
|
|||
# Readiness and identity of the machines of an AutomatedLab lab for the live tests of NTFSSecurity, in Windows PowerShell 5.1 on the |
|||
# Hyper-V host. It proves what the tests need, not that a VM runs: authenticated WinRM through AutomatedLab, the operating system |
|||
# build, the domain, the clock against the host, LDAP and a Kerberos ticket (a domain controller), or the secure channel, the domain |
|||
# controller locator and a service ticket for a peer (a member), the PowerShell 7 and Pester payloads, and the ports of SMB, RPC, and |
|||
# WinRM from the host. Nothing is changed in the lab. Passwords are never read or printed. |
|||
& { |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$DomainController = @($DomainController | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] START matrix-readiness lab={1}' -f [DateTime]::UtcNow, $LabName |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$labCommand = @{ NoDisplay = $true; PassThru = $true; ErrorAction = 'Stop' } |
|||
$everyMachine = @($DomainController) + @($Member) |
|||
$peerByMember = @{} |
|||
foreach ($name in $Member) { $peerByMember[$name] = @($Member | Where-Object -FilterScript { $_ -ne $name })[0] } |
|||
foreach ($name in $everyMachine) { |
|||
$address = (Get-LabVM -ComputerName $name).IpV4Address |
|||
$wsman = try { $null = Test-WSMan -ComputerName $address -ErrorAction Stop; 'ok' } catch { "failed: $($_.Exception.Message)" } |
|||
$ports = foreach ($port in 135, 445, 5985) { |
|||
$client = New-Object -TypeName 'System.Net.Sockets.TcpClient' |
|||
try { $open = $client.ConnectAsync($address, $port).Wait(3000) } catch { $open = $false } finally { $client.Dispose() } |
|||
'{0}={1}' -f $port, $open |
|||
} |
|||
|
|||
$hostUtc = [DateTime]::UtcNow |
|||
$state = Invoke-LabCommand -ComputerName $name -ActivityName "Readiness of $name" -ScriptBlock { |
|||
param ([bool] $IsDomainController, [string] $Peer) |
|||
$os = Get-CimInstance -ClassName Win32_OperatingSystem |
|||
$computer = Get-CimInstance -ClassName Win32_ComputerSystem |
|||
$version = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
$dotNet = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -ErrorAction SilentlyContinue).Release |
|||
$pwshPath = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' |
|||
$result = [ordered]@{ |
|||
Os = '{0} {1}.{2}' -f $os.Caption, $os.Version, $version.UBR |
|||
ProductType = $os.ProductType |
|||
Edition = $version.EditionID |
|||
Domain = $computer.Domain |
|||
Utc = [DateTime]::UtcNow |
|||
DotNet = $dotNet |
|||
WindowsPowerShell = $PSVersionTable.PSVersion.ToString() |
|||
PowerShell7 = $(if (Test-Path -LiteralPath $pwshPath) { (Get-Item -LiteralPath $pwshPath).VersionInfo.ProductVersion } else { 'missing' }) |
|||
PesterDesktop = $(@(Get-Module -Name Pester -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1 | ForEach-Object -Process { $_.Version.ToString() }) -join '') |
|||
PesterCore = $(if (Test-Path -LiteralPath (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\Modules\Pester\5.7.1\Pester.psd1')) { '5.7.1' } else { 'missing' }) |
|||
Ldap = '' |
|||
Channel = '' |
|||
Kerberos = '' |
|||
} |
|||
if ($IsDomainController) { |
|||
$rootDse = [adsi]'LDAP://RootDSE' |
|||
$result.Ldap = 'RootDSE {0}, synchronized {1}' -f $rootDse.dnsHostName.Value, $rootDse.isSynchronized.Value |
|||
$result.Kerberos = (& klist.exe get "krbtgt/$($computer.Domain)" 2>&1 | Select-String -Pattern 'Error|Server: krbtgt' | Select-Object -First 1).Line |
|||
} |
|||
else { |
|||
$result.Ldap = (& nltest.exe "/dsgetdc:$($computer.Domain)" 2>&1 | Select-String -Pattern '^\s*DC: |ERROR' | Select-Object -First 1).Line |
|||
$result.Channel = 'secure channel {0}' -f (Test-ComputerSecureChannel) |
|||
$result.Kerberos = (& klist.exe get "host/$Peer.$($computer.Domain)" 2>&1 | Select-String -Pattern 'Error|Server: host' | Select-Object -First 1).Line |
|||
} |
|||
|
|||
[pscustomobject] $result |
|||
} -ArgumentList ($name -in $DomainController), $peerByMember[$name] @labCommand |
|||
$skew = [Math]::Round(($state.Utc - $hostUtc).TotalSeconds, 1) |
|||
'{0,-9} wsman={1} ports({2}) os={3} type={4} edition={5} domain={6} skew={7}s' -f $name, $wsman, ($ports -join ' '), $state.Os, $state.ProductType, $state.Edition, $state.Domain, $skew |
|||
' .NET release={0}; Windows PowerShell {1}; PowerShell 7 {2}; Pester Desktop {3}, Core {4}' -f $state.DotNet, $state.WindowsPowerShell, $state.PowerShell7, $state.PesterDesktop, $state.PesterCore |
|||
' ldap: {0}' -f ("$($state.Ldap)".Trim()) |
|||
if ($state.Channel) { ' {0}' -f $state.Channel } |
|||
' kerberos: {0}' -f ("$($state.Kerberos)".Trim()) |
|||
} |
|||
|
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] matrix-readiness-DONE' -f [DateTime]::UtcNow |
|||
} *>&1 | Out-File -FilePath $OutFile -Encoding utf8 -Width 400 |
|||
Loading…
Reference in new issue