From 5b71ab5e83933d527435016e2aa9d238ea1bf69a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Wed, 7 Oct 2026 16:21:21 +0000 Subject: [PATCH] test: add live tests in a lab Add Tests\Lab, which runs the module against a Windows file server with domain accounts in an AutomatedLab lab: #34 over SMB, the audit cmdlets over SMB, Get-NTFSEffectiveAccess with domain and file server groups, Get-NTFSOrphanedAccess with a deleted domain account, long paths on a share, and #108. Invoke-NTFSSecurityLabTest.ps1 prepares the lab and runs the tests per module version and PowerShell edition; without a lab, every live test skips. CI excludes the folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Invoke-Tests.ps1 | 7 +- Docs/Contributing/02-Writing.md | 4 + Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 | 1010 ++++++++++++++++++++++ Tests/Lab/NTFSSecurity.LabHelpers.ps1 | 238 +++++ Tests/Lab/NTFSSecurity.Live.Tests.ps1 | 468 ++++++++++ Tests/Lab/README.md | 124 +++ Tests/Lab/Start-NTFSSecurityLiveTest.ps1 | 85 ++ 7 files changed, 1934 insertions(+), 2 deletions(-) create mode 100644 Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 create mode 100644 Tests/Lab/NTFSSecurity.LabHelpers.ps1 create mode 100644 Tests/Lab/NTFSSecurity.Live.Tests.ps1 create mode 100644 Tests/Lab/README.md create mode 100644 Tests/Lab/Start-NTFSSecurityLiveTest.ps1 diff --git a/.github/scripts/Invoke-Tests.ps1 b/.github/scripts/Invoke-Tests.ps1 index 2fdc016..7c7d997 100644 --- a/.github/scripts/Invoke-Tests.ps1 +++ b/.github/scripts/Invoke-Tests.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Imports Pester 5.7.1, runs the tests against the module build in NTFSSecurity\bin\Release, writes the result file in the NUnit format, and adds the counts and the failed tests to the job summary of GitHub Actions. Fails if - a test or a test file fails. + a test or a test file fails. The live tests in Tests\Lab need a lab and don't run here. .PARAMETER ResultPath Specifies the path of the result file. @@ -39,8 +39,11 @@ if ($resultFolder -and -not (Test-Path -LiteralPath $resultFolder)) { New-Item -ItemType Directory -Path $resultFolder | Out-Null } +$testsPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests')).ProviderPath $configuration = New-PesterConfiguration -$configuration.Run.Path = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests' +$configuration.Run.Path = $testsPath +# The live tests in Tests\Lab need a lab (Tests\Lab\README.md). Pester matches the full path of each test file. +$configuration.Run.ExcludePath = '{0}\Lab\*' -f [WildcardPattern]::Escape($testsPath) $configuration.Run.PassThru = $true $configuration.Output.Verbosity = 'Detailed' $configuration.TestResult.Enabled = $true diff --git a/Docs/Contributing/02-Writing.md b/Docs/Contributing/02-Writing.md index 98c78a2..7da169a 100644 --- a/Docs/Contributing/02-Writing.md +++ b/Docs/Contributing/02-Writing.md @@ -127,6 +127,10 @@ Before you open a pull request, check the following: Invoke-Pester -Path .\Tests -Output Detailed ``` + The [live tests](../../Tests/Lab/README.md) in `Tests\Lab` need a lab with + a file server and domain accounts. Without one, they skip all their tests, + and the CI workflow doesn't run them. + - All links work. The CI workflow checks them with `Get-MarkdownLink` from the MarkdownLinkCheck module: diff --git a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 new file mode 100644 index 0000000..c8adcad --- /dev/null +++ b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 @@ -0,0 +1,1010 @@ +<# +.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 diff --git a/Tests/Lab/NTFSSecurity.LabHelpers.ps1 b/Tests/Lab/NTFSSecurity.LabHelpers.ps1 new file mode 100644 index 0000000..765ddd2 --- /dev/null +++ b/Tests/Lab/NTFSSecurity.LabHelpers.ps1 @@ -0,0 +1,238 @@ +<# + Helpers of the live tests in a lab (README.md). NTFSSecurity.Live.Tests.ps1 dot-sources this file on the client + and on the file server, and Invoke-NTFSSecurityLabTest.ps1 runs it on both to prepare the fixtures. Dot-sourcing + it only defines the helpers. +#> + +if (-not ('NTFSSecurityLab.NativeMethods' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace NTFSSecurityLab +{ + // GetFileSecurity and SetFileSecurity read and write a security descriptor as Windows stores it. + // GetNamedSecurityInfo and SetNamedSecurityInfo, which .NET and the module use, convert a DACL without the + // auto-inherit flag when they read it, and add the flag when they write a DACL. + public static class NativeMethods + { + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetFileSecurityW(string fileName, int requestedInformation, byte[] securityDescriptor, int length, out int lengthNeeded); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool SetFileSecurityW(string fileName, int securityInformation, byte[] securityDescriptor); + + public static byte[] GetFileSecurity(string path, int information) + { + int needed; + GetFileSecurityW(path, information, null, 0, out needed); + if (needed == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var buffer = new byte[needed]; + if (!GetFileSecurityW(path, information, buffer, buffer.Length, out needed)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return buffer; + } + + public static void SetFileSecurity(string path, int information, byte[] securityDescriptor) + { + if (!SetFileSecurityW(path, information, securityDescriptor)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + } +} +'@ +} + +function Get-LabSecurityDescriptor { + <# + .SYNOPSIS + Returns the owner, the group, and the DACL of a file or folder as Windows stores them. + .DESCRIPTION + GetNamedSecurityInfo returns the owner with a DACL without the auto-inherit flag even when only the DACL is + read, and converts such a DACL. This function reads with GetFileSecurity, which does neither. + .PARAMETER Path + A local path or a UNC path. The account needs the right to read the permissions. + #> + [CmdletBinding()] + [OutputType([System.Security.AccessControl.RawSecurityDescriptor])] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + # OWNER_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, and DACL_SECURITY_INFORMATION + $bytes = [NTFSSecurityLab.NativeMethods]::GetFileSecurity($Path, 7) + New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList $bytes, 0 +} + +function Test-LabDaclAutoInherited { + <# + .SYNOPSIS + Returns whether the stored DACL of a file or folder has the auto-inherit flag. + .PARAMETER Path + A local path or a UNC path. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + $autoInherited = [System.Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited + ((Get-LabSecurityDescriptor -Path $Path).ControlFlags -band $autoInherited) -ne 0 +} + +function Set-LabLegacyDacl { + <# + .SYNOPSIS + Stores the DACL of a file or folder again, without the auto-inherit flag. + .DESCRIPTION + Windows adds the flag whenever SetNamedSecurityInfo writes a DACL. SetFileSecurity stores the DACL as given, + like tools that predate Windows 2000. For such a DACL, GetNamedSecurityInfo returns the owner also when only + the DACL is read, and NTFSSecurity before 5.0.0-rc3 wrote that owner back (#34). + .PARAMETER Path + A local path. The account needs the right to change the permissions. + #> + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory)] + [string] + $Path + ) + + $descriptor = Get-LabSecurityDescriptor -Path $Path + $autoInherited = [System.Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited + $descriptor.SetFlags([System.Security.AccessControl.ControlFlags]($descriptor.ControlFlags -band -bnot $autoInherited)) + $bytes = New-Object -TypeName 'byte[]' -ArgumentList $descriptor.BinaryLength + $descriptor.GetBinaryForm($bytes, 0) + if ($PSCmdlet.ShouldProcess($Path, 'Store the DACL without the auto-inherit flag')) { + # DACL_SECURITY_INFORMATION + [NTFSSecurityLab.NativeMethods]::SetFileSecurity($Path, 4, $bytes) + } +} + +function Get-LabTokenSid { + <# + .SYNOPSIS + Returns the SIDs of the token that this computer creates for a domain account. + .DESCRIPTION + Logs the account on with Kerberos S4U, without its password, like the Effective Access tab of the advanced + security settings does. The token holds the account and all its groups that this computer knows: nested + domain groups and the local groups of this computer. + .PARAMETER UserPrincipalName + The user principal name of the account, such as user@contoso.com. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] + $UserPrincipalName + ) + + $identity = New-Object -TypeName 'System.Security.Principal.WindowsIdentity' -ArgumentList $UserPrincipalName + try { + $identity.User.Value + foreach ($group in $identity.Groups) { + $group.Value + } + } + finally { + $identity.Dispose() + } +} + +function Get-LabGrantedRight { + <# + .SYNOPSIS + Returns the access mask that the allow entries of a DACL grant to a set of SIDs. + .DESCRIPTION + Combines the entries that apply to the item itself and name one of the SIDs. A deny entry for one of the SIDs + makes the function throw, because the calculation doesn't cover it. + .PARAMETER Descriptor + The security descriptor of the item. + .PARAMETER Sid + The SIDs of a token, such as the output of Get-LabTokenSid. + #> + [CmdletBinding()] + [OutputType([long])] + param ( + [Parameter(Mandatory)] + [System.Security.AccessControl.RawSecurityDescriptor] + $Descriptor, + + [Parameter(Mandatory)] + [string[]] + $Sid + ) + + $granted = 0L + foreach ($ace in $Descriptor.DiscretionaryAcl) { + if ($ace -isnot [System.Security.AccessControl.CommonAce] -or $ace.SecurityIdentifier.Value -notin $Sid) { + continue + } + + # HasFlag, because Windows PowerShell can't apply -band to an enum of the type byte. + if ($ace.AceFlags.HasFlag([System.Security.AccessControl.AceFlags]::InheritOnly)) { + continue + } + + if ($ace.AceQualifier -ne [System.Security.AccessControl.AceQualifier]::AccessAllowed) { + throw "The DACL denies $($ace.SecurityIdentifier) access, which Get-LabGrantedRight doesn't calculate." + } + + $granted = $granted -bor ([long]$ace.AccessMask -band 0xFFFFFFFFL) + } + + $granted +} + +function Assert-LabTestTarget { + <# + .SYNOPSIS + Throws unless this process runs on the client or the file server that a configuration of the lab names, and + the folder of the run lies in the share of the lab. + .DESCRIPTION + The live tests change security descriptors and must never run on another computer, such as the host of the + lab or a workstation. + .PARAMETER Configuration + The configuration of the run that Invoke-NTFSSecurityLabTest.ps1 wrote. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [object] + $Configuration + ) + + # Without CIM, which an account that isn't an administrator can't use in a remote session + $domainName = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().DomainName + if ($domainName -ne $Configuration.DomainName) { + throw "The live tests run only on a computer of the lab domain '$($Configuration.DomainName)'." + } + + if ($env:COMPUTERNAME -notin $Configuration.Client, $Configuration.FileServer) { + throw "The live tests run only on '$($Configuration.Client)' and '$($Configuration.FileServer)', not on '$env:COMPUTERNAME'." + } + + $shareRoot = '\\{0}\{1}\' -f $Configuration.FileServerFqdn, $Configuration.ShareName + $comparison = [System.StringComparison]::OrdinalIgnoreCase + if (-not $Configuration.SharePath.StartsWith($shareRoot, $comparison) -or + -not $Configuration.ServerPath.StartsWith($Configuration.ShareLocalPath + '\', $comparison) -or + $Configuration.SharePath.Contains('..') -or $Configuration.ServerPath.Contains('..')) { + throw "The folder of the run must lie in the share '$shareRoot' of the lab." + } +} diff --git a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 new file mode 100644 index 0000000..7037d7e --- /dev/null +++ b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 @@ -0,0 +1,468 @@ +<# + Live tests of the module against a Windows file server in a lab, for the cases that depend on the file server or + on domain accounts and that the tests in the Tests folder can't cover. Invoke-NTFSSecurityLabTest.ps1 prepares the + lab and runs this file on the client in the roles Delegate, ServerAdmin, and Admin, as the accounts of these roles, + and then on the file server in the role Server, which checks the security descriptors that the runs on the client + left, without the module. README.md describes the cases and the lab. Without a configuration, all tests are + skipped. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', Justification = 'Pester passes the data of the container to the blocks.' +)] +param ( + [string] + $ModulePath, + + [string] + $ConfigurationPath, + + [ValidateSet('', 'Delegate', 'ServerAdmin', 'Admin', 'Server')] + [string] + $Role +) + +BeforeDiscovery { + $configured = -not [string]::IsNullOrEmpty($ConfigurationPath) + + $variants = @( + @{ Variant = 'LegacyDacl'; Description = 'a DACL without the auto-inherit flag'; AutoInherited = $false } + @{ Variant = 'AutoInheritedDacl'; Description = 'an auto-inherited DACL'; AutoInherited = $true } + ) + $operations = 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance', + 'SetInheritance', 'SetSecurityDescriptor' + + $auditSuccessCases = @( + @{ AuditRole = 'Admin'; Description = 'an administrator of the file server and the client' } + @{ AuditRole = 'ServerAdmin'; Description = 'an administrator of the file server only' } + ) + + $ownedFolders = @( + foreach ($variant in $variants) { + foreach ($operation in $operations) { + @{ Folder = 'Case1\{0}\{1}' -f $variant.Variant, $operation } + } + } + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + foreach ($operation in 'GetAudit', 'AddAudit', 'RemoveAudit') { + @{ Folder = 'Case2\{0}\{1}' -f $auditRole, $operation } + } + } + ) + + # The administrators of the file server add and remove the audit entries; the delegated account changes nothing. + $auditExpectations = @( + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + $mayWrite = $auditRole -ne 'Delegate' + @{ Folder = "Case2\$auditRole\GetAudit"; Count = 1 } + @{ Folder = "Case2\$auditRole\AddAudit"; Count = [int]$mayWrite } + @{ Folder = "Case2\$auditRole\RemoveAudit"; Count = [int](-not $mayWrite) } + } + ) +} + +BeforeAll { + if ($ConfigurationPath) { + . (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1') + $configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json + Assert-LabTestTarget -Configuration $configuration + + # On the client, the tests use the share; on the file server, the folder of the share. + $runRoot = if ($Role -eq 'Server') { $configuration.ServerPath } else { $configuration.SharePath } + if ($ModulePath) { + Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop + } + + $administrators = 'S-1-5-32-544' + $everyone = 'S-1-1-0' + $sidType = [System.Security.Principal.SecurityIdentifier] + $synchronize = 0x100000L + } + + function Get-LabPath { + param ([string] $RelativePath) + + # Normalized, so that neither '..' nor '/' leads out of the folder of the run. + $path = [System.IO.Path]::GetFullPath((Join-Path -Path $runRoot -ChildPath $RelativePath)) + if ([System.IO.Path]::IsPathRooted($RelativePath) -or + -not $path.StartsWith($runRoot.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase)) { + throw "'$RelativePath' must be a path in the folder of the run." + } + + $path + } + + function Get-LabOwner { + param ([string] $Path) + + (Get-LabSecurityDescriptor -Path $Path).Owner.Value + } + + function Get-LabExplicitAccessRule { + param ([string] $Path, [string] $Sid) + + (Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $Sid } + } + + function Format-LabError { + param ([object[]] $ErrorRecord) + + foreach ($record in $ErrorRecord) { + if ($null -ne $record) { + '{0}: {1}' -f $record.FullyQualifiedErrorId, $record.Exception.Message + } + } + } + + function Format-LabRight { + param ([object] $Right) + + # .NET adds Synchronize to every allow entry that it creates. + '0x{0:X}' -f (([long]$Right) -bor $synchronize) + } +} + +AfterAll { + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Account of the run' -Tag 'Delegate', 'ServerAdmin', 'Admin', 'Server' -Skip:(-not $configured) { + It 'Should run as the account of the role' { + [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value | Should -Be $configuration.Accounts.$Role.Sid + } + + It 'Should be an administrator of this computer only in the roles that are' { + $principal = New-Object -TypeName 'System.Security.Principal.WindowsPrincipal' -ArgumentList ( + [System.Security.Principal.WindowsIdentity]::GetCurrent() + ) + $expected = if ($env:COMPUTERNAME -eq $configuration.FileServer) { + $configuration.Accounts.$Role.FileServerAdministrator + } + else { + $configuration.Accounts.$Role.ClientAdministrator + } + + $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) | Should -Be $expected + } + + It 'Should test the module version of the run' -Skip:($Role -eq 'Server') { + $module = Get-Module -Name NTFSSecurity + $version = [string]$module.Version + if ($module.PrivateData.PSData.Prerelease) { + $version = '{0}-{1}' -f $version, $module.PrivateData.PSData.Prerelease + } + + $version | Should -Be $configuration.ModuleVersion + } +} + +Describe 'Access and inheritance cmdlets on a share folder whose owner the account may not assign (#34)' -Tag 'Delegate' -Skip:(-not $configured) { + # The delegated account has Full Control on the folders through a domain group, but isn't an administrator of the + # file server, so the file server refuses Administrators as the owner that the account writes: (1307) This security + # ID may not be assigned as the owner of this object. Before 5.0.0-rc3, the cmdlets wrote the unchanged owner back + # whenever they had read it: Windows returns the owner with a DACL that is read alone when the DACL has no + # auto-inherit flag, and Get-NTFSSecurityDescriptor always reads it. + Context 'With ' -ForEach $variants { + BeforeAll { + $folder = Get-LabPath -RelativePath "Case1\$Variant" + } + + It 'Should start with folders that Administrators own' { + foreach ($operation in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', + 'EnableInheritance', 'SetInheritance', 'SetSecurityDescriptor') { + $path = Join-Path -Path $folder -ChildPath $operation + Get-LabOwner -Path $path | Should -Be $administrators -Because $operation + Test-LabDaclAutoInherited -Path $path | Should -Be $AutoInherited -Because $operation + } + } + + It 'Add-NTFSAccess should add the entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'AddAccess' + + Add-NTFSAccess -Path $path -Account $everyone -AccessRights ReadData -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + @(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1 + } + + It 'Remove-NTFSAccess should remove the entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'RemoveAccess' + @(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1 + + Remove-NTFSAccess -Path $path -Account $everyone -AccessRights ReadAndExecute -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + Get-LabExplicitAccessRule -Path $path -Sid $everyone | Should -BeNullOrEmpty + } + + It 'Clear-NTFSAccess should remove the explicit entries and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'ClearAccess' + + Clear-NTFSAccess -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).GetAccessRules($true, $false, $sidType) | Should -BeNullOrEmpty + } + + It 'Disable-NTFSAccessInheritance should disable the inheritance and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'DisableInheritance' + + Disable-NTFSAccessInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + } + + It 'Enable-NTFSAccessInheritance should enable the inheritance and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'EnableInheritance' + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + + Enable-NTFSAccessInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeFalse + } + + It 'Set-NTFSInheritance should disable the inheritance and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'SetInheritance' + + Set-NTFSInheritance -Path $path -AccessInheritanceEnabled $false -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue + } + + It 'Set-NTFSSecurityDescriptor should write the added entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'SetSecurityDescriptor' + + $descriptor = Get-NTFSSecurityDescriptor -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + Add-NTFSAccess -SecurityDescriptor $descriptor -Account $everyone -AccessRights ReadData -ErrorVariable +operationErrors -ErrorAction SilentlyContinue + Set-NTFSSecurityDescriptor -SecurityDescriptor $descriptor -ErrorVariable +operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + @(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1 + } + } +} + +Describe 'Audit cmdlets on a share folder' -Skip:(-not $configured) { + # Over SMB, the file server checks whether the account holds the Security privilege there; the role Server checks + # the audit entries that the runs left on the file server. + foreach ($auditCase in $auditSuccessCases) { + Context 'As ' -Tag $auditCase.AuditRole -ForEach @($auditCase) { + BeforeAll { + $folder = Get-LabPath -RelativePath "Case2\$AuditRole" + } + + It 'Get-NTFSAudit should return the audit entry of the folder' { + $entries = @(Get-NTFSAudit -Path (Join-Path -Path $folder -ChildPath 'GetAudit') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $everyone + $entries[0].AuditFlags | Should -Be 'Success' + [long]$entries[0].AccessRights | Should -Be 0x10000 + } + + It 'Add-NTFSAudit should add the audit entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'AddAudit' + + Add-NTFSAudit -Path $path -Account $everyone -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + } + + It 'Remove-NTFSAudit should remove the audit entry and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'RemoveAudit' + + Remove-NTFSAudit -Path $path -Account $everyone -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + } + } + } + + Context 'As the delegated account, an administrator of the client only' -Tag 'Delegate' { + # The account has Full Control on the folders, so taking ownership succeeds, but it doesn't hold the Security + # privilege on the file server, and it may not assign Administrators as the owner again. + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case2\Delegate' + } + + It 'Get-NTFSAudit should write a ReadSecurityError that names the missing privilege' { + $entries = @(Get-NTFSAudit -Path (Join-Path -Path $folder -ChildPath 'GetAudit') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + $entries | Should -BeNullOrEmpty + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + + It 'Add-NTFSAudit should write an AddAceError that names the missing privilege and leave the folder unchanged' { + $path = Join-Path -Path $folder -ChildPath 'AddAudit' + $before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') + + Add-NTFSAudit -Path $path -Account $everyone -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + $written = (Format-LabError -ErrorRecord $operationErrors) -join ' | ' + (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written" + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written" + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + + It 'Remove-NTFSAudit should write a RemoveAceError that names the missing privilege and leave the folder unchanged' { + $path = Join-Path -Path $folder -ChildPath 'RemoveAudit' + $before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') + + Remove-NTFSAudit -Path $path -Account $everyone -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + $written = (Format-LabError -ErrorRecord $operationErrors) -join ' | ' + (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written" + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written" + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*' + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + } +} + +Describe 'Get-NTFSEffectiveAccess for a domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) { + # The account gets ReadAndExecute through two nested domain groups and Write through a local group of the file + # server. Only the file server knows its local groups. The expected rights come from the S4U tokens that the file + # server and the client create for the account, which hold the same groups as the Effective Access tab there. + BeforeAll { + $path = Get-LabPath -RelativePath 'Case3\EffectiveAccess' + $subject = $configuration.Accounts.Subject.Name + } + + It 'Should return the rights through the domain groups and the local group of the file server with -ServerName, without a warning' { + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.FileServerRights) + } + + It 'Should return only the rights through the domain groups without -ServerName' { + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights) + } + + # The cmdlet page: when the remote authorization manager can't be reached, the cmdlet falls back to the local one + # and warns that the result may be inaccurate. + It 'Should fall back to the authorization manager of the client and warn when -ServerName can''t be reached' { + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.UnreachableServerName -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer. ' + + 'For more accurate results, calculate effective access rights on the target computer') + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights) + } +} + +Describe 'Get-NTFSOrphanedAccess with the entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) { + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case4\OrphanedAccess' + $file = Join-Path -Path $folder -ChildPath 'File.txt' + $orphan = $configuration.Accounts.Orphan.Sid + } + + It 'Should return the entry of the deleted account with its SID' { + $entries = @(Get-NTFSOrphanedAccess -Path $folder -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $orphan + $entries[0].Account.AccountName | Should -BeNullOrEmpty + $entries[0].IsInherited | Should -BeFalse + } + + It 'Should return the inherited entry for a file in the folder, and nothing with -ExcludeInherited' { + $entries = @(Get-NTFSOrphanedAccess -Path $file -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + $explicitEntries = @(Get-NTFSOrphanedAccess -Path $file -ExcludeInherited -ErrorVariable +operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $orphan + $entries[0].IsInherited | Should -BeTrue + $explicitEntries | Should -BeNullOrEmpty + } +} + +Describe 'Paths longer than 260 characters on a share' -Tag 'Admin' -Skip:(-not $configured) { + BeforeAll { + $folder = Get-LabPath -RelativePath 'LongPath' + $file = Join-Path -Path $folder -ChildPath $configuration.LongPath + } + + It 'Get-ChildItem2 should return the file at the end of the long path' { + $files = @(Get-ChildItem2 -Path $folder -Recurse -File -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $files | Should -HaveCount 1 + $files[0].FullName.Length | Should -BeGreaterThan 260 + } + + It 'Get-NTFSAccess should return the entries of that file' { + $file.Length | Should -BeGreaterThan 260 + + $entries = @(Get-NTFSAccess -Path $file -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -Not -BeNullOrEmpty + } +} + +Describe 'Copy-Item2 and Move-Item2 with -WhatIf onto an existing file on a share (#108)' -Tag 'Admin' -Skip:(-not $configured) { + # Before 5.0.0-rc4, the cmdlets wrote an error with -WhatIf when the destination file existed. + BeforeAll { + $folder = Get-LabPath -RelativePath 'WhatIf' + $source = Join-Path -Path $folder -ChildPath 'Source.txt' + $destination = Join-Path -Path $folder -ChildPath 'Destination.txt' + } + + It 'Copy-Item2 should write no error and leave the destination unchanged' { + Copy-Item2 -Path $source -Destination $destination -WhatIf -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath $destination -Raw | Should -Be 'Destination' + } + + It 'Move-Item2 should write no error and leave both files unchanged' { + Move-Item2 -Path $source -Destination $destination -WhatIf -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath $source -Raw | Should -Be 'Source' + Get-Content -LiteralPath $destination -Raw | Should -Be 'Destination' + } +} + +Describe 'Security descriptors on the file server after the runs on the client' -Tag 'Server' -Skip:(-not $configured) { + It 'Should keep Administrators as the owner of ' -ForEach $ownedFolders { + Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $administrators + } + + It 'Should have explicit audit entries on ' -ForEach $auditExpectations { + $acl = Get-Acl -LiteralPath (Get-LabPath -RelativePath $Folder) -Audit + + @($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Count + } +} diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md new file mode 100644 index 0000000..ba0880a --- /dev/null +++ b/Tests/Lab/README.md @@ -0,0 +1,124 @@ +# Live tests in a lab + +The tests in this folder run the module against a Windows file server with +domain accounts, in an [AutomatedLab](https://automatedlab.org) lab. They cover +the cases that depend on the file server or on the accounts, which the tests in +`Tests` can't cover: those run on one computer, against local folders, with +local and well-known accounts. CI doesn't run the tests in this folder, and +without a lab they skip every test. + +## Cases + +| Case | Role | What the tests check | +| --- | --- | --- | +| 1, [#34][issue-34] | Delegate | `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Disable-NTFSAccessInheritance`, `Enable-NTFSAccessInheritance`, `Set-NTFSInheritance`, and `Set-NTFSSecurityDescriptor` on share folders that Administrators own and on which a domain group has Full Control, run by a member of that group who isn't an administrator of the file server. They succeed and keep the owner. | +| 2 | Admin, ServerAdmin, Delegate | `Get-NTFSAudit`, `Add-NTFSAudit`, and `Remove-NTFSAudit` on share folders. Over SMB, the file server checks the Security privilege of the account. The administrators of the file server read and change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and the folders stay unchanged. | +| 3 | Admin | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. | +| 4 | Admin | `Get-NTFSOrphanedAccess` returns the entry of a deleted domain account with its SID, on the folder and as inherited entry on a file in it. | +| Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. | +| [#108][issue-108] | Admin | `Copy-Item2` and `Move-Item2` with `-WhatIf` onto an existing file on the share write no error. | +| State | Server | After the runs on the client, the file server checks the owners and the audit entries of the folders itself, without the module. | + +Case 1 uses two kinds of folders. Before 5.0.0-rc3, the cmdlets wrote back the +owner that Windows returns with a DACL without the auto-inherit flag, and the +file server refused it with error 1307, "This security ID may not be assigned +as the owner of this object". Windows sets that flag whenever it writes a DACL +with `SetNamedSecurityInfo`, so the fixture stores the DACL of one kind of +folders again with `SetFileSecurity`, without the flag, like tools that predate +Windows 2000. `Set-NTFSSecurityDescriptor` wrote the owner on both kinds. + +The expected rights of case 3 come from the tokens that the file server and the +client create for the account with a Kerberos S4U logon, the way the Effective +Access tab of the advanced security settings does. + +## Roles + +| Role | Account | Administrator of the client | Administrator of the file server | +| --- | --- | --- | --- | +| Delegate | `NtfsLiveDelegate`, member of `NtfsLiveDelegates` | Yes | No | +| ServerAdmin | `NtfsLiveServerAdmin`, member of Remote Management Users on the client | No | Yes | +| Admin | `NtfsLiveAdmin` | Yes | Yes | +| Server | The installation account of the lab, on the file server | Yes | Yes | + +The script also creates `NtfsLiveSubject`, the account of case 3, which is a +member of `NtfsLiveInner`, a member of `NtfsLiveOuter`, and of the local group +`NtfsLiveLocal` of the file server, and `NtfsLiveOrphan`, which it deletes in +every run. + +## Lab + +The lab needs a domain controller, a file server, and a client of one domain, +PowerShell 7 and Pester 5.7.1 on the client and the file server, and +remoting with CredSSP from the host, which AutomatedLab sets up. The defaults +use the lab of +[WindowsAccessControl](https://github.com/raandree/WindowsAccessControl), which +`tests/Lab/Deploy-WindowsAccessControlLab.ps1` in that repository deploys: +`F1ADC1` as domain controller, `F1AFile2` as file server, and `F1AFile1` as +client, all in `a.forest1.net`. `-DomainController`, `-FileServer`, and +`-Client` select other machines. + +The script adds to the lab: + +- the organizational unit `NTFSSecurityLive` with the accounts and groups +- the local group `NtfsLiveLocal` and members of Administrators on the file + server, and members of Administrators and Remote Management Users on the + client +- the share `NTFSSecurityLive` on `C:\NTFSSecurityLive` of the file server, + with a folder for each run +- the folder `C:\NTFSSecurityLab` with the tests on the file server, and with + the modules and the tests on the client + +## Run the tests + +In an elevated Windows PowerShell 5.1 session on the Hyper-V host of the lab: + +```powershell +.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc2, 5.0.0-rc4 -Confirm:$false +``` + +The script downloads each version from the PowerShell Gallery, checks the hash +that the gallery publishes, and runs the tests for each version in Windows +PowerShell 5.1 and PowerShell 7. Each version runs in its own process, because +all versions of `NTFSSecurity.dll` have the same assembly version. To test a +build, add `-ModulePath .\NTFSSecurity\bin\Release`; it runs as the version +`local`. + +The script sets new random passwords for the accounts in every call and keeps +them in memory only. The tests refuse to run on a computer other than the +client and the file server of the configuration, and on a folder outside the +share. + +Remove everything the script added to the lab: + +```powershell +.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture +``` + +## Results + +Each call writes to a new folder in `$env:TEMP\NTFSSecurityLab\Results`: + +- `Summary.md` and `Summary.json`: the counts per version, edition, and role, + and the failed tests with their messages +- `-.result.json` and `-.log`: the result and the + error message of each test that ran, and the output of Pester +- `.json`: the configuration of the run +- `-State.json`: the stored owner, group, and DACL, and the SACL of each + folder after the run + +A version before 5.0.0-rc3 fails case 1 with error 1307, a version before +5.0.0-rc4 fails the tests of #108, and a version before 5.0.0-rc5 fails the +test of case 3 with a computer that can't be reached: it returned no access +instead of the result of the client. + +## Files + +| File | Purpose | +| --- | --- | +| `Invoke-NTFSSecurityLabTest.ps1` | Prepares the lab, runs the tests, and writes the results; runs on the host. | +| `NTFSSecurity.Live.Tests.ps1` | The tests; run on the client and the file server. | +| `Start-NTFSSecurityLiveTest.ps1` | Runs the tests of one role in a new process. | +| `NTFSSecurity.LabHelpers.ps1` | Reads and writes security descriptors as Windows stores them, and calculates the expected rights. | + +[issue-34]: https://github.com/raandree/NTFSSecurity/issues/34 +[issue-108]: https://github.com/raandree/NTFSSecurity/issues/108 diff --git a/Tests/Lab/Start-NTFSSecurityLiveTest.ps1 b/Tests/Lab/Start-NTFSSecurityLiveTest.ps1 new file mode 100644 index 0000000..bf414b1 --- /dev/null +++ b/Tests/Lab/Start-NTFSSecurityLiveTest.ps1 @@ -0,0 +1,85 @@ +<# +.SYNOPSIS + Runs NTFSSecurity.Live.Tests.ps1 for one role and writes the result file. + +.DESCRIPTION + Invoke-NTFSSecurityLabTest.ps1 starts this script in a new Windows PowerShell 5.1 or PowerShell 7 process on the + client or the file server of the lab, as the account of the role. Each module version runs in its own process, + because all versions of NTFSSecurity.dll have the same assembly version, and a second import in a process would + use the first DLL. Exits with 0 when all tests passed and with 1 otherwise. + +.PARAMETER ModulePath + The folder that contains NTFSSecurity.psd1 of the version to test. The role Server needs no module. + +.PARAMETER ConfigurationPath + The configuration of the run that Invoke-NTFSSecurityLabTest.ps1 wrote. + +.PARAMETER Role + The role whose tests run: Delegate, ServerAdmin, Admin, or Server. + +.PARAMETER ResultPath + The path of the result file: a JSON array with the name, the result, and the error message of each test that + ran, and of each test file that failed. Pester's result formats read the operating system through CIM, which an + account that isn't an administrator can't use in a remote session. + +.EXAMPLE + .\Start-NTFSSecurityLiveTest.ps1 -ModulePath C:\NTFSSecurityLab\Modules\5.0.0-rc4\NTFSSecurity -ConfigurationPath C:\NTFSSecurityLab\Configuration\Run.json -Role Delegate -ResultPath $env:TEMP\Delegate.json + + Runs the tests of the delegated account against 5.0.0-rc4. +#> +[CmdletBinding()] +param ( + [string] + $ModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ConfigurationPath, + + [Parameter(Mandatory)] + [ValidateSet('Delegate', 'ServerAdmin', 'Admin', 'Server')] + [string] + $Role, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ResultPath +) + +$ErrorActionPreference = 'Stop' +Import-Module -Name Pester -RequiredVersion 5.7.1 + +$data = @{ + ModulePath = $ModulePath + ConfigurationPath = $ConfigurationPath + Role = $Role +} +$configuration = New-PesterConfiguration +$configuration.Run.Container = New-PesterContainer -Path (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.Live.Tests.ps1') -Data $data +$configuration.Run.PassThru = $true +$configuration.Filter.Tag = $Role +$configuration.Output.Verbosity = 'Detailed' +$configuration.Output.RenderMode = 'Plaintext' +$result = Invoke-Pester -Configuration $configuration + +$tests = foreach ($test in $result.Tests | Where-Object -FilterScript { $_.Result -ne 'NotRun' }) { + [pscustomobject]@{ + Name = $test.ExpandedPath + Result = [string]$test.Result + Message = (@($test.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { $_.ToString() }) -join [Environment]::NewLine + } +} + +# A test file fails also when only its tests fail; it is listed only when it failed with an error of its own. +$failedFiles = foreach ($container in $result.Containers | Where-Object -FilterScript { $_.Result -eq 'Failed' -and @($_.ErrorRecord).Count -gt 0 }) { + [pscustomobject]@{ + Name = 'Test file {0}' -f $container.Item + Result = 'Failed' + Message = (@($container.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { $_.ToString() }) -join [Environment]::NewLine + } +} + +ConvertTo-Json -InputObject @(@($tests) + @($failedFiles)) -Depth 3 | Set-Content -LiteralPath $ResultPath -Encoding UTF8 +exit [int]($result.Result -ne 'Passed')