You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1010 lines
46 KiB

<#
.SYNOPSIS
Runs the live tests of NTFSSecurity in an AutomatedLab lab against versions of the module.
.DESCRIPTION
Prepares what NTFSSecurity.Live.Tests.ps1 needs in a lab with a domain controller, a file server, and a client of
one domain, and runs the tests for each module version and PowerShell edition: on the client as the accounts of the
roles Delegate, ServerAdmin, and Admin, then on the file server in the role Server. Each version runs in its own
process. README.md describes the cases; the results and a summary go to a new folder in OutputPath.
The script changes only the lab: the organizational unit NTFSSecurityLive with the accounts and groups of the
tests, the local group NtfsLiveLocal and members of Administrators on the file server, members of Administrators
and Remote Management Users on the client, the share NTFSSecurityLive, and the folders C:\NTFSSecurityLive and
C:\NTFSSecurityLab. -RemoveFixture removes them again. The passwords of the accounts exist only in this process.
.PARAMETER LabName
The name of the AutomatedLab lab.
.PARAMETER DomainController
The domain controller that manages the accounts of the tests.
.PARAMETER FileServer
The file server with the share. Its domain must be the domain of the domain controller.
.PARAMETER Client
The computer that runs the tests. Its domain must be the domain of the domain controller.
.PARAMETER Version
The versions of NTFSSecurity on the PowerShell Gallery to test, such as 5.0.0-rc4.
.PARAMETER ModulePath
A folder with a build of the module, such as NTFSSecurity\bin\Release, to test as the version 'local'.
.PARAMETER Edition
The PowerShell editions to test in: Desktop (Windows PowerShell 5.1) and Core (PowerShell 7).
.PARAMETER OutputPath
The folder for the downloaded packages and the results.
.PARAMETER RemoveFixture
Removes everything the script added to the lab, and runs no test.
.EXAMPLE
.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc2, 5.0.0-rc4 -Confirm:$false
Runs the live tests against 5.0.0-rc2 and 5.0.0-rc4 in both PowerShell editions.
.EXAMPLE
.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture
Removes the accounts, the share, and the folders of the live tests from the lab.
.NOTES
Run it in an elevated Windows PowerShell 5.1 session on the Hyper-V host of the lab, with AutomatedLab.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Test')]
param (
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]
$LabName = 'WindowsAccessControlLab',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]
$DomainController = 'F1ADC1',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]
$FileServer = 'F1AFile2',
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]
$Client = 'F1AFile1',
[Parameter(ParameterSetName = 'Test')]
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z]+)?$')]
[string[]]
$Version = @('5.0.0-rc2', '5.0.0-rc4'),
[Parameter(ParameterSetName = 'Test')]
[ValidateScript({ Test-Path -LiteralPath (Join-Path -Path $_ -ChildPath 'NTFSSecurity.psd1') -PathType Leaf })]
[string]
$ModulePath,
[Parameter(ParameterSetName = 'Test')]
[ValidateSet('Desktop', 'Core')]
[string[]]
$Edition = @('Desktop', 'Core'),
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]
$OutputPath = (Join-Path -Path $env:TEMP -ChildPath 'NTFSSecurityLab'),
[Parameter(Mandatory, ParameterSetName = 'Remove')]
[switch]
$RemoveFixture
)
$ErrorActionPreference = 'Stop'
$currentPrincipal = New-Object -TypeName 'System.Security.Principal.WindowsPrincipal' -ArgumentList (
[System.Security.Principal.WindowsIdentity]::GetCurrent()
)
if (-not $currentPrincipal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'This script must run in an elevated (Run as Administrator) PowerShell session.'
}
$shareName = 'NTFSSecurityLive'
$shareLocalPath = 'C:\NTFSSecurityLive'
$payloadPath = 'C:\NTFSSecurityLab'
$organizationalUnitName = 'NTFSSecurityLive'
$roleAccounts = [ordered]@{
Delegate = 'NtfsLiveDelegate'
ServerAdmin = 'NtfsLiveServerAdmin'
Admin = 'NtfsLiveAdmin'
}
$subjectAccount = 'NtfsLiveSubject'
$orphanAccount = 'NtfsLiveOrphan'
$localGroupName = 'NtfsLiveLocal'
$groupMembers = @{
NtfsLiveDelegates = @('NtfsLiveDelegate')
NtfsLiveInner = @('NtfsLiveSubject')
NtfsLiveOuter = @('NtfsLiveInner')
}
# A name that no DNS server resolves (RFC 2606)
$unreachableServerName = 'ntfssecurity-live.invalid'
# ReadAndExecute through the nested domain groups, Write through the local group of the file server, with Synchronize
$expectedFileServerRights = 0x1201BFL
$expectedClientRights = 0x1200A9L
$testFiles = 'NTFSSecurity.Live.Tests.ps1', 'NTFSSecurity.LabHelpers.ps1', 'Start-NTFSSecurityLiveTest.ps1' |
ForEach-Object -Process { Join-Path -Path $PSScriptRoot -ChildPath $_ }
function Write-LabProgress {
param ([string] $Message)
Write-Information -MessageData ('[{0:yyyy-MM-dd HH:mm:ss}] {1}' -f (Get-Date), $Message) -InformationAction Continue
}
function New-LabPassword {
<#
.SYNOPSIS
Returns a random password that meets the complexity rules of a domain.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates a string in memory only.'
)]
[CmdletBinding()]
[OutputType([securestring])]
param ()
$bytes = New-Object -TypeName 'byte[]' -ArgumentList 24
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($bytes)
}
finally {
$generator.Dispose()
}
# Base64 has upper case and lower case letters and digits; the suffix adds the other classes.
$password = New-Object -TypeName 'System.Security.SecureString'
foreach ($character in ([Convert]::ToBase64String($bytes) + '!a1Z').ToCharArray()) {
$password.AppendChar($character)
}
$password.MakeReadOnly()
$password
}
function Get-NTFSSecurityPackage {
<#
.SYNOPSIS
Downloads a version of NTFSSecurity from the PowerShell Gallery, checks its hash, and extracts the module.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory)]
[string]
$Version,
[Parameter(Mandatory)]
[string]
$Destination
)
$entry = Invoke-RestMethod -Uri ("https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='{0}')" -f $Version)
$expectedHash = [string]$entry.entry.properties.PackageHash
if ($entry.entry.properties.PackageHashAlgorithm -ne 'SHA512' -or -not $expectedHash) {
throw "The PowerShell Gallery has no SHA512 hash for NTFSSecurity $Version."
}
$package = Join-Path -Path $Destination -ChildPath "NTFSSecurity.$Version.nupkg"
if (-not (Test-Path -LiteralPath $package)) {
Invoke-WebRequest -Uri "https://www.powershellgallery.com/api/v2/package/NTFSSecurity/$Version" -OutFile $package -UseBasicParsing
}
$sha512 = [System.Security.Cryptography.SHA512]::Create()
try {
$actualHash = [Convert]::ToBase64String($sha512.ComputeHash([System.IO.File]::ReadAllBytes($package)))
}
finally {
$sha512.Dispose()
}
if ($actualHash -ne $expectedHash) {
Remove-Item -LiteralPath $package
throw "The package of NTFSSecurity $Version doesn't have the hash that the PowerShell Gallery publishes."
}
# Expand-Archive of Windows PowerShell rejects the extension .nupkg.
$folder = Join-Path -Path $Destination -ChildPath $Version
$moduleFolder = Join-Path -Path $folder -ChildPath 'NTFSSecurity'
if (Test-Path -LiteralPath $folder) {
Remove-Item -LiteralPath $folder -Recurse -Force
}
Add-Type -AssemblyName 'System.IO.Compression.FileSystem'
[System.IO.Compression.ZipFile]::ExtractToDirectory($package, $moduleFolder)
foreach ($name in '_rels', 'package', '[Content_Types].xml', 'NTFSSecurity.nuspec') {
$item = Join-Path -Path $moduleFolder -ChildPath $name
if (Test-Path -LiteralPath $item) {
Remove-Item -LiteralPath $item -Recurse -Force
}
}
$moduleVersion = Get-LabModuleVersion -Path $moduleFolder
if ($moduleVersion -ne $Version) {
throw "The package of NTFSSecurity $Version contains the version $moduleVersion."
}
[pscustomobject]@{
Label = $Version
Folder = $folder
ModuleVersion = $moduleVersion
}
}
function Get-LabModuleVersion {
<#
.SYNOPSIS
Returns the version of the module in a folder, with the prerelease label, from its manifest.
#>
[CmdletBinding()]
[OutputType([string])]
param (
[Parameter(Mandatory)]
[string]
$Path
)
$manifest = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $Path -ChildPath 'NTFSSecurity.psd1')
$moduleVersion = $manifest.ModuleVersion
if ($manifest.PrivateData.PSData.Prerelease) {
$moduleVersion = '{0}-{1}' -f $moduleVersion, $manifest.PrivateData.PSData.Prerelease
}
$moduleVersion
}
function ConvertFrom-LabTestResult {
<#
.SYNOPSIS
Returns the tests of a result file that Start-NTFSSecurityLiveTest.ps1 wrote, with their result and message.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param (
[Parameter(Mandatory)]
[string]
$Json
)
# Windows PowerShell returns a JSON array as one object, which foreach enumerates.
$tests = ConvertFrom-Json -InputObject $Json
foreach ($test in $tests) {
[pscustomobject]@{
Name = $test.Name
Result = $test.Result
Message = [string]$test.Message
}
}
}
#region Remote script blocks
# Runs on the domain controller: creates or updates the accounts and groups in their organizational unit, pushes them
# to the other domain controllers of the domain, and returns their SIDs.
$accountScript = {
param ($OrganizationalUnitName, [hashtable] $Password, [hashtable] $GroupMember)
$ErrorActionPreference = 'Stop'
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$server = $domain.PDCEmulator
$path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $server)) {
New-ADOrganizationalUnit -Name $OrganizationalUnitName -Path $domain.DistinguishedName -ProtectedFromAccidentalDeletion $false -Server $server
}
foreach ($name in $Password.Keys) {
$user = Get-ADUser -LDAPFilter "(sAMAccountName=$name)" -Server $server
if ($user -and $user.DistinguishedName -notlike "*,$path") {
throw "The account '$name' exists outside '$path'."
}
if ($user) {
Set-ADAccountPassword -Identity $user -Reset -NewPassword $Password[$name] -Server $server
Enable-ADAccount -Identity $user -Server $server
}
else {
New-ADUser -Name $name -SamAccountName $name -UserPrincipalName "$name@$($domain.DNSRoot)" -Path $path -AccountPassword $Password[$name] -Enabled $true -PasswordNeverExpires $true -Server $server
}
}
# All groups exist before the memberships are set, because a group can be a member of another one.
$groups = @{}
foreach ($name in $GroupMember.Keys) {
$group = Get-ADGroup -LDAPFilter "(sAMAccountName=$name)" -Server $server
if ($group -and $group.DistinguishedName -notlike "*,$path") {
throw "The group '$name' exists outside '$path'."
}
if (-not $group) {
$group = New-ADGroup -Name $name -SamAccountName $name -GroupScope Global -GroupCategory Security -Path $path -Server $server -PassThru
}
$groups[$name] = $group
}
foreach ($name in $GroupMember.Keys) {
$group = $groups[$name]
$current = @(Get-ADGroupMember -Identity $group -Server $server | ForEach-Object -Process { $_.SamAccountName })
$wanted = @($GroupMember[$name])
$missing = @($wanted | Where-Object -FilterScript { $_ -notin $current })
$extra = @($current | Where-Object -FilterScript { $_ -notin $wanted })
if ($missing) {
Add-ADGroupMember -Identity $group -Members $missing -Server $server
}
if ($extra) {
Remove-ADGroupMember -Identity $group -Members $extra -Server $server -Confirm:$false
}
}
# The other domain controllers replicate on their own schedule, and the computers of the domain may ask them.
$partners = @(Get-ADDomainController -Filter * -Server $server | Where-Object -FilterScript { $_.HostName -ne $server })
foreach ($partner in $partners) {
foreach ($object in Get-ADObject -SearchBase $path -Filter * -Server $server) {
Sync-ADObject -Object $object.DistinguishedName -Source $server -Destination $partner.HostName
}
}
$sids = @{}
foreach ($name in @($Password.Keys) + @($GroupMember.Keys)) {
$sids[$name] = (Get-ADObject -LDAPFilter "(sAMAccountName=$name)" -Properties objectSid -Server $server).objectSid.Value
}
$current = [System.Security.Principal.WindowsIdentity]::GetCurrent()
[pscustomobject]@{
DomainName = $domain.DNSRoot
NetBiosName = $domain.NetBIOSName
Sids = $sids
InstallName = $current.Name
InstallSid = $current.User.Value
}
}
# Runs on the domain controller: creates the account whose entry becomes orphaned in a run.
$newOrphanScript = {
param ($OrganizationalUnitName, $Name, [securestring] $Password)
$ErrorActionPreference = 'Stop'
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$server = $domain.PDCEmulator
$path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName
# A run that stopped early may have left the account.
Get-ADUser -LDAPFilter "(sAMAccountName=$Name)" -SearchBase $path -Server $server | Remove-ADUser -Server $server -Confirm:$false
$user = New-ADUser -Name $Name -SamAccountName $Name -Path $path -AccountPassword $Password -Enabled $false -Server $server -PassThru
[pscustomobject]@{
Sid = $user.SID.Value
Guid = $user.ObjectGUID.Guid
}
}
# Runs on the domain controller: deletes the account and waits until no domain controller of the domain knows it.
# Sync-ADObject can't push a deleted object, so the other domain controllers pull the domain partition.
$removeOrphanScript = {
param ($Guid)
$ErrorActionPreference = 'Stop'
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$server = $domain.PDCEmulator
Remove-ADUser -Identity $Guid -Server $server -Confirm:$false
foreach ($partner in @(Get-ADDomainController -Filter * -Server $server | Where-Object -FilterScript { $_.HostName -ne $server })) {
$deadline = (Get-Date).AddMinutes(3)
while (Get-ADObject -Filter "ObjectGUID -eq '$Guid'" -Server $partner.HostName) {
if ((Get-Date) -gt $deadline) {
throw "The domain controller $($partner.HostName) still knows the deleted account after 3 minutes."
}
$null = & repadmin.exe /replicate $partner.HostName $server $domain.DistinguishedName 2>&1
Start-Sleep -Seconds 10
}
}
}
# Runs on the file server once: the local group, the members of Administrators, the share, and the tools folder.
$fileServerSetupScript = {
param ($ShareName, $ShareLocalPath, $PayloadPath, $LocalGroupName, $SubjectSid, $AdministratorSid, $DelegatesSid)
$ErrorActionPreference = 'Stop'
if (-not (Get-LocalGroup -Name $LocalGroupName -ErrorAction SilentlyContinue)) {
$null = New-LocalGroup -Name $LocalGroupName -Description 'NTFSSecurity live tests'
}
if ($SubjectSid -notin @(Get-LocalGroupMember -Name $LocalGroupName | ForEach-Object -Process { $_.SID.Value })) {
Add-LocalGroupMember -Name $LocalGroupName -Member $SubjectSid
}
foreach ($sid in $AdministratorSid) {
if ($sid -notin @(Get-LocalGroupMember -SID 'S-1-5-32-544' | ForEach-Object -Process { $_.SID.Value })) {
Add-LocalGroupMember -SID 'S-1-5-32-544' -Member $sid
}
}
if (-not (Test-Path -LiteralPath $ShareLocalPath)) {
$null = New-Item -ItemType Directory -Path $ShareLocalPath
}
# The folders of earlier runs, also those with paths longer than 260 characters, which PowerShell 7 removes.
$command = '$ErrorActionPreference = ''Stop''; Get-ChildItem -LiteralPath ''{0}'' -Force | Remove-Item -Recurse -Force' -f $ShareLocalPath
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1
if ($LASTEXITCODE -ne 0) {
throw "The folders of earlier runs could not be removed: $output"
}
# Administrators and the system own the share; the delegated group may read it, and the folders of the cases grant
# what each case needs.
$administrators = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-544'
$acl = New-Object -TypeName 'System.Security.AccessControl.DirectorySecurity'
$acl.SetOwner($administrators)
$acl.SetAccessRuleProtection($true, $false)
foreach ($entry in @(
@{ Sid = 'S-1-5-32-544'; Rights = 'FullControl' }
@{ Sid = 'S-1-5-18'; Rights = 'FullControl' }
@{ Sid = $DelegatesSid; Rights = 'ReadAndExecute' }
)) {
$identity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $entry.Sid
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList $identity, $entry.Rights, 'ContainerInherit, ObjectInherit', 'None', 'Allow'))
}
Set-Acl -LiteralPath $ShareLocalPath -AclObject $acl
if (-not (Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue)) {
$null = New-SmbShare -Name $ShareName -Path $ShareLocalPath -FullAccess 'Everyone' -Description 'NTFSSecurity live tests'
}
foreach ($folder in 'Tests', 'Configuration') {
$null = New-Item -ItemType Directory -Path (Join-Path -Path $PayloadPath -ChildPath $folder) -Force
}
(Get-LocalGroup -Name $LocalGroupName).SID.Value
}
# Runs on the client once: the members of Administrators and Remote Management Users, and an empty payload folder.
$clientSetupScript = {
param ($PayloadPath, $AdministratorSid, $RemoteUserSid)
$ErrorActionPreference = 'Stop'
foreach ($membership in @(
@{ Group = 'S-1-5-32-544'; Members = $AdministratorSid }
@{ Group = 'S-1-5-32-580'; Members = $RemoteUserSid }
)) {
foreach ($sid in $membership.Members) {
if ($sid -notin @(Get-LocalGroupMember -SID $membership.Group | ForEach-Object -Process { $_.SID.Value })) {
Add-LocalGroupMember -SID $membership.Group -Member $sid
}
}
}
if (Test-Path -LiteralPath $PayloadPath) {
Remove-Item -LiteralPath $PayloadPath -Recurse -Force
}
foreach ($folder in 'Modules', 'Tests', 'Configuration') {
$null = New-Item -ItemType Directory -Path (Join-Path -Path $PayloadPath -ChildPath $folder) -Force
}
}
# Runs on the file server for each run: creates the folders of the cases below the share and returns what the
# configuration needs. Each case and operation gets its own folder, so that the tests don't depend on each other.
$fixtureScript = {
param ($HelperScript, $ShareLocalPath, $RunId, $Sid, $SubjectPrincipalName, $LongPathSegment)
$ErrorActionPreference = 'Stop'
. ([scriptblock]::Create($HelperScript))
$root = Join-Path -Path $ShareLocalPath -ChildPath $RunId
if (Test-Path -LiteralPath $root) {
throw "The folder '$root' exists already."
}
$administrators = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-544'
function New-AccessRule {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object in memory only.'
)]
param ([string] $Sid, [string] $Rights)
$identity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $Sid
New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList $identity, $Rights, 'ContainerInherit, ObjectInherit', 'None', 'Allow'
}
function New-FixtureFolder {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Runs in the fixture that the script confirmed.'
)]
param ([string] $RelativePath, [object[]] $AccessRule = @(), [switch] $Protected, [switch] $RemoveInherited,
[switch] $Audit, [switch] $LegacyDacl)
$path = Join-Path -Path $root -ChildPath $RelativePath
$null = New-Item -ItemType Directory -Path $path -Force
$acl = Get-Acl -LiteralPath $path
$acl.SetOwner($administrators)
if ($Protected) {
$acl.SetAccessRuleProtection($true, -not $RemoveInherited)
}
foreach ($rule in $AccessRule) {
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $path -AclObject $acl
if ($Audit) {
$auditAcl = Get-Acl -LiteralPath $path -Audit
$everyone = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0'
$auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'None', 'None', 'Success'))
Set-Acl -LiteralPath $path -AclObject $auditAcl
}
# Set-Acl adds the auto-inherit flag, so the DACL is stored again without it at the end.
if ($LegacyDacl) {
Set-LabLegacyDacl -Path $path -Confirm:$false
}
if ((Get-LabSecurityDescriptor -Path $path).Owner.Value -ne 'S-1-5-32-544') {
throw "Administrators don't own '$path'."
}
if ((Test-LabDaclAutoInherited -Path $path) -eq [bool]$LegacyDacl) {
throw "The DACL of '$path' has the wrong auto-inherit flag."
}
$path
}
$null = New-Item -ItemType Directory -Path $root
$delegatesFullControl = New-AccessRule -Sid $Sid.NtfsLiveDelegates -Rights 'FullControl'
# Case 1 (#34): the delegated group has Full Control on folders that Administrators own.
foreach ($variant in 'LegacyDacl', 'AutoInheritedDacl') {
foreach ($operation in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance',
'SetInheritance', 'SetSecurityDescriptor') {
$rules = @($delegatesFullControl)
if ($operation -eq 'RemoveAccess') {
$rules += New-AccessRule -Sid 'S-1-1-0' -Rights 'ReadAndExecute'
}
$null = New-FixtureFolder -RelativePath "Case1\$variant\$operation" -AccessRule $rules -Protected:($operation -eq 'EnableInheritance') -LegacyDacl:($variant -eq 'LegacyDacl')
}
}
# Case 2: the same Full Control, and an audit entry on the folders whose entry the tests read or remove.
foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') {
$null = New-FixtureFolder -RelativePath "Case2\$auditRole\GetAudit" -AccessRule $delegatesFullControl -Audit
$null = New-FixtureFolder -RelativePath "Case2\$auditRole\AddAudit" -AccessRule $delegatesFullControl
$null = New-FixtureFolder -RelativePath "Case2\$auditRole\RemoveAudit" -AccessRule $delegatesFullControl -Audit
}
# Case 3: only these entries, so that the expected rights are known.
$effectivePath = New-FixtureFolder -RelativePath 'Case3\EffectiveAccess' -Protected -RemoveInherited -AccessRule @(
New-AccessRule -Sid 'S-1-5-32-544' -Rights 'FullControl'
New-AccessRule -Sid 'S-1-5-18' -Rights 'FullControl'
New-AccessRule -Sid $Sid.NtfsLiveOuter -Rights 'ReadAndExecute'
New-AccessRule -Sid $Sid.LocalGroup -Rights 'Write'
)
$effectiveDescriptor = Get-LabSecurityDescriptor -Path $effectivePath
$fileServerRights = Get-LabGrantedRight -Descriptor $effectiveDescriptor -Sid @(Get-LabTokenSid -UserPrincipalName $SubjectPrincipalName)
# Case 4: an entry for the account that the domain controller deletes next, and a file that inherits it.
$orphanPath = New-FixtureFolder -RelativePath 'Case4\OrphanedAccess' -AccessRule (New-AccessRule -Sid $Sid.Orphan -Rights 'Modify')
Set-Content -LiteralPath (Join-Path -Path $orphanPath -ChildPath 'File.txt') -Value 'Orphan'
# A file whose path on the share is longer than 260 characters, created by PowerShell 7, which handles such paths.
$longPath = New-FixtureFolder -RelativePath 'LongPath'
$longRelativePath = ($LongPathSegment -join '\') + '\File.txt'
$command = '$ErrorActionPreference = ''Stop''; $file = ''{0}''; $null = New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force; Set-Content -LiteralPath $file -Value ''LongPath''' -f (Join-Path -Path $longPath -ChildPath $longRelativePath)
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1
if ($LASTEXITCODE -ne 0) {
throw "The long path could not be created: $output"
}
$whatIfPath = New-FixtureFolder -RelativePath 'WhatIf'
Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Source.txt') -Value 'Source' -NoNewline
Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Destination.txt') -Value 'Destination' -NoNewline
[pscustomobject]@{
ServerPath = $root
FileServerRights = $fileServerRights
EffectiveAccessSddl = $effectiveDescriptor.GetSddlForm('All')
LongPath = $longRelativePath
}
}
# Runs on the client for each run: the rights that the client's own token for the account grants on the folder of
# case 3, and whether the client still resolves the SID of the deleted account.
$clientOracleScript = {
param ($HelperScript, $EffectiveAccessSddl, $SubjectPrincipalName, $OrphanSid)
$ErrorActionPreference = 'Stop'
. ([scriptblock]::Create($HelperScript))
$descriptor = New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList $EffectiveAccessSddl
$orphanResolved = $true
try {
$null = (New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $OrphanSid).Translate([System.Security.Principal.NTAccount])
}
catch [System.Security.Principal.IdentityNotMappedException] {
$orphanResolved = $false
}
[pscustomobject]@{
ClientRights = Get-LabGrantedRight -Descriptor $descriptor -Sid @(Get-LabTokenSid -UserPrincipalName $SubjectPrincipalName)
OrphanResolved = $orphanResolved
}
}
# Runs on the client and the file server: writes the configuration of a run.
$writeConfigurationScript = {
param ($Path, $Json)
$ErrorActionPreference = 'Stop'
Set-Content -LiteralPath $Path -Value $Json -Encoding UTF8
}
# Runs on the client as the account of a role, or on the file server: runs the tests of the role in a new process.
$runScript = {
param ($Edition, $TestPath, $ModulePath, $ConfigurationPath, $Role)
$executable = if ($Edition -eq 'Core') {
Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe'
}
else {
Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe'
}
$resultPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('NTFSSecurityLive-{0}.json' -f [guid]::NewGuid().ToString('N'))
$arguments = @(
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'
'-File', (Join-Path -Path $TestPath -ChildPath 'Start-NTFSSecurityLiveTest.ps1')
'-ConfigurationPath', $ConfigurationPath, '-Role', $Role, '-ResultPath', $resultPath
)
if ($ModulePath) {
$arguments += '-ModulePath', $ModulePath
}
$output = & $executable @arguments 2>&1 | ForEach-Object -Process { "$_" }
$exitCode = $LASTEXITCODE
$result = if (Test-Path -LiteralPath $resultPath) { Get-Content -LiteralPath $resultPath -Raw }
Remove-Item -LiteralPath $resultPath -ErrorAction SilentlyContinue
[pscustomobject]@{
Account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
ExitCode = $exitCode
Output = $output -join [Environment]::NewLine
Result = $result
}
}
# Runs on the file server after the tests of a run: the stored owner, group, and DACL, and the SACL of each folder.
$stateScript = {
param ($HelperScript, $Root)
$ErrorActionPreference = 'Stop'
. ([scriptblock]::Create($HelperScript))
foreach ($folder in Get-ChildItem -LiteralPath $Root -Directory -Recurse -Depth 2) {
[pscustomobject]@{
Path = $folder.FullName.Substring($Root.Length + 1)
Stored = (Get-LabSecurityDescriptor -Path $folder.FullName).GetSddlForm('All')
Sacl = (Get-Acl -LiteralPath $folder.FullName -Audit).GetSecurityDescriptorSddlForm('Audit')
}
}
}
# Runs on the domain controller: the SIDs of the accounts in the organizational unit, for -RemoveFixture.
$accountSidScript = {
param ($OrganizationalUnitName)
$ErrorActionPreference = 'Stop'
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName
if (Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator) {
Get-ADUser -Filter * -SearchBase $path -Server $domain.PDCEmulator | ForEach-Object -Process { $_.SID.Value }
}
}
$removeFileServerScript = {
param ($ShareName, $ShareLocalPath, $PayloadPath, $LocalGroupName, $Sid)
$ErrorActionPreference = 'Stop'
if (Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue) {
Remove-SmbShare -Name $ShareName -Force
}
foreach ($path in $ShareLocalPath, $PayloadPath) {
if (Test-Path -LiteralPath $path) {
$command = '$ErrorActionPreference = ''Stop''; Remove-Item -LiteralPath ''{0}'' -Recurse -Force' -f $path
$output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1
if ($LASTEXITCODE -ne 0) {
throw "'$path' could not be removed: $output"
}
}
}
if (Get-LocalGroup -Name $LocalGroupName -ErrorAction SilentlyContinue) {
Remove-LocalGroup -Name $LocalGroupName
}
foreach ($accountSid in $Sid) {
Remove-LocalGroupMember -SID 'S-1-5-32-544' -Member $accountSid -ErrorAction SilentlyContinue
}
}
$removeClientScript = {
param ($PayloadPath, $Sid)
$ErrorActionPreference = 'Stop'
foreach ($group in 'S-1-5-32-544', 'S-1-5-32-580') {
foreach ($accountSid in $Sid) {
Remove-LocalGroupMember -SID $group -Member $accountSid -ErrorAction SilentlyContinue
}
}
Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid } | Remove-CimInstance
if (Test-Path -LiteralPath $PayloadPath) {
Remove-Item -LiteralPath $PayloadPath -Recurse -Force
}
}
$removeAccountScript = {
param ($OrganizationalUnitName)
$ErrorActionPreference = 'Stop'
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$unit = Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator
if ($unit) {
Remove-ADOrganizationalUnit -Identity $unit -Recursive -Server $domain.PDCEmulator -Confirm:$false
}
}
#endregion Remote script blocks
Import-Module -Name AutomatedLab -ErrorAction Stop
Import-Lab -Name $LabName -NoValidation -NoDisplay
$machines = foreach ($name in $DomainController, $FileServer, $Client) {
$machine = Get-LabVM -ComputerName $name
if (-not $machine) {
throw "The lab '$LabName' has no machine '$name'."
}
$machine
}
if (@($machines | ForEach-Object -Process { $_.DomainName } | Select-Object -Unique).Count -ne 1) {
throw 'The domain controller, the file server, and the client must belong to one domain.'
}
$helperScript = Get-Content -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1') -Raw
$labCommand = @{
NoDisplay = $true
PassThru = $true
ErrorAction = 'Stop'
}
if ($RemoveFixture) {
if (-not $PSCmdlet.ShouldProcess("lab '$LabName'", 'Remove the accounts, the share, and the folders of the live tests')) {
return
}
$accountSids = @(Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Read the accounts' -ScriptBlock $accountSidScript -ArgumentList $organizationalUnitName @labCommand)
$null = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand
$null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Remove the members and the folder' -ScriptBlock $removeClientScript -ArgumentList $payloadPath, $accountSids @labCommand
$null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Remove the accounts' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand
Write-LabProgress "Removed the live tests from the lab '$LabName'."
return
}
$labels = @($Version) + @(if ($ModulePath) { 'local' })
if (-not $PSCmdlet.ShouldProcess("lab '$LabName'", "Prepare the live tests and run them against $($labels -join ', ')")) {
return
}
$resultFolder = Join-Path -Path $OutputPath -ChildPath ('Results\{0:yyyyMMdd-HHmmss}' -f (Get-Date))
$packageFolder = Join-Path -Path $OutputPath -ChildPath 'Packages'
$null = New-Item -ItemType Directory -Path $resultFolder, $packageFolder -Force
Write-LabProgress "START live tests in the lab '$LabName' against $($labels -join ', ') in $($Edition -join ', '); results in $resultFolder"
$modules = @(
foreach ($item in $Version) {
Get-NTFSSecurityPackage -Version $item -Destination $packageFolder
}
if ($ModulePath) {
$folder = Join-Path -Path $packageFolder -ChildPath 'local'
if (Test-Path -LiteralPath $folder) {
Remove-Item -LiteralPath $folder -Recurse -Force
}
$null = New-Item -ItemType Directory -Path $folder
Copy-Item -LiteralPath (Resolve-Path -LiteralPath $ModulePath).ProviderPath -Destination (Join-Path -Path $folder -ChildPath 'NTFSSecurity') -Recurse
[pscustomobject]@{
Label = 'local'
Folder = $folder
ModuleVersion = Get-LabModuleVersion -Path (Join-Path -Path $folder -ChildPath 'NTFSSecurity')
}
}
)
Write-LabProgress 'Preparing the accounts, the file server, and the client'
$passwords = @{}
foreach ($name in @($roleAccounts.Values) + $subjectAccount) {
$passwords[$name] = New-LabPassword
}
$directory = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Prepare the accounts' -ScriptBlock $accountScript -ArgumentList $organizationalUnitName, $passwords, $groupMembers @labCommand
$sids = $directory.Sids
$localGroupSid = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Prepare the file server' -ScriptBlock $fileServerSetupScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $sids[$subjectAccount], @($sids['NtfsLiveServerAdmin'], $sids['NtfsLiveAdmin']), $sids['NtfsLiveDelegates'] @labCommand
$null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Prepare the client' -ScriptBlock $clientSetupScript -ArgumentList $payloadPath, @($sids['NtfsLiveDelegate'], $sids['NtfsLiveAdmin']), @($sids['NtfsLiveServerAdmin']) @labCommand
foreach ($computer in $Client, $FileServer) {
Copy-LabFileItem -Path $testFiles -ComputerName $computer -DestinationFolderPath (Join-Path -Path $payloadPath -ChildPath 'Tests')
}
foreach ($module in $modules) {
Copy-LabFileItem -Path $module.Folder -ComputerName $Client -DestinationFolderPath (Join-Path -Path $payloadPath -ChildPath 'Modules') -Recurse
}
$expectedFiles = @($modules | ForEach-Object -Process { Join-Path -Path $payloadPath -ChildPath ('Modules\{0}\NTFSSecurity\NTFSSecurity.psd1' -f $_.Label) }) +
@($testFiles | ForEach-Object -Process { Join-Path -Path $payloadPath -ChildPath ('Tests\{0}' -f (Split-Path -Path $_ -Leaf)) })
$missingFiles = @(Invoke-LabCommand -ComputerName $Client -ActivityName 'Check the payload' -ScriptBlock {
param ($Path)
$Path | Where-Object -FilterScript { -not (Test-Path -LiteralPath $_ -PathType Leaf) }
} -ArgumentList (, $expectedFiles) @labCommand)
if ($missingFiles.Count -gt 0) {
throw "The client lacks these files: $($missingFiles -join ', ')"
}
$credentials = @{}
foreach ($role in $roleAccounts.Keys) {
$userName = '{0}\{1}' -f $directory.NetBiosName, $roleAccounts[$role]
$credentials[$role] = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $userName, $passwords[$roleAccounts[$role]]
}
$clientAddress = (Get-LabVM -ComputerName $Client).IpV4Address
$fileServerFqdn = '{0}.{1}' -f $FileServer, $directory.DomainName
$subjectPrincipalName = '{0}@{1}' -f $subjectAccount, $directory.DomainName
$longPathSegments = 1..6 | ForEach-Object -Process { 'Segment{0:D2}-{1}' -f $_, ('x' * 40) }
$summary = New-Object -TypeName 'System.Collections.Generic.List[object]'
foreach ($module in $modules) {
foreach ($runEdition in $Edition) {
$runId = '{0}-{1}-{2:yyyyMMddHHmmss}' -f $module.Label, $runEdition, (Get-Date)
Write-LabProgress "Run ${runId}: preparing the folders"
$orphan = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Create the orphan account' -ScriptBlock $newOrphanScript -ArgumentList $organizationalUnitName, $orphanAccount, (New-LabPassword) @labCommand
$fixtureSids = @{
NtfsLiveDelegates = $sids['NtfsLiveDelegates']
NtfsLiveOuter = $sids['NtfsLiveOuter']
LocalGroup = [string]$localGroupSid
Orphan = $orphan.Sid
}
$fixture = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Create the folders of the run' -ScriptBlock $fixtureScript -ArgumentList $helperScript, $shareLocalPath, $runId, $fixtureSids, $subjectPrincipalName, $longPathSegments @labCommand
$null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Delete the orphan account' -ScriptBlock $removeOrphanScript -ArgumentList $orphan.Guid @labCommand
$oracle = Invoke-LabCommand -ComputerName $Client -ActivityName 'Calculate the rights on the client' -ScriptBlock $clientOracleScript -ArgumentList $helperScript, $fixture.EffectiveAccessSddl, $subjectPrincipalName, $orphan.Sid @labCommand
if ($oracle.OrphanResolved) {
throw "The client still resolves the SID $($orphan.Sid) of the deleted account."
}
if ($fixture.FileServerRights -ne $expectedFileServerRights -or $oracle.ClientRights -ne $expectedClientRights) {
throw ('The tokens of {0} grant 0x{1:X} on the file server and 0x{2:X} on the client instead of 0x{3:X} and 0x{4:X}.' -f
$subjectAccount, [long]$fixture.FileServerRights, [long]$oracle.ClientRights, $expectedFileServerRights, $expectedClientRights)
}
$configuration = [ordered]@{
RunId = $runId
ModuleVersion = $module.ModuleVersion
Edition = $runEdition
DomainName = $directory.DomainName
Client = $Client
FileServer = $FileServer
FileServerFqdn = $fileServerFqdn
ShareName = $shareName
ShareLocalPath = $shareLocalPath
SharePath = '\\{0}\{1}\{2}' -f $fileServerFqdn, $shareName, $runId
ServerPath = $fixture.ServerPath
UnreachableServerName = $unreachableServerName
LongPath = $fixture.LongPath
EffectiveAccess = [ordered]@{
FileServerRights = [long]$fixture.FileServerRights
ClientRights = [long]$oracle.ClientRights
}
Accounts = [ordered]@{
Delegate = [ordered]@{ Name = $credentials['Delegate'].UserName; Sid = $sids['NtfsLiveDelegate']; ClientAdministrator = $true; FileServerAdministrator = $false }
ServerAdmin = [ordered]@{ Name = $credentials['ServerAdmin'].UserName; Sid = $sids['NtfsLiveServerAdmin']; ClientAdministrator = $false; FileServerAdministrator = $true }
Admin = [ordered]@{ Name = $credentials['Admin'].UserName; Sid = $sids['NtfsLiveAdmin']; ClientAdministrator = $true; FileServerAdministrator = $true }
Server = [ordered]@{ Name = $directory.InstallName; Sid = $directory.InstallSid; ClientAdministrator = $true; FileServerAdministrator = $true }
Subject = [ordered]@{ Name = '{0}\{1}' -f $directory.NetBiosName, $subjectAccount; Sid = $sids[$subjectAccount] }
Orphan = [ordered]@{ Name = '{0}\{1}' -f $directory.NetBiosName, $orphanAccount; Sid = $orphan.Sid }
}
}
$json = $configuration | ConvertTo-Json -Depth 5
Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$runId.json") -Value $json
$configurationPath = Join-Path -Path $payloadPath -ChildPath "Configuration\$runId.json"
foreach ($computer in $Client, $FileServer) {
$null = Invoke-LabCommand -ComputerName $computer -ActivityName 'Write the configuration' -ScriptBlock $writeConfigurationScript -ArgumentList $configurationPath, $json @labCommand
}
$runs = @(
foreach ($role in $roleAccounts.Keys) {
@{ Role = $role; Edition = $runEdition; ModulePath = Join-Path -Path $payloadPath -ChildPath ('Modules\{0}\NTFSSecurity' -f $module.Label) }
}
@{ Role = 'Server'; Edition = 'Desktop'; ModulePath = '' }
)
foreach ($run in $runs) {
Write-LabProgress "Run ${runId}: role $($run.Role)"
$arguments = $run.Edition, (Join-Path -Path $payloadPath -ChildPath 'Tests'), $run.ModulePath, $configurationPath, $run.Role
if ($run.Role -eq 'Server') {
$outcome = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Run the tests of the role Server' -ScriptBlock $runScript -ArgumentList $arguments @labCommand
}
else {
$session = New-PSSession -ComputerName $clientAddress -Credential $credentials[$run.Role] -Authentication Credssp
try {
$outcome = Invoke-Command -Session $session -ScriptBlock $runScript -ArgumentList $arguments
}
finally {
Remove-PSSession -Session $session
}
}
$baseName = '{0}-{1}' -f $runId, $run.Role
Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$baseName.log") -Value $outcome.Output
$cases = @()
if ($outcome.Result) {
Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$baseName.result.json") -Value $outcome.Result
$cases = @(ConvertFrom-LabTestResult -Json $outcome.Result)
}
$failures = @($cases | Where-Object -FilterScript { $_.Result -eq 'Failed' })
$entry = [pscustomobject]@{
Version = $module.Label
Edition = $runEdition
Role = $run.Role
Account = $outcome.Account
ExitCode = $outcome.ExitCode
Passed = @($cases | Where-Object -FilterScript { $_.Result -eq 'Passed' }).Count
Failed = $failures.Count
Skipped = @($cases | Where-Object -FilterScript { $_.Result -notin 'Passed', 'Failed' }).Count
Failures = @($failures | ForEach-Object -Process { [pscustomobject]@{ Name = $_.Name; Message = $_.Message } })
}
$summary.Add($entry)
Write-LabProgress ('Run {0}: role {1} as {2}: {3} passed, {4} failed, {5} skipped, exit code {6}' -f
$runId, $run.Role, $entry.Account, $entry.Passed, $entry.Failed, $entry.Skipped, $entry.ExitCode)
}
$state = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Read the security descriptors of the run' -ScriptBlock $stateScript -ArgumentList $helperScript, $fixture.ServerPath @labCommand
$state | Select-Object -Property Path, Stored, Sacl | ConvertTo-Json -Depth 3 |
Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath "$runId-State.json")
}
}
$summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath 'Summary.json')
$report = New-Object -TypeName 'System.Collections.Generic.List[string]'
$report.Add('# NTFSSecurity live tests')
$report.Add('')
$report.Add(('Lab `{0}`: client `{1}`, file server `{2}`, domain controller `{3}`.' -f $LabName, $Client, $FileServer, $DomainController))
$report.Add('')
$report.Add('| Version | Edition | Role | Passed | Failed | Skipped |')
$report.Add('| --- | --- | --- | ---: | ---: | ---: |')
foreach ($entry in $summary) {
$report.Add(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $entry.Version, $entry.Edition, $entry.Role, $entry.Passed, $entry.Failed, $entry.Skipped))
}
foreach ($entry in $summary | Where-Object -FilterScript { $_.Failed -gt 0 -or -not $_.Passed }) {
$report.Add('')
$report.Add(('## {0}, {1}, {2}' -f $entry.Version, $entry.Edition, $entry.Role))
$report.Add('')
foreach ($failure in $entry.Failures) {
$report.Add(('- {0}: {1}' -f $failure.Name, (($failure.Message -split '\r?\n')[0])))
}
if (-not $entry.Passed -and -not $entry.Failures) {
$report.Add('- No test ran; see the log of the role.')
}
}
Set-Content -LiteralPath (Join-Path -Path $resultFolder -ChildPath 'Summary.md') -Value $report
Write-LabProgress "DONE live tests; results in $resultFolder"
$summary