mirror of https://github.com/raandree/NTFSSecurity
Browse Source
test(lab): operating-system matrix, three module fixes, and first-lab acceptance for the 5.0.0 gatepull/121/head 5.0.0-rc7
committed by
GitHub
44 changed files with 5997 additions and 97 deletions
@ -0,0 +1,74 @@ |
|||
--- |
|||
status: proposed |
|||
date: 2026-10-09 |
|||
last-verified: 2026-10-09 |
|||
owner: shared |
|||
source: agent assessment for the maintainer (Handoff 4), from #34 read on 2026-10-09 21:33 UTC |
|||
--- |
|||
|
|||
# Decision 23: Non-Windows file servers before 5.0.0 (#34) |
|||
|
|||
- Context: Decision 21 leaves to the maintainer how to cover file servers that |
|||
aren't Windows (#34). The issue is open (labels Bug and Help Wanted, 26 |
|||
comments, last activity 2026-10-06 16:05 UTC). This record separates what |
|||
the reporters said from what we tested, and states what the maintainer has |
|||
to decide. It accepts no risk: the gate stays open until a tester reports |
|||
on the exact candidate or the maintainer accepts the risk in his own words. |
|||
- Reporter evidence (text of the issue and its comments, treated as data): |
|||
|
|||
| Date | Who | System and claim | |
|||
| --- | --- | --- | |
|||
| 2018-08 | deftleft | NetApp Clustered Data ONTAP 9.3P6, Windows 10 1607 and 1709: error 1307 from `Add-NTFSAccess` only on the UNC path of the filer, not on a local drive; the folder is owned by `BUILTIN\Administrators`, the account is a member; `icacls` works | |
|||
| 2018-09, 2019-01 | dt1ll0ts0n, FrisbeeGolfer | 1307 on UNC paths; Windows Server 2012 R2 file servers with DFS (a Windows server); builds from 4.0 fail, 3.2.3 works; service account has Full Control and isn't an administrator | |
|||
| 2019-10, 2020-01 | Bi00, Marc408 | NetApp behind DFS; it works when the running account owns the folder | |
|||
| 2020-01, 2023-11 | jcardel | EMC filer: the owner can be set only through a share that impersonates root; other permissions work over SMB; the owner entry "Owner Rights" is his workaround | |
|||
| 2023-05 | tberta | EMC NAS, 4.2.6, no administrator rights on the NAS: 1307; Process Monitor shows the owner written in addition to the DACL, while `icacls` writes only the DACL | |
|||
| 2023-11-28 | maintainer | reproduced with a customer: the user isn't a local administrator and lacks the backup and restore privileges | |
|||
| 2026-10-05, -06 | maintainer | the fix writes only the changed section (Decision 19); rc3 announced as published on 2026-10-06 13:40 UTC; asked for a tester on NetApp or EMC | |
|||
| 2026-10-06 16:05 | jcardel | moving to IBM ESS (UNIX), owner issue "still the same" there; will test both systems and report "tomorrow" | |
|||
|
|||
No reply followed by 2026-10-09 21:33 UTC. Silence is not success. The |
|||
2020 comments of Kluk and agonzalezm describe other causes (a script that |
|||
wasn't run as administrator; a name that can't be translated). |
|||
- What we tested: only Windows. The lab comparison of 2026-10-07 reproduced |
|||
the error over SMB with rc2 and showed rc4 passing; every candidate since, |
|||
including `83149ee` on 2026-10-09, passes case 1 (a delegated account that |
|||
doesn't own the folder: add, remove, clear, disable and enable inheritance, |
|||
set inheritance, and security descriptor, with the owner kept) in both |
|||
editions on Windows Server 2025. CI reproduces the error without a file |
|||
server. The matrix of Decision 21 adds Server 2019, 2022, and Windows 11. |
|||
- What it doesn't show: whether NetApp ONTAP, Dell EMC, or IBM ESS accepts a |
|||
write of the DACL alone from an account that isn't their administrator, and |
|||
what else they refuse. Hypotheses, not facts: they may refuse a flag that |
|||
the cmdlets set (for example the protected-DACL flag when the inheritance |
|||
changes), or map the ACL differently. `Set-NTFSOwner` can't work where the |
|||
server doesn't allow assigning an owner; that is a server policy. |
|||
- Options for the maintainer: |
|||
1. Wait for a report on the exact candidate (rc7 once published) from both |
|||
reporters. Safest; the stable release waits for an unknown time. |
|||
2. Accept the untested risk, and release with the caveat below. The |
|||
decision is security-relevant, so only the maintainer can take it; it |
|||
doesn't go through a "not sure, you pick" answer. |
|||
3. Both: publish rc7, ask for tests with the checklist, and choose a date |
|||
after which you decide between 1 and 2. |
|||
- Recommendation: option 3. Nothing in the module changes for #34 meanwhile. |
|||
- Caveat for the release notes, if the risk is accepted (adjust the list of |
|||
operating systems to what the matrix has tested): "The fix for #34, which |
|||
writes only the section of the security descriptor that a command changes, |
|||
was tested on Windows file servers. NetApp, EMC, and IBM ESS file servers |
|||
weren't available for 5.0.0. If a command still fails there with error |
|||
1307, tell us in #34. `icacls` is the fallback." |
|||
- Open: the maintainer's choice between the options, and the date. Until |
|||
then the gate of Decision 21 for #34 is open. |
|||
- Tester checklist: `Tests/Lab/Non-Windows-File-Server-Test.md`. It uses a |
|||
new folder that the tester controls and asks for sanitized evidence. |
|||
- Draft comment for #34 (for the maintainer to post; the agent posts |
|||
nothing; replace the version and the link when they exist): |
|||
|
|||
```text |
|||
Thanks again for offering to test, @jcardel, and thanks @tberta for the Process Monitor capture that showed the owner write. Since 5.0.0-rc3, we have published more prereleases. The one to test is 5.0.0-rc7, because it is the candidate that becomes 5.0.0. Please test it on both systems you mentioned, with an account that is not an administrator or root of the file server. |
|||
|
|||
Use a new folder that you create for the test, never real data, a share root, or a home folder. The steps take about 20 minutes: <link to Tests/Lab/Non-Windows-File-Server-Test.md>. Please report the package version, the file server product and version, and for each command whether it worked, the first line of any error, and the owner before and after. Please don't post passwords, keys, file contents, or complete security descriptors, and replace names with placeholders. |
|||
|
|||
A report of "it works" without these details can't tell us which command ran on which setup. If something fails, that is just as useful: the error and the owner before and after show what the file server refuses. We would like to have your result before 5.0.0. NTFSSecurity will be archived after 5.0.0. |
|||
``` |
|||
@ -0,0 +1,150 @@ |
|||
--- |
|||
status: proposed |
|||
date: 2026-10-09 |
|||
last-verified: 2026-10-10 |
|||
owner: shared |
|||
source: agent decisions under the maintainer's delegation of 2026-10-09 (Handoff 2); the scope follows Decision 21, phase 3 |
|||
--- |
|||
|
|||
# Decision 24: The operating-system matrix lab |
|||
|
|||
- Context: Decision 21 requires the live tests on more operating systems, |
|||
"such as a Windows 11 client and Server 2019 and 2022 file servers", and the |
|||
published package must pass them. Every machine of `WindowsAccessControlLab` |
|||
is Server 2025. Handoff 2 asks for the maintainer's approval of scope and |
|||
topology before new VMs. On 2026-10-09 at 21:21 UTC the maintainer, going to |
|||
bed, wrote "you can do whatever is required with the lab" and told the agent |
|||
to decide and report later. The agent took that as the approval for the |
|||
minimal matrix below and for nothing broader. It is the agent's decision, so |
|||
the status stays `proposed` until the maintainer confirms it. |
|||
- Choice: |
|||
1. Cells: a Windows 11 client with each file server (Server 2019 Datacenter |
|||
10.0.17763.1217, Server 2022 Datacenter 10.0.20348.4773, Server 2025 |
|||
Datacenter 10.0.26100.32690), the module in Windows PowerShell 5.1 and |
|||
PowerShell 7 in every cell. The client was planned as Windows 11 Pro |
|||
26H1 (10.0.28000.1836). It cannot keep a secure channel to the Server 2025 |
|||
domain controller (see "What the deployment showed"), so the domain client |
|||
is `OSWin11E`, Windows 11 Enterprise Evaluation 22H2 (10.0.22621.525), and |
|||
the 26H1 machine `OSWin11` stays in the lab outside the domain for runs of |
|||
the module's own tests. The reference cell of Decision 20 (Server 2025 |
|||
client and file server in `WindowsAccessControlLab`) stays as it is. |
|||
Server 2019 and 2022 as clients are extra cells, to run the module on the |
|||
older .NET Framework builds (Server 2019 has 4.7.2). |
|||
2. Topology: a separate AutomatedLab lab `NtfsSecurityOsMatrixLab` with its |
|||
own internal switch (`192.168.12.0/24`) and its own forest `osmatrix.net`: |
|||
`OSDC1` (Server 2025, root domain controller), `OSFile19`, `OSFile22`, |
|||
`OSFile25`, `OSWin11E`, and `OSWin11`. `Deploy-OsMatrixLab.ps1` deploys it |
|||
with the maintainer's AutomatedLab and the VM path `V:\AutomatedLab-VMs`; |
|||
`Add-OsMatrixMachine.ps1` adds a machine to the deployed lab; the |
|||
payloads of the existing lab (PowerShell 7.6.3 and Pester 5.7.1 from the |
|||
host, because the VMs have no internet) come from |
|||
`Complete-OsMatrixLab.ps1`. |
|||
3. Case 9 (accounts of other domains and forests) needs trusts to the |
|||
forests of the existing lab, so the matrix cells run with |
|||
`-ForeignDomainController @()`; the existing lab keeps that case. The final |
|||
candidate ran it there (run `fl1`: 245 passed, 0 failed, 1 skipped per |
|||
edition, 16 case-9 tests per edition). |
|||
4. The controller of the repository runs in every cell with `-LabName`, |
|||
`-DomainController`, `-FileServer`, and `-Client`. The matrix showed three |
|||
defects of its setup and removal, fixed in `7d47316`: a recursive delete |
|||
fails with "The directory is not empty" on Windows Server 2019 (and the |
|||
stderr line ended the script before any retry, because `2>&1` under `Stop` |
|||
is terminating in Windows PowerShell 5.1), `Get-LocalGroupMember` fails on |
|||
an orphaned SID, and a vanished profile failed the client cleanup. |
|||
5. The module's own behavior tests run on every machine as well |
|||
(`Run-MatrixLocalSuite.ps1`), elevated and as a basic user, in both |
|||
editions, as scheduled tasks so that the token matches a CI runner. This |
|||
found three defects of the module, fixed in two commits on |
|||
`ai/quality-gate-lab-matrix`: `Get-NTFSInheritance -SecurityDescriptor` |
|||
and `Get-NTFSEffectiveAccess -ServerName ''` (`962887a`, two fixes), and |
|||
`Get-NTFSEffectiveAccess` for a user who isn't an administrator on a |
|||
computer in a domain (`fdd7a8b`, with a live test for the ServerAdmin |
|||
role). The maintainer decides which of them belong to rc7. |
|||
- Why a separate lab: AutomatedLab 5.61 refuses to add machines to an |
|||
imported lab, and defining a lab under an existing name would overwrite the |
|||
metadata of its 13 machines. A separate lab leaves every shared machine, |
|||
switch, domain, and account untouched, which Handoff 2 requires. Inside the |
|||
new lab, a machine can be added with `Import-LabDefinition`, |
|||
`Add-LabMachineDefinition`, and `Export-LabDefinition`, followed by the steps |
|||
that `Install-Lab` runs for one machine; `Add-OsMatrixMachine.ps1` does this |
|||
after it copies the lab metadata. |
|||
- Cost and rollback: six VMs (4 GB for the domain controller and both clients, |
|||
3 GB for each file server), four new base images, measured at 17.8 GB for |
|||
the differencing disks and 42.4 GB for the base images (about 60 GB on `V:`; |
|||
my first estimate of 100 GB was too high). The deployment added twelve lines |
|||
to the hosts file of the host, which `Remove-Lab` removes. To remove the |
|||
matrix, run `Remove-Lab -Name NtfsSecurityOsMatrixLab` from AutomatedLab; |
|||
nothing else depends on it. No existing machine, checkpoint, or lab was |
|||
changed. A copy of the lab metadata from before the sixth machine is in |
|||
`C:\ProgramData\AutomatedLab\Backups` (administrators only). |
|||
- What the deployment showed (the agent's decisions D11 to D23 of the night |
|||
log, each reversible): |
|||
- The base image of a Server 2019 or a Windows 11 22H2 machine had an empty |
|||
EFI system partition: the `bcdboot` of the Server 2025 host fails with |
|||
exit code 193 on their boot files, and AutomatedLab ignores the exit code, |
|||
so the generation 2 machine fails with Hyper-V event 18603. The images of |
|||
Server 2022 and Windows 11 26H1 are fine. `Repair-OsMatrixBoot.ps1` runs |
|||
the `bcdboot` of the image itself on the differencing disk of the one |
|||
machine and starts it. |
|||
- The AutomatedLab driver sat in its file-server job wait with idle remote |
|||
runspaces after all features were installed, so I stopped it and ran the |
|||
rest by script. |
|||
- Windows 11 26H1 (10.0.28000.1836) joins the domain but cannot keep the |
|||
Netlogon secure channel to the Server 2025 domain controller |
|||
(10.0.26100.32690). The client calls `NetrLogonGetCapabilities` with query |
|||
level 2, which the protocol document describes as a check of the flags the |
|||
client sent; the controller answers `STATUS_ACCESS_DENIED` (level 1 and |
|||
`NetrServerAuthenticate` succeed), and the client denies the channel |
|||
(`NlConfirmRequestedCapabilities: denying access ... 0xc0000022`). |
|||
`Test-ComputerSecureChannel -Repair` can't help. Windows 11 22H2 against the |
|||
same controller works (secure channel, Kerberos, readiness). This is an |
|||
environment finding about two Microsoft builds, not about NTFSSecurity; the |
|||
maintainer may want to know it for his own labs. |
|||
- The Windows 11 Enterprise Evaluation 22H2 image (`OSWin11E`) is in |
|||
notification mode from its first day and shuts down an hour after every |
|||
start (`wlms.exe`, 0xC004F009 "grace time expired"): its install time was |
|||
recorded on a clock about seven hours ahead, which was then corrected. One |
|||
of its two rearms didn't help. After an unplanned shutdown its machine |
|||
account password no longer matched (domain logons fail with 0xC000018D, |
|||
`nltest /sc_verify` says `ERROR_INVALID_PASSWORD`); |
|||
`Test-ComputerSecureChannel -Repair` with the lab account fixed it, and |
|||
`/sc_verify` kept showing the old status afterwards, so test a domain |
|||
session instead. A run on this machine has to stay under an hour from its |
|||
start. |
|||
- A profile of an account that a probe's scheduled task used stayed loaded on |
|||
one server until it restarted; the probe now uses a new account name for |
|||
every run. |
|||
- The matrix cells of the live controller failed in the effective-access tests |
|||
of the Admin role in the Windows Server 2022 cell (`rc7f`, `rc7h`, `rc7i`, |
|||
and `rc7j`) in cells that followed each other, where the fixture was removed |
|||
after a cell and created again with the same account names. This looked like |
|||
a regression of the module (the audit read of `962887a` was the first suspect) |
|||
until a replay of the same cells with the baseline and the final candidate |
|||
alternating (`ab0` to `ab6`) failed the baseline in two of three cells and |
|||
the final candidate in one of three (not counting the warm-up `ab0`). In a |
|||
failing cell the remote |
|||
authorization managers (the client's and the file server's) returned no |
|||
groups for the current account while the Kerberos S4U logon, the name |
|||
resolution, and the local manager were right in the same second. One model, |
|||
in which a remote manager answers for an account name for about ten minutes |
|||
after its first request, fits all 43 Admin-role runs of 27 cells; the |
|||
predictions that I wrote down before three of the replay cells held (the |
|||
weakest is `ab6`, 10.5 minutes after its entry, above the lifetimes that |
|||
fit). The mechanism in Windows isn't known. The controller now gives a new |
|||
fixture a new name for the account of case 3 (`1dec389`); four more cells |
|||
with it (`ab7` to `ab10`) passed, two of them at positions where the model |
|||
predicts a failure for a reused name. The record has the evidence. |
|||
- Result: [the record](../../Tests/Lab/Acceptance-2026-10-10-os-matrix.md). The |
|||
final candidate (`fdd7a8b`) passes the module's suite on all five machines |
|||
and the host in all four configurations, and the live cells (see the record). |
|||
- Open: the maintainer confirms or changes the matrix and decides whether to |
|||
keep the VMs after 5.0.0. Local `-ModulePath` runs are validation; the gate |
|||
needs the published package in every cell (Handoff 3, stage D). The newest |
|||
Windows 11 build that can join a Server 2025 domain here is 22H2; a domain |
|||
cell with 26H1 needs a newer domain controller build or a fix of the |
|||
mismatch. The maintainer also decides which of the module fixes belong to |
|||
rc7 (two commits: `962887a` holds two fixes, `fdd7a8b` one; `fdd7a8b` reverts |
|||
cleanly on its own, `962887a` conflicts with it in `Lib.cs` and `CHANGELOG.md` |
|||
if `fdd7a8b` stays), and whether the |
|||
evaluation client stays (it needs a start shortly before every run) or is |
|||
replaced by a client with a license that doesn't expire. |
|||
@ -0,0 +1,171 @@ |
|||
--- |
|||
status: current |
|||
last-verified: 2026-10-10 |
|||
owner: software-engineer |
|||
source: release gates of 5.0.0 (lab acceptance, OS matrix, publication plan), repository evidence |
|||
--- |
|||
|
|||
# Deployment notes |
|||
|
|||
## Publish the next prerelease (rc7) |
|||
|
|||
State on 2026-10-10 at 09:59 UTC: #116 (rc7, head `d25647d`) is merged into |
|||
`master` (merge commit `8a6be9f`, 09:10:12Z). #117 (head `f11ff41`, base |
|||
`ai/release-5.0.0-rc7`) was **closed without a merge** at 09:10:16Z: the |
|||
`--delete-branch` of `gh pr merge 116` deleted its base branch, and GitHub |
|||
closed it (events `base_ref_deleted`, then `closed`) instead of retargeting it. |
|||
Nothing is lost: `ai/quality-gate-coverage` is intact at `f11ff41`, and |
|||
`master` still lacks its change (25 files). #118 (draft, head `83149ee`, base |
|||
`ai/quality-gate-coverage`) and #119 (draft, head `49734ef`, base |
|||
`ai/quality-gate-paths`) are open and green. A simulated merge chain |
|||
(`git merge-tree --write-tree`, no ref written) with merge commits |
|||
(Decision 15) is conflict-free at every step: the coverage branch into `master` |
|||
gives the tree of `f11ff41`, #118 then gives `b1dc006`, and #119 gives |
|||
`62aa1ae`, the tree of the matrix branch. The manifest says `5.0.0` with |
|||
`Prerelease = 'rc7'`, and `$publishedVersions` in `Tests/Repository.Tests.ps1` |
|||
lists the versions up to rc6, as it must before rc7 is published. |
|||
|
|||
The branch `ai/quality-gate-lab-matrix` (draft #119) is stacked on #118. It |
|||
holds three fixes of the module in two commits (`962887a` has two, `fdd7a8b` |
|||
one). `fdd7a8b` reverts cleanly on its own; `962887a` doesn't |
|||
revert while `fdd7a8b` stays (the two conflict in `Security2/Win32/Lib.cs` and |
|||
`CHANGELOG.md`), and its two fixes go together. The branch also holds the kit of the |
|||
operating-system matrix, the changes of the live controller, and the record |
|||
(Decision 24). rc7 contains the module fixes only if the branch is merged after |
|||
#118 and before the tag; otherwise they go to the next prerelease. The |
|||
maintainer decides. |
|||
|
|||
Do not delete a head branch while another open pull request uses it as its |
|||
base. On 2026-10-10 the deletion through `gh pr merge --delete-branch` closed |
|||
#117 instead of retargeting it. The open reports `cli/cli#1168` and |
|||
`cli/cli#14223` show the same two events and say that GitHub retargets only |
|||
when the branch is deleted with the button on the pull request page. The |
|||
latter also reports, and this was not tried here, that `gh pr edit --base` |
|||
refuses a closed pull request and that `gh pr reopen` refuses while the base |
|||
branch is missing. A new pull request from the same head is the repair. |
|||
|
|||
1. Open a new pull request from `ai/quality-gate-coverage` to `master` (it |
|||
replaces #117, same head and title), wait for its CI, and merge it with |
|||
**Create a merge commit**. Do not delete the branch yet. |
|||
2. Retarget #118 (`gh pr edit 118 --base master`), mark it ready, and merge it |
|||
the same way. Then do the same for #119 if its module fixes go into rc7. |
|||
A retarget doesn't start CI again (`pull_request` in `ci.yml` has the |
|||
default event types), and the merge result is the tree that CI tested. |
|||
3. Delete the head branches only after the last pull request that uses one as |
|||
its base is merged or retargeted. |
|||
4. Tag the merge commit on `master` with `5.0.0-rc7` and push the tag. The |
|||
`release` job checks the tag against the manifest and builds nothing new: |
|||
it publishes the package that the `build` job tested. Approve the |
|||
deployment of the `powershell-gallery` environment if it asks. |
|||
5. After the publication, add `5.0.0-rc7` to `$publishedVersions` with the |
|||
next change that goes to `master`. |
|||
|
|||
## Accept a published package |
|||
|
|||
Local `-ModulePath` runs are validation; the gate needs the published bytes. |
|||
|
|||
1. `Tests/Lab/Acceptance/Test-PublishedRelease.ps1 -Version <version> |
|||
-OutputPath <folder>` (read-only): tag and commit on `master`, the CI run |
|||
of the tag, the Gallery's SHA-512 against the downloaded nupkg (ordinal, |
|||
case-sensitive base64), the nupkg against the GitHub zip file by file, and |
|||
the identity of the manifest. Dry run on rc6: all checks passed. |
|||
2. `Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 -Version <version>` in the |
|||
existing lab, both editions, and `Tests/Lab/Acceptance/Run-MatrixSequence.ps1 |
|||
-Version <version>` for each cell of the matrix (Decision 24; pass the file |
|||
servers as one quoted string, `-FileServer 'OSFile19,OSFile22,OSFile25'`, and |
|||
start `OSWin11E` shortly before, because its license period ends an hour |
|||
after each start). Check every role from the result files with |
|||
`Validate-LabResults.ps1`, never from the marker `DONE` of the controller. |
|||
Dry run of the `-Version` path of the sequence runner with the published rc6 |
|||
on OSFile19 (Desktop): the mechanics work, the failing tests are the newer |
|||
ones that rc6 predates, and the cleanup verdict was CLEAN. The first lab ran |
|||
the final local candidate through the same stages (readiness, controller, |
|||
`Validate-LabResults.ps1`, snapshot, `-RemoveFixture`, `Test-MatrixCleanup.ps1` |
|||
with the four domain controllers and both machines) in one detached driver. |
|||
3. Remove the fixture and check the end state independently with |
|||
`Test-MatrixCleanup.ps1`, which takes the lab name and the machine names |
|||
(`-LabName WindowsAccessControlLab -DomainController F1ADC1, F1BDC1, F2DC1, |
|||
F3DC1 -Machine F1AFile1, F1AFile2` for the existing lab). |
|||
4. If the published binary changes, repeat the cells; never combine runs of |
|||
different binaries into one matrix. |
|||
|
|||
## Lab lessons |
|||
|
|||
- AutomatedLab 5.61 can't add machines to an imported lab (`Add-LabMachineDefinition` |
|||
throws "Lab is already imported"), and `New-LabDefinition` under an existing |
|||
name overwrites its metadata. New machines go into a new lab with its own |
|||
switch and domain, and `-LabName` of the controller selects it. |
|||
- `Install-Lab -NetworkSwitches -BaseImages` creates the switch and the base |
|||
images first; the base images of Server 2019, Server 2022, and Windows 11 Pro |
|||
took about two to four minutes each from the ISO files. AutomatedLab |
|||
adds records to the hosts file of the host, which `Remove-Lab` removes. |
|||
- The VMs have no internet: take PowerShell 7 and Pester 5.7.1 from the host |
|||
(`Copy-LabFileItem`, `Install-LabSoftwarePackage`). |
|||
- AutomatedLab ignores the exit code of `bcdboot` when it builds a base image. |
|||
The Server 2019 image that it built on this Server 2025 host got an empty |
|||
EFI system partition: the `bcdboot` of the host fails with exit code 193, |
|||
"Failure when attempting to copy boot files", on the 2019 boot files, and the |
|||
VM failed to boot (Hyper-V event 18603, "failed to boot an operating |
|||
system"; no memory demand, no IP, heartbeat `NoContact`). Check the EFI |
|||
system partition of a new base image before the first VM: mount the image |
|||
read-only (`Mount-DiskImage -Access ReadOnly`) and look for |
|||
`EFI\Microsoft\Boot\bootmgfw.efi` (the images of Server 2022 and Windows 11 |
|||
Pro had 140 and 149 files). Repair a VM, not the base: stop the VM, mount its |
|||
own differencing disk, run the `bcdboot.exe` of the image (`D:\Windows\System32\bcdboot.exe |
|||
D:\Windows /s H: /f UEFI`), copy `bootmgfw.efi` to `EFI\Boot\bootx64.efi`, |
|||
dismount, and start the VM. A changed base image would invalidate its |
|||
differencing disks. |
|||
- The tool output of the agent masks text that looks like a secret, such as |
|||
`-Password $password`, in what it shows. Test such a line by parsing the |
|||
file, and don't repair it from the displayed text. |
|||
- A script that a detached process runs needs its own log, an exit marker, and |
|||
an end-state check of its own; verify cleanup from the end state, not from |
|||
its marker. |
|||
- Extend a deployed lab with one machine like this: in a process that never ran |
|||
`Import-Lab`, call `Import-LabDefinition`, `Add-LabMachineDefinition`, and |
|||
`Export-LabDefinition`, then `New-LabBaseImages` and `New-LabVM -Name <machine>`. |
|||
`Install-Lab` has no per-machine selector, and `Add-LabMachineDefinition` |
|||
throws as soon as `Get-Lab` returns a lab. `Add-OsMatrixMachine.ps1` does it |
|||
after it copies the lab metadata to `C:\ProgramData\AutomatedLab\Backups`. |
|||
- Windows 11 22H2 (10.0.22621) has the empty EFI system partition problem too |
|||
(`bcdboot` exit code 193 on the host); `Repair-OsMatrixBoot.ps1` repairs it. |
|||
After `Mount-VHD` the host gives the NTFS partition a letter on its own; don't |
|||
assign a second one. |
|||
- Run AutomatedLab processes one after the other. Two `Import-Lab` calls at the |
|||
same time corrupt each other (XML errors, "No machines imported"). |
|||
- Check the secure channel of every domain client before the first run |
|||
(`Test-MatrixReadiness.ps1`). Windows 11 26H1 (10.0.28000.1836) joins a |
|||
Server 2025 domain (10.0.26100.32690) but loses the channel: the client asks |
|||
`NetrLogonGetCapabilities` for query level 2, the domain controller answers |
|||
`0xC0000022`, and the client denies the channel. Rejoining doesn't help. |
|||
- A process that starts from a remoting session has every privilege enabled |
|||
and no credentials of its own, so tests that expect disabled privileges fail |
|||
(eight per edition). Run the suite of a VM as a scheduled task with a batch |
|||
logon at the highest run level (`Register-ScheduledTask -RunLevel Highest |
|||
-User -Password`): that token matches a CI runner. A restricted token (a basic |
|||
user) can't run Pester's NUnit export, because it asks WMI for the |
|||
environment, so write the JSON summary first. |
|||
- In Windows PowerShell 5.1, `$PSScriptRoot` is empty in a parameter default of a |
|||
script that runs with `-File`; compute it in the body. `-File` passes an array |
|||
as one string, so split on commas. With `$ErrorActionPreference = 'Stop'`, a |
|||
line that a native command writes to stderr and that `2>&1` redirects is a |
|||
terminating error; let the command write its errors to stdout. |
|||
- `Get-LocalGroupMember` fails with "Failed to compare two elements in the array" |
|||
when the group holds an orphaned SID. Add members with `Add-LocalGroupMember` |
|||
and ignore `MemberExistsException`; read and remove members with |
|||
`net localgroup <name>` and `net localgroup <name> <SID> /delete`. The SIDs |
|||
of a deleted account can't be found afterwards, so keep `fixture-sids.json` |
|||
from before the removal of the organizational unit. |
|||
- An account that is deleted and created again with the same name made the |
|||
remote authorization managers (the client's and the file server's) answer for |
|||
about ten minutes as if it had no groups, so `Get-NTFSEffectiveAccess` returned |
|||
no access; when the accounts are created again within seconds, the Kerberos S4U |
|||
logons returned the old account on the domain controller and member servers for |
|||
7 to 15 minutes. The five remedies tried (a ticket purge, `nltest /sc_reset`, a |
|||
DNS flush, a restart of the Kerberos service, and waiting) helped only by |
|||
waiting (see `techContext.md`). The controller names the account of case 3 anew |
|||
for each new fixture; a script of your own that recreates accounts needs unique |
|||
names too. |
|||
- Restart the evaluation client (`OSWin11E`) right before a sequence or a suite, |
|||
not before several: it shuts down an hour after each start. The restart takes |
|||
about two and a half minutes and may need the repair of the secure channel. |
|||
|
@ -0,0 +1,251 @@ |
|||
# Quality-gate paths follow-up acceptance, 2026-10-09 |
|||
|
|||
Live acceptance, in the lab, of the behavior that the fixes of |
|||
`ai/quality-gate-paths` change, as the handoff table of the |
|||
[path report](../Coverage/Quality-Gate-Paths-2026-10-09.md) asks. The branch |
|||
(28 commits on `f11ff41`, the head of #117; head `83149ee`, draft #118) is a |
|||
local candidate, tested from its extracted package with `-ModulePath`. It is |
|||
not a published package, and this record is not a claim that the quality gate |
|||
is complete. Architecture and cmdlet-design choices remain with the |
|||
maintainer (Decisions 16, 21, and 22). |
|||
|
|||
## Method |
|||
|
|||
New live tests, case 10 and one test of the Server role, check what each fix |
|||
changed. The same tests, controller, and lab ran against two builds, in new |
|||
processes for each edition: the candidate (`83149ee`) and the baseline |
|||
(`f11ff41`, the base of the branch). A test is evidence of a fix when it |
|||
passes on the candidate and fails on the baseline; a test that passes on both |
|||
is a control. |
|||
|
|||
## Candidate and artifact identity |
|||
|
|||
| | Candidate | Baseline | |
|||
| --- | --- | --- | |
|||
| Commit | `83149eedee0684bd0a0522865abd0bc6127f6bf5` | `f11ff412947b35d682878ac4a8121c949868fcb2` | |
|||
| `NTFSSecurity.dll` SHA-256 | `40D0C8A6B819F15AE69A21D4D510B3B3CFCE2D93294368046C707BD558E67C1F` | `96F087E2AA39D521018346CC9F0A23C8AE2EE2D8CB39AE0E9B7A9325CF47AB73` | |
|||
| `NTFSSecurity.5.0.0-rc7.nupkg` SHA-256 | `2AAE3403A2D1C3AEE5156F05441E46B85B855AF95B46A7B73D2F80435513D71D` | `06244B161F76F3A9DCCCFDE3D7D6C5D0D5FEB625127FBF1B298D935BCBD8A2E2` | |
|||
| `NTFSSecurity.zip` SHA-256 | `A5AFA241DCA5DF87080A9801BB336282BD424D70DA395F6456F2D74B7FC8076A` | `3DF287C9AC4A311E4093DE519DED046B94109F51B513ACBC1653EF483DB3A2C0` | |
|||
|
|||
- Each build is a Release build (.NET Framework 4.5.2) in an isolated worktree |
|||
of its commit, packaged by `.github/scripts/New-ModulePackage.ps1`. Both |
|||
carry the label `5.0.0-rc7` and one assembly version, so every run used a |
|||
new process. All 11 files of each tested module folder equal the extracted |
|||
`NTFSSecurity.zip` byte for byte (SHA-256). The first packaging attempt, at |
|||
20:41 UTC, stopped in both builds at the check of the build script that |
|||
compares the package folder with the extracted zip ("The extracted ZIP |
|||
differs from the module folder"); the logs of that attempt are kept. I |
|||
changed the script (20:43) and built both again; the files that the lab |
|||
tested are those of the second attempt, and the cause of the first |
|||
mismatch wasn't recorded. |
|||
- Test source, identical in both runs (last written 20:56 and 20:54 UTC, |
|||
before the candidate run started at 21:02): `NTFSSecurity.Live.Tests.ps1` |
|||
(Git blob `67b85efeb45af67070538f241c203c4afa38b6f4`) and |
|||
`Invoke-NTFSSecurityLabTest.ps1` (blob |
|||
`0b46427bc32b0b15449e283a2a6cf67879937541`). Both are in the commit that |
|||
adds this record. |
|||
|
|||
## Tests added |
|||
|
|||
Case 10 adds 78 tests per edition to the 166 of the acceptance at `3442194` |
|||
(244 in all): 75 in the roles on the client and 3 for the state that the file |
|||
server finds. The fixture adds the folder `Case10` with delegated Full |
|||
Control, the folder `Locked` that Administrators own, and files that |
|||
Administrators own for the cases of `Set-NTFSOwner`. |
|||
|
|||
| Describe (roles) | Tests | Fail on baseline | Fail on candidate | Fix | |
|||
| --- | ---: | ---: | ---: | --- | |
|||
| An item that the account owns and whose owner may not change its permissions (Delegate) | 2 | 2 | 0 | `c7a0383` owner restore | |
|||
| InheritedFrom of access entries that Windows cannot resolve (3 roles) | 3 | 3 | 0 | `2909a1c` | |
|||
| InheritedFrom of audit entries that Windows cannot resolve (ServerAdmin, Admin) | 2 | 2 | 0 | `2909a1c` | |
|||
| InheritedFrom of an item below a folder whose permissions the account cannot read (Delegate) | 2 | 1 | 0 | `2909a1c` | |
|||
| A later command that ends the pipeline or throws, for the item cmdlets (3 roles; 16 each) | 48 | 48 | 0 | `c77ecbf`, `40bf6a8` | |
|||
| A later command and the error of a folder that Get-ChildItem2 cannot read (Delegate) | 3 | 2 | 0 | `c77ecbf` (break), `d44a200` (throw) | |
|||
| Get-ChildItem2 -Filter (3 roles; brackets, `*.*`, null) | 9 | 9 | 0 | `ee7c105`, `40bf6a8`, `ae3078f` | |
|||
| Privileges when a later command takes the debug messages (Delegate, Admin) | 6 | 4 | 0 | `d44a200` | |
|||
| State of the file server: only the first item changed (Server; one per role) | 3 | 3 | 0 | `c77ecbf`, `40bf6a8` | |
|||
|
|||
The tests that pass on the baseline are controls (a precondition, or the |
|||
privileges the cmdlets hold). `b14c90b` (public object APIs) has no lab |
|||
scenario; the package smoke below runs its unit tests. The fix of the leaked |
|||
native buffer has no observable guard. |
|||
|
|||
A first run of the new tests on the candidate in Windows PowerShell failed |
|||
five tests. All five were errors of the tests, not of the module: a native |
|||
`icacls` call that Pester's `Stop` turned into a terminating error, and |
|||
assertions that expected a descriptor to be written at a verbose stop, which |
|||
that stop prevents. The tests were corrected, and the runs below are complete |
|||
runs of the final test files. |
|||
|
|||
## Package smoke |
|||
|
|||
Before the lab run, the eight unit-test files that guard the fixes ran |
|||
against the extracted candidate package in a scratch tree, in the four |
|||
configurations of the report (650 cases each): elevated Desktop 643 passed, |
|||
elevated Core 642, basic Desktop 528, basic Core 527; none failed; 7, 8, 122, |
|||
and 123 were skipped by their own conditions, which the report's eligibility |
|||
check covers. |
|||
|
|||
## Lab and rollback evidence |
|||
|
|||
`WindowsAccessControlLab`: F1ADC1, F1BDC1, F2DC1, F3DC1, F1AFile1 (client), |
|||
and F1AFile2 (file server), all Windows Server 2025 (10.0.26100). At |
|||
20:41 UTC, authenticated WinRM, LDAP RootDSE, Kerberos tickets, member secure |
|||
channels, and clocks (skew at most 7 s) passed on all six machines. At 20:43 |
|||
UTC, before the first test run, no `NTFSSecurityLive` OU or `NtfsLive*` |
|||
account existed. The runs changed no VM, operating system, or network |
|||
setting. A process listing at the start showed no other controller of these |
|||
tests on the host; it wasn't kept as a log. |
|||
|
|||
Six checkpoints named `ntfs-qg-paths-83149ee-before-acceptance` were taken |
|||
at 20:45 to 20:46 UTC, one per machine; the Hyper-V listing that shows the |
|||
names is kept with the evidence. The policy of each machine is Production, |
|||
but Hyper-V reports the type Standard. As before, Production classification |
|||
is unverified, and no checkpoint was restored or deleted. Every machine now |
|||
carries seven checkpoints of the acceptances since 2026-10-08, F1AFile1 eight. |
|||
|
|||
## Live results |
|||
|
|||
Candidate run 21:02 to 21:19 UTC, baseline run 21:22 to 21:39 UTC, each in |
|||
Windows PowerShell 5.1 and PowerShell 7 against the extracted package. Both |
|||
editions gave the same counts in each build. |
|||
|
|||
| Build | Role | Passed | Failed | Skipped | |
|||
| --- | --- | ---: | ---: | ---: | |
|||
| Candidate | Delegate | 69 | 0 | 0 | |
|||
| Candidate | ServerAdmin | 34 | 0 | 0 | |
|||
| Candidate | Admin | 64 | 0 | 0 | |
|||
| Candidate | Server | 76 | 0 | 1 | |
|||
| Baseline | Delegate | 42 | 27 | 0 | |
|||
| Baseline | ServerAdmin | 13 | 21 | 0 | |
|||
| Baseline | Admin | 41 | 23 | 0 | |
|||
| Baseline | Server | 73 | 3 | 1 | |
|||
|
|||
Candidate, both editions: 486 passed, zero failed, two skipped; the skip is |
|||
the test that needs the module in the Server role, which doesn't import it. |
|||
Baseline, both editions: 338 passed, 148 failed, two skipped. Every role |
|||
exited 0 on the candidate. A joined verification of the result files (not of |
|||
the counts) found the same 488 tests in both builds, no duplicate, and every |
|||
one of the 148 baseline failures passed on the candidate. The 148 failures |
|||
are 74 tests in each edition, all among the 78 new tests of each edition |
|||
(case 10 and the state test); the four that pass on both builds are |
|||
preconditions. [The results file](Acceptance-2026-10-09-quality-gate-paths-Results.csv) |
|||
lists the 156 results (78 tests in two editions) with both outcomes and the |
|||
first line of the baseline message. |
|||
|
|||
What the baseline shows, from its messages: |
|||
|
|||
- Owner: `RestoreOwnerError ... (5) Access is denied` for the unchanged owner. |
|||
- `InheritedFrom`: a text of 13 characters instead of the 14 of |
|||
`unknown parent`. |
|||
- Later command: the `Downstream failure` of a `throw` never reached the |
|||
caller (the messages read `Expected like wildcard '*Downstream failure*' to |
|||
match $null`), and a `break` of a later command didn't leave the caller's |
|||
loop. For `Select-Object -First 1`, see the next section. |
|||
- `-Filter`: no result for a name with brackets; `*.*` returned only the |
|||
three names with a dot and dropped `NoExtension` and `NoExtensionFolder`; |
|||
`$null` gave `ArgumentNull` instead of the parameter validation error. |
|||
- Privileges: `TakeOwnership` still enabled after the pipeline stopped. |
|||
|
|||
### Baseline failures without a message |
|||
|
|||
Seven tests of each role, 21 per edition and 42 in all, fail on the baseline |
|||
with an empty message, and Pester prints no line for them: `Select-Object |
|||
-First 1` for the five item cmdlets, the verbose stop of |
|||
`Set-NTFSSecurityDescriptor`, and the debug stop of `Set-NTFSOwner`. This lab |
|||
run doesn't show what the baseline did in them. The State test of the Server |
|||
role shows it only for `Remove-Item2`: in each role, the second item was |
|||
removed after `Select-Object -First 1`. That test stops at its first failed |
|||
assertion, so it says nothing about the other cmdlets, and its assertions for |
|||
the debug and verbose stops check only that the second item is as it was, |
|||
which is also true when the client test never ran. |
|||
|
|||
To close the gap, the bodies of these tests ran afterwards on this host, in a |
|||
sandbox below TEMP, with the settings of the runner (Pester 5.7.1, |
|||
`ErrorActionPreference` Stop), one build in one edition per process |
|||
(`Acceptance\Probe-LaterCommand.ps1`; it isn't part of the acceptance, and |
|||
it didn't run on a share). The result is the same in Windows PowerShell 5.1 |
|||
and PowerShell 7: |
|||
|
|||
| Cmdlet | Baseline `f11ff41`, after `Select-Object -First 1` and after `throw` | Candidate `83149ee` | |
|||
| --- | --- | --- | |
|||
| `Remove-Item2` | both items removed | the second item stays | |
|||
| `Copy-Item2` | both items copied | only the first is copied | |
|||
| `Move-Item2` | both items moved | the second item stays | |
|||
| `Set-NTFSOwner` | both owners changed, also at the debug stop | the second owner stays Administrators | |
|||
| `Set-NTFSSecurityDescriptor` | both descriptors written | only the first is written | |
|||
|
|||
All 12 tests of the probe (seven stop rows, five `throw` rows) fail on the |
|||
baseline, the seven stop rows with no error record, as in the lab, and the |
|||
`throw` rows with the message of the lab; all 12 pass on the candidate. At the |
|||
verbose stop of `Set-NTFSSecurityDescriptor`, neither build writes a |
|||
descriptor, because the stop comes before the first write, so that failure on |
|||
the baseline isn't a change of state. |
|||
|
|||
## Cleanup and review |
|||
|
|||
Before the removal, the SIDs of the fixture were saved from the four domains |
|||
(10: seven in `a.forest1.net`, one each in `b.forest1.net`, `forest2.net`, |
|||
and `forest3.net`). The fixture was removed at 21:40 to 21:41 UTC with |
|||
`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture`. A separate read-only check |
|||
at 21:41 UTC, not the wrapper's marker, found in all four domains no |
|||
`NTFSSecurityLive` OU and no `NtfsLive*` account, and on F1AFile1 and |
|||
F1AFile2 no share, no `C:\NTFSSecurityLive` or `C:\NTFSSecurityLab`, no |
|||
`NtfsLiveLocal` group, no fixture member of Administrators, Access Control |
|||
Assistance Operators, or Remote Management Users, and no profile of the ten |
|||
SIDs. No checkpoint was restored. |
|||
|
|||
One independent, read-only, static review of the finished change (tests, |
|||
fixture, README, this record and its results file, and Decision 22) ran |
|||
before the first commit. The custom `security-reviewer` can't start because |
|||
its configured model is unavailable, so the built-in code-review agent did |
|||
it. Verdict: approve with Minor; no Blocker and no Major. It confirmed that |
|||
the new tests can't pass vacuously (every precondition is asserted, the data |
|||
rows are not empty, nothing is shared between rows), that the fixture stays |
|||
below the guarded folders and throws when Administrators don't own the |
|||
files, and that the counts, the hashes, the 156 results, and the cleanup |
|||
facts of this record match the evidence. Its findings, all corrected in the |
|||
commit that follows the first: the fix that this record credited for the |
|||
`break` row, the claims about the State test and the 42 messageless |
|||
failures (now the section above, with the diagnostic), this heading, the |
|||
count of results, the wording about the folders before the run, the |
|||
truncated messages in the results file, the README row of case 10, and the |
|||
migration hint of item 8 and the comparison with `Copy-Item` in Decision 22. |
|||
It could not run anything, so the run state and the lab-wide claims rest on |
|||
the logs; the diagnostic above and the checkpoint listing close two of its |
|||
open points. |
|||
|
|||
## Limits |
|||
|
|||
- `Get-NTFSAudit` below an unreadable parent folder can't be built here: an |
|||
account that may read the audit entries (it holds the Security privilege) |
|||
also reads the DACL of an Administrators-owned folder. The audit scenario |
|||
uses a file that was deleted after it was read, which reaches the same |
|||
`unknown parent` text. |
|||
- The run covers the candidate package from disk (`-ModulePath`), not the |
|||
published package, one lab, and Windows Server 2025 only. The other |
|||
operating systems of Decision 21, the acceptance of the published |
|||
prerelease, and the answer of a non-Windows file server (#34) stay with the |
|||
other gates. The stable version remains 4.2.6. |
|||
- The candidate and the baseline differ only by the 28 commits; the test and |
|||
controller files are the same. |
|||
- What the baseline did in the 42 failures without a message is shown by a |
|||
local diagnostic, not by this lab run. A State test split per cmdlet and |
|||
stop style would show it on the share too, and would need both lab runs |
|||
again. |
|||
|
|||
## Evidence |
|||
|
|||
The result files, logs, hashes, readiness, checkpoint, snapshot, and cleanup |
|||
logs of both runs are in the session artifact |
|||
`4b12e2f4-d4c7-4a5d-883a-ddb7421c4848\files\lab-qg-paths` (local, not in Git); |
|||
so are the Hyper-V listing of the checkpoint names |
|||
(`checkpoints-83149ee-names.csv`) and the outputs of the diagnostic |
|||
(`runs\diagnostic-mute`, and `runs\diagnostic-mute-first-run` from before the |
|||
probe listed owners and entries). The per-test results of case 10 are in |
|||
[the results file](Acceptance-2026-10-09-quality-gate-paths-Results.csv). The |
|||
scripts that build, package, run, and clean up in this acceptance contain |
|||
paths of the session folder and stay in the session artifact; the generic |
|||
ones that it used, `Validate-LabResults.ps1` (the check of the result files) |
|||
and `Probe-LaterCommand.ps1` (the diagnostic), are in the folder |
|||
[Acceptance](Acceptance). |
|||
|
|
|
|
|
@ -0,0 +1,760 @@ |
|||
# Operating-system matrix acceptance, 2026-10-10 |
|||
|
|||
Live and local acceptance of NTFSSecurity candidates on more operating systems |
|||
than the first lab has (Decision 24, handoff 2 of the 5.0.0 quality gate): |
|||
Windows Server 2019, 2022, and 2025 as file servers and Windows 11 as client, in |
|||
Windows PowerShell 5.1 and PowerShell 7, elevated and as a basic user. The |
|||
candidates are local builds of `ai/quality-gate-lab-matrix`, tested from their |
|||
extracted packages with `-ModulePath`. None is a published package, so this |
|||
record isn't the acceptance of a release (see the limits at the end), and it |
|||
isn't a claim that the quality gate is complete. |
|||
|
|||
## Result |
|||
|
|||
- The module's own suite, 1,011 cases per configuration (1,010 on the host), ran |
|||
on five operating systems and on the host in four configurations each. The |
|||
final candidate (`fdd7a8b`) has no failure in any of the 24 runs; every |
|||
skipped test is also skipped on the host. |
|||
- The live controller ran for the final candidate in three cells of the matrix |
|||
(the Windows 11 client with each file server, both editions, every role) in |
|||
one sequence, after the fixture got a new account name for each new fixture: |
|||
1,374 passed, 0 failed, 12 skipped (case 9 and the module test of the Server |
|||
role). An earlier run of the same cells with the old controller had failed in |
|||
the Windows Server 2022 cell. A replay showed that the baseline fails the same |
|||
way there, so the module doesn't decide the outcome: the failures depend on the |
|||
position of the cell (six replay runs can't rule out a small effect of the |
|||
module; see "The effective-access failures of the Admin role"). |
|||
- The final candidate also passed the live controller in the first lab, where |
|||
case 9 runs: 245 passed, 0 failed, 1 skipped in each edition (see "First lab, |
|||
final candidate (case 9)"). |
|||
- The matrix found three defects of the module, fixed in two commits on the |
|||
branch, and each was red on the machines where it shows before its fix and |
|||
green after it: `Get-NTFSInheritance -SecurityDescriptor` for an item without |
|||
audit entries and `Get-NTFSEffectiveAccess -ServerName ''` (`962887a`, two |
|||
fixes), and `Get-NTFSEffectiveAccess` for a user who isn't an administrator on |
|||
a computer in a domain (`fdd7a8b`). The first two showed on Windows Server 2022 |
|||
and 2025 and on Windows 11 26H1, the third on every machine of the domain. |
|||
- The controller had four defects of its own: three in cleanup and setup |
|||
(`7d47316`) and the reuse of the name of the account of case 3 (`1dec389`). |
|||
Cells that followed each other failed in the effective-access tests of the |
|||
Admin role when the account of case 3 was deleted and created again under the |
|||
same name: the remote authorization managers of the client and of the file |
|||
server returned no groups for the new account, for the baseline and for the |
|||
final candidate alike. A model with a lifetime of about ten minutes fits every run; the mechanism |
|||
in Windows isn't known. This looked like a regression of the module until the |
|||
baseline failed the same way in a replay of the same cells. |
|||
- Windows 11 26H1 (10.0.28000) can't keep a secure channel to the Windows |
|||
Server 2025 domain controller of this lab, so it runs the module's suite only. |
|||
The domain client is Windows 11 Enterprise Evaluation 22H2. |
|||
- Open: the published package in every cell and in the first lab (stage D of the |
|||
gate), and the maintainer's decisions listed at the end. The matrix lab has no |
|||
trusts, so case 9 runs only in the first lab. |
|||
|
|||
## Machines |
|||
|
|||
All machines are virtual machines on the Hyper-V host in the lab |
|||
`NtfsSecurityOsMatrixLab` (domain `osmatrix.net`, switch `192.168.12.0/24`), |
|||
which AutomatedLab deployed beside the other labs without touching them. The |
|||
.NET Framework release number is the one that the readiness check read. |
|||
|
|||
| Machine | Role | Operating system | Build | .NET Framework | Windows PowerShell | PowerShell 7 | |
|||
| --- | --- | --- | --- | ---: | --- | --- | |
|||
| OSDC1 | Root domain controller | Windows Server 2025 Datacenter | 10.0.26100.32690 | 533509 | 5.1.26100.32684 | 7.6.3 | |
|||
| OSFile19 | File server | Windows Server 2019 Datacenter | 10.0.17763.1217 | 461814 | 5.1.17763.1007 | 7.6.3 | |
|||
| OSFile22 | File server | Windows Server 2022 Datacenter | 10.0.20348.4773 | 528449 | 5.1.20348.4294 | 7.6.3 | |
|||
| OSFile25 | File server | Windows Server 2025 Datacenter | 10.0.26100.32690 | 533509 | 5.1.26100.32684 | 7.6.3 | |
|||
| OSWin11E | Client | Windows 11 Enterprise Evaluation 22H2 | 10.0.22621.525 | 533320 | 5.1.22621.169 | 7.6.3 | |
|||
| OSWin11 | Suite only | Windows 11 Pro 26H1 | 10.0.28000.1836 | 533510 | 5.1.28000.1830 | 7.6.3 | |
|||
| Host | Reference for the suite | Windows Server 2025 Datacenter | 10.0.26100.33438 | 533509 | 5.1.26100.33438 | 7.6.6 | |
|||
|
|||
Windows 11 reports `Windows 10` as the product name in the registry; the builds |
|||
are Windows 11. The VMs have no internet, so PowerShell 7.6.3 and Pester 5.7.1 |
|||
came from the host. Every machine passed a readiness check before a cell: WinRM |
|||
with the lab account, LDAP, Kerberos, the secure channel, the clocks, the tools, |
|||
and the Pester version. |
|||
|
|||
## Candidates and artifact identity |
|||
|
|||
Each candidate is a Release build (.NET Framework 4.5.2) in an isolated worktree |
|||
of its commit, packaged by `.github/scripts/New-ModulePackage.ps1`. All 11 files |
|||
of every tested module folder equal the extracted `NTFSSecurity.zip` byte for |
|||
byte. The builds aren't byte-reproducible: `PrivilegeControl.dll` and |
|||
`ProcessPrivileges.dll` differ between the candidates although no source of |
|||
theirs changed, so the hashes belong to one build each. |
|||
|
|||
| | Baseline `83149ee` | Candidate `962887a` | Final `fdd7a8b` | |
|||
| --- | --- | --- | --- | |
|||
| What it is | Head of #118 | Two fixes in the module | Three fixes in the module | |
|||
| `NTFSSecurity.dll` | `40D0C8A6B819F15A…` | `9AC1169F91687495…` | `B0631389E68C8244…` | |
|||
| `Security2.dll` | `804D199335CA0D6A…` | `FF314FACCF01676A…` | `A4D744579E8FF3BE…` | |
|||
| `NTFSSecurity.5.0.0-rc7.nupkg` | `2AAE3403A2D1C3AE…` | `ACFA247BCD5AD33D…` | `1A112B8CBBE27F9D…` | |
|||
| `NTFSSecurity.zip` | `A5AFA241DCA5DF87…` | `CD836E39C6B22EB3…` | `3DF48E5C590A9E38…` | |
|||
|
|||
The full SHA-256 values and the hashes of every result file are in the local |
|||
evidence (see the end). The tests of the suite are the files of the working |
|||
tree at the commit of the run. The live tests (Git blob |
|||
`efe36e5073b9b10742ca7de242ddcbe90d8eda62`) are those of `fdd7a8b` in every run |
|||
from `rc7f` to `rc7l` and in the first-lab run `fl1`; the replay `ab0` to `ab10` |
|||
ran the same file with one diagnostic test added (blob |
|||
`72c12fe09e4005e048a8aed0fa02b9a922c58f34`, see "The effective-access failures |
|||
of the Admin role"). The controller of the cells `rc7f` to `rc7k` and of the |
|||
replay cells `ab0` to `ab6` is the blob `683aee91ec8805d77a33b2d368acaf876724fa32` |
|||
(`fdd7a8b`). The cells of `rc7l` and the replay cells `ab7` to `ab10` ran with the |
|||
blob `9917cac5820ed20ed2eb5592eff06894677f9874` (`1dec389`), and the first-lab run |
|||
`fl1` with the blob `d269e0fe6ba5f72894dd2dcba5dca7f2dc56624f` (the head of the |
|||
branch then, which differs from `1dec389` by a comment). The earlier cells of the |
|||
baseline ran with the controller blobs `0b46427b…` and `d485b1d0…` (`rc7d` with |
|||
`4cac0d0b…`) and the live tests `67b85efe…`, before the cleanup fixes. |
|||
|
|||
## Method |
|||
|
|||
- **Module's own suite.** `Acceptance\Run-MatrixLocalSuite.ps1` copies the 18 |
|||
behavior test files and the candidate to a machine and runs them there in a |
|||
new Windows PowerShell process and a new PowerShell 7 process, elevated and as |
|||
a basic user. The basic user is the token that |
|||
`.github\scripts\Invoke-TestsAsBasicUser.ps1` makes (SAFER level Normal User), |
|||
so the tests that need a missing privilege skip in the elevated mode and run |
|||
in this one. The processes run as scheduled tasks with a batch logon at the |
|||
highest run level: a process that starts from a remoting session has every |
|||
privilege enabled and no credentials of its own, which eight tests don't |
|||
expect. The host runs the same stage as the reference. A skipped test counts |
|||
as a difference when only one side skips it; the skipped lists are compared as |
|||
multisets of test names. |
|||
- **Live controller.** `Acceptance\Run-MatrixSequence.ps1` runs, for each file |
|||
server with the client `OSWin11E`: the readiness of every machine, the |
|||
unmodified controller of the repository in both editions, the validation of |
|||
every role from the result files (`Validate-LabResults.ps1`, never from the |
|||
marker `DONE`), a snapshot of the fixture SIDs, the removal of the fixture, |
|||
and an independent check of the end state (`Test-MatrixCleanup.ps1`). |
|||
- **Probe.** `Acceptance\Probe-EffectiveAccess.ps1` asks |
|||
`Get-NTFSEffectiveAccess` the same questions under four tokens on one machine |
|||
and writes the result and the failing call of each: the elevated lab account, |
|||
the SAFER token of a basic user, a local standard user, and a standard user of |
|||
the domain. It creates the two standard users with passwords that exist only |
|||
in memory and removes them, their profiles, and their group membership again. |
|||
- **Account probe.** `Acceptance\Probe-AccountRecreation.ps1` deletes an account |
|||
and creates it again with the same name in a loop. It shows the token that |
|||
Kerberos S4U logons give on the domain controller, the client, and the file |
|||
server, and what `Get-NTFSEffectiveAccess` of each module under test returns |
|||
from the client (see "The effective-access failures of the Admin role"). Its |
|||
accounts, folder, and files are named `NtfsProbe*`, which `Test-MatrixCleanup.ps1` |
|||
reports if they stay. |
|||
- **Replay.** The cells that failed were run again back to back, one edition, one |
|||
file server, with the baseline and the final candidate alternating: after a |
|||
restart of the client, one `Acceptance\Run-MatrixSequence.ps1 -Edition Desktop |
|||
-FileServer OSFile22` per cell with a different `-ModulePath`, from frozen |
|||
copies of the kit and the controller so that no edit could change a run in |
|||
progress. The live tests of the replay had one test added that is not in the |
|||
repository and prints the state of the subject account after the three |
|||
effective-access tests. The kit has the tools that read the |
|||
result: `Acceptance\Export-CellTimeline.ps1` (the timeline of the Admin role of |
|||
every cell and edition: the module, the account, the times, and the three |
|||
tests) and |
|||
`Acceptance\Test-StaleAuthzModel.ps1` (the model of the failures, replayed |
|||
against that timeline). |
|||
|
|||
## Results |
|||
|
|||
### Live controller |
|||
|
|||
The final candidate (`fdd7a8b`) in the three cells of the matrix, with the |
|||
Windows 11 client `OSWin11E`, the controller of `1dec389` (Git blob |
|||
`9917cac5820ed20ed2eb5592eff06894677f9874`) and the live tests of blob |
|||
`efe36e5073b9b10742ca7de242ddcbe90d8eda62`: run `rc7l`, one sequence, 03:45 to |
|||
04:07 UTC. Every role was checked from the result files |
|||
(`Validate-LabResults.ps1` printed `LIVE_RESULT_VERIFIED`). Passed / failed / |
|||
skipped: |
|||
|
|||
| File server | Edition | Delegate | ServerAdmin | Admin | Server | |
|||
| --- | --- | --- | --- | --- | --- | |
|||
| OSFile19 (Windows Server 2019) | Windows PowerShell | 69 / 0 / 0 | 36 / 0 / 0 | 51 / 0 / 1 | 73 / 0 / 1 | |
|||
| OSFile19 (Windows Server 2019) | PowerShell 7 | 69 / 0 / 0 | 36 / 0 / 0 | 51 / 0 / 1 | 73 / 0 / 1 | |
|||
| OSFile22 (Windows Server 2022) | Windows PowerShell | 69 / 0 / 0 | 36 / 0 / 0 | 51 / 0 / 1 | 73 / 0 / 1 | |
|||
| OSFile22 (Windows Server 2022) | PowerShell 7 | 69 / 0 / 0 | 36 / 0 / 0 | 51 / 0 / 1 | 73 / 0 / 1 | |
|||
| OSFile25 (Windows Server 2025) | Windows PowerShell | 69 / 0 / 0 | 36 / 0 / 0 | 51 / 0 / 1 | 73 / 0 / 1 | |
|||
| OSFile25 (Windows Server 2025) | PowerShell 7 | 69 / 0 / 0 | 36 / 0 / 0 | 51 / 0 / 1 | 73 / 0 / 1 | |
|||
|
|||
That is 1,374 passed, 0 failed, and 12 skipped for the three cells. The skipped |
|||
tests are the same in every cell: case 9 (the Admin role skips "Get-NTFSOrphanedAccess |
|||
should not report the entries of the accounts" of other domains and forests, |
|||
because the matrix lab has no foreign domain) and the Server role's test of the |
|||
module version (that role runs without the module). Each cell had a new |
|||
fixture: the subject of case 3 was `NtfsLiveSubject0602` in the cell of |
|||
OSFile19 and `NtfsLiveSubject6688` in the cell of OSFile22. |
|||
|
|||
All runs of the controller, as the table of cells |
|||
([Cells.csv](Acceptance-2026-10-10-os-matrix-Cells.csv)) lists them. The runs |
|||
before `rc7l` used the controller with the fixed name of the subject: |
|||
|
|||
| Run | Candidate | Cells | Result per edition and cell | |
|||
| --- | --- | --- | --- | |
|||
| `rc7c`, `rc7e` | Baseline `83149ee`, tests before the new cases | OSFile19, 22, 25 | 227 passed, 0 failed, 2 skipped in every cell. In `rc7c`, the failed cleanup of the first cell left the accounts in place through all three cells. `rc7d` between them created accounts and removed them 36 seconds later, because its setup failed ("Failed to compare two elements in the array", fixed in `7d47316`) before any test ran. In `rc7e`, the first cell created new accounts 4.6 minutes after that removal, the second reused them, and the third created new accounts 1.0 minute after the previous removal | |
|||
| `rc7f` | Final `fdd7a8b` | OSFile19, OSFile22 | OSFile19: 229 / 0 / 2. OSFile22: 228 / 1 / 2, the effective-access test of the Admin role | |
|||
| `rc7g` | Final | OSFile25 | 229 / 0 / 2 | |
|||
| `rc7h` | Final | OSFile22 | 228 / 1 / 2, the same test | |
|||
| `rc7i` | Final | OSFile22 | Windows PowerShell 228 / 1 / 2 (Admin), PowerShell 7 229 / 0 / 2 | |
|||
| `rc7j` | `962887a` (without the third fix) | OSFile22 | 225 / 4 / 2: the two new tests of the ServerAdmin role (red without the fix, "Access is denied" for `localhost` and for the name of the client) and two tests of the Admin role | |
|||
| `rc7k` | Baseline `83149ee`, with the final tests | OSFile22 | 227 / 2 / 2: the two new tests of the ServerAdmin role; the Admin role passed | |
|||
|
|||
The failures of the Admin role in `rc7f`, `rc7h`, `rc7i`, and `rc7j` don't depend |
|||
on the module: the baseline fails the same way in a replay of the cells (see "The |
|||
effective-access failures of the Admin role"). The two |
|||
failures of the ServerAdmin role in `rc7j` and `rc7k` are the red state of the |
|||
new live tests, as intended; they pass in `rc7f`, `rc7g`, `rc7h`, `rc7i`, and |
|||
`rc7l`. The end-state check after each cell of `rc7l` found the fixture gone |
|||
(no organizational unit, account, share, folder, local group, membership, or |
|||
profile) and reported only the staging folders of the earlier suite runs, which |
|||
`Test-MatrixCleanup.ps1` didn't check before (see the limits). |
|||
|
|||
### First lab, final candidate (case 9) |
|||
|
|||
Case 9, the accounts of other domains and forests, needs the trusts of the |
|||
first lab, so the cells of the matrix skip it. The final candidate (`fdd7a8b`, |
|||
from the same extracted module folder as in the matrix) ran through the |
|||
controller of the repository (blob `d269e0fe6ba5f72894dd2dcba5dca7f2dc56624f`) in |
|||
`WindowsAccessControlLab`, both editions, on 2026-10-10 from 06:14 to 06:31 UTC |
|||
(run `fl1`): the domain controller `F1ADC1`, the file server `F1AFile2`, and the |
|||
client `F1AFile1` (all Windows Server 2025 Datacenter 10.0.26100.32690, domain |
|||
`a.forest1.net`), with the foreign domain controllers `F1BDC1`, `F2DC1`, and |
|||
`F3DC1`. `Validate-LabResults.ps1` printed `LIVE_RESULT_VERIFIED`. Passed / |
|||
failed / skipped, the same in both editions |
|||
([FirstLab.csv](Acceptance-2026-10-10-os-matrix-FirstLab.csv)): |
|||
|
|||
| Role | Passed / failed / skipped | |
|||
| --- | --- | |
|||
| Delegate | 69 / 0 / 0 | |
|||
| ServerAdmin | 36 / 0 / 0 | |
|||
| Admin | 64 / 0 / 0 | |
|||
| Server | 76 / 0 / 1 | |
|||
|
|||
That is 245 passed, 0 failed, and 1 skipped per edition; the skipped test is the |
|||
test of the module version in the Server role, which runs without the module. A |
|||
cell of the matrix has 229 passed and 2 skipped. The 16 tests more that passed |
|||
here are the tests of case 9: 15 that a matrix cell doesn't have (12 in the |
|||
Admin role and 3 in the Server role: the entries of `NtfsLiveForeign` of the |
|||
three foreign domains by `Get-NTFSAccess`, `Add-NTFSAccess`, `Remove-NTFSAccess`, |
|||
and `Get-NTFSEffectiveAccess`, and on the file server), and the test of |
|||
`Get-NTFSOrphanedAccess` that the matrix cells skip. Every test of a matrix |
|||
cell is in this run too (a comparison of the test names of `rc7l` OSFile25 and |
|||
this run found none that only the cell has). The fixture was removed with |
|||
`-RemoveFixture`, and the independent check (`Test-MatrixCleanup.ps1`, with the |
|||
10 SIDs that it recorded before: the accounts of the lab domain and |
|||
`NtfsLiveForeign` in each of the three foreign domains) found the four domains |
|||
and both machines clean: no organizational unit, account, share, folder, local |
|||
group, membership, or profile of the fixture, and none of the residue that the |
|||
check counted then (scheduled tasks, stage items, probe users); its verdict was |
|||
CLEAN. The check has counted the profiles and the log-group entries of the |
|||
account probe since the review that followed, and a `Verify` with that version |
|||
(07:29 UTC, the same SIDs) found none on the two machines either. This |
|||
is one run of one candidate. The baseline didn't run in the first lab on this |
|||
occasion, so the record says nothing about the red state of the new tests there. |
|||
|
|||
### The module's own suite, final candidate |
|||
|
|||
Passed / failed / skipped. Every configuration has 1,011 cases on the machines |
|||
of the domain and 1,010 on the host, which has no DNS domain and so doesn't run |
|||
the case for the fully qualified name of its computer. |
|||
|
|||
| Machine | Elevated, Windows PowerShell | Elevated, PowerShell 7 | Basic user, Windows PowerShell | Basic user, PowerShell 7 | |
|||
| --- | --- | --- | --- | --- | |
|||
| OSFile19 (Server 2019) | 994 / 0 / 17 | 992 / 0 / 19 | 782 / 0 / 229 | 780 / 0 / 231 | |
|||
| OSFile22 (Server 2022) | 994 / 0 / 17 | 992 / 0 / 19 | 782 / 0 / 229 | 780 / 0 / 231 | |
|||
| OSFile25 (Server 2025) | 994 / 0 / 17 | 992 / 0 / 19 | 782 / 0 / 229 | 780 / 0 / 231 | |
|||
| OSWin11E (Windows 11 22H2, the client of the cells) | 994 / 0 / 17 | 992 / 0 / 19 | 782 / 0 / 229 | 780 / 0 / 231 | |
|||
| OSWin11 (Windows 11 26H1) | 994 / 0 / 17 | 992 / 0 / 19 | 782 / 0 / 229 | 780 / 0 / 231 | |
|||
| Host (reference) | 993 / 0 / 17 | 991 / 0 / 19 | 781 / 0 / 229 | 779 / 0 / 231 | |
|||
|
|||
On every machine, the skipped tests are the same as on the host, name for name, |
|||
in every configuration. They are tests that need the other privilege level or |
|||
the other edition and that run in another configuration, as the earlier |
|||
analysis of the skipped rows found (all 578 skipped rows of the suite at |
|||
`5a5d58b` ran in two other configurations). The two disabled-audit-inheritance |
|||
tests that `962887a` added are skipped without the Security privilege, so the |
|||
basic configurations skip two cases more than before (229 instead of 227). |
|||
|
|||
### What the fixes changed |
|||
|
|||
The same tests, the same machines, and three builds. The baseline and the |
|||
candidate `962887a` were run with the tests of the final commit, so the guards |
|||
of the later fixes were present and red. |
|||
|
|||
| Candidate | Elevated | Basic user | |
|||
| --- | --- | --- | |
|||
| Baseline `83149ee` (Server 2022 and 2025) | 4 failures in every configuration | 20 failures in every configuration | |
|||
| `962887a` (all five machines of the domain) | 0 failures | 20 failures in every configuration | |
|||
| Final `fdd7a8b` (all five machines of the domain) | 0 failures | 0 failures | |
|||
|
|||
The four elevated failures of the baseline are the same on Server 2022 and |
|||
2025, in both editions: |
|||
|
|||
1. `Get-NTFSInheritance` with a security descriptor, "Should report the same |
|||
state as for the path of the item" (existing test), |
|||
2. the same for "a file without audit entries" and |
|||
3. "a folder without audit entries" (two new guards), |
|||
4. `Get-NTFSEffectiveAccess`, "Should return the result of this computer and |
|||
warn for an empty -ServerName" (existing test). |
|||
|
|||
The 20 failures in the basic-user mode are the same set on every machine of the |
|||
domain and in both editions, once the name of the computer in two test names is |
|||
set aside. All of them call `Get-NTFSEffectiveAccess` without a reachable |
|||
remote authorization manager: 11 in its own tests, 5 in the tests of a later |
|||
command that ends the pipeline, 2 for an unresolved identity, 1 for a path that |
|||
doesn't exist, and 1 for an item whose owner may not read its permissions. Each |
|||
fails with "Could not get effective permissions from machine 'localhost'. The |
|||
error is 'Access is denied'". The host and the CI runners aren't in a domain and |
|||
passed them all along. The failing names of every run are in |
|||
[the failures table](Acceptance-2026-10-10-os-matrix-Failures.csv). |
|||
|
|||
## Defects found |
|||
|
|||
### In the module |
|||
|
|||
1. **`Get-NTFSInheritance -SecurityDescriptor` for an item without audit |
|||
entries** reported the audit inheritance as disabled, where `-Path` reported |
|||
it as enabled. On Windows Server 2022 and 2025 and on Windows 11 26H1, .NET |
|||
reports the SACL of such an item as protected from inheritance when it reads |
|||
all sections together, and as not protected when it reads the SACL alone; |
|||
the descriptor kept the first state. The baseline showed it on Windows |
|||
Server 2022 and 2025, and an earlier run with the same two existing tests |
|||
showed it on Windows 11 26H1; the host (build 33438) reads both ways alike. |
|||
The baseline wasn't run on Server 2019 and Windows 11 22H2. `Write()` stores |
|||
the sections that were read, so a descriptor with the wrong flag would also |
|||
have stored the SACL as protected. Fixed in `962887a`: the descriptor takes |
|||
the audit section from a separate read, as it already did for the access |
|||
section. |
|||
2. **`Get-NTFSEffectiveAccess -ServerName ''`** wrote "Access is denied" on the |
|||
same machines, because Windows takes an empty name for this computer and the |
|||
remote interface then refuses the check; on the host it fails as |
|||
unreachable. Fixed in `962887a`: an empty name names no computer, so the |
|||
cmdlet warns and returns the result of this computer on every machine. |
|||
3. **`Get-NTFSEffectiveAccess` for a user who isn't an administrator**, on a |
|||
computer in a domain, wrote "Access is denied" and returned nothing for |
|||
every account, also for the default `-ServerName localhost`. See the probe |
|||
below. The function that fails, `GetEffectivePermissions_AuthzInitializeContextFromSid`, |
|||
is identical in the published 5.0.0-rc6 (compared with `git diff` against |
|||
the tag; the cmdlet wasn't run there), and the defect shows in the tests only |
|||
on a domain-joined machine as a basic user. Fixed in `fdd7a8b`. |
|||
|
|||
### The probe |
|||
|
|||
The remote interface of the authorization manager of a computer answers only |
|||
its administrators and the members of its group Access Control Assistance |
|||
Operators. A computer in a domain offers the interface to every caller; a |
|||
computer outside a domain doesn't, so the cmdlet already used the local manager |
|||
there. The probe, on Windows Server 2019, 2022, and 2025, with the module of |
|||
`962887a` (before the third fix): |
|||
|
|||
| Token | Name of this computer (the default, `localhost`, computer name, FQDN) | `-ServerName ''` (the local manager) | Another computer | |
|||
| --- | --- | --- | --- | |
|||
| Lab account, elevated | Result for every account | Result for every account | Result for every domain account | |
|||
| Lab account, filtered (SAFER Normal User) | Access denied for every account | Result for every account, also the Administrator and Domain Users of the domain | Result for domain accounts: network authentication carries the groups of the account | |
|||
| Local standard user | Access denied | Result, except for the domain Administrator, a user account of the domain | Access denied: a local account has no domain credentials | |
|||
| Standard domain user | Access denied for every account | Result for every account, also the domain Administrator | Access denied, as the cmdlet page and the live test of the delegated account describe | |
|||
|
|||
The accounts were the user itself, Everyone, the local Administrator, and the |
|||
Administrator and Domain Users of the domain. The cmdlet now uses the local |
|||
manager for a name of this computer when the remote one refuses the user. The |
|||
second column shows that this answers for every caller of these four kinds, |
|||
except for a local user who asks about a user account of the domain; that stays |
|||
an "Access is denied" from Windows. For another computer, the denial stays an |
|||
error. A new live test runs the case as the administrator of the file server, |
|||
who isn't an administrator of the client, and compares the rights with the S4U |
|||
oracle that the other roles use. |
|||
|
|||
### In the environment |
|||
|
|||
- The base images of Windows Server 2019 and Windows 11 22H2 had an empty EFI |
|||
system partition: the `bcdboot` of the Server 2025 host fails with exit code |
|||
193 on their boot files, and AutomatedLab ignores the exit code, so the VM |
|||
doesn't boot (Hyper-V event 18603). `Repair-OsMatrixBoot.ps1` runs the |
|||
`bcdboot` of the image itself on the VM's own disk. |
|||
- Windows 11 26H1 (28000.1836) joins the domain but loses the secure channel to |
|||
the domain controller (26100.32690): the client asks `NetrLogonGetCapabilities` |
|||
for query level 2, the controller answers `0xC0000022`, and the client denies |
|||
the channel (`NlConfirmRequestedCapabilities: denying access ... 0xc0000022`). |
|||
A rejoin can't fix a protocol mismatch, so this machine isn't a domain client. |
|||
- The Windows 11 Enterprise Evaluation image shuts itself down an hour after |
|||
each start (`wlms.exe`: "The license period for this installation of Windows |
|||
has expired"; status 0xC004F009). It recorded its install time on a clock that |
|||
ran about seven hours ahead, the clock was then corrected, and the evaluation |
|||
licensing took the step back as the end of the grace period. One of the two |
|||
documented rearms didn't clear it. After an unplanned shutdown its machine |
|||
account password no longer matched the domain's (`0xC000018D` for domain |
|||
logons, `nltest /sc_verify` reports `ERROR_INVALID_PASSWORD`); |
|||
`Test-ComputerSecureChannel -Repair` with the lab account repaired it. Runs on |
|||
this machine have to stay under an hour from its start. |
|||
- A profile of an account that a scheduled task used stayed loaded on one |
|||
server, so its folder couldn't be removed until the machine restarted. |
|||
|
|||
### In the harness |
|||
|
|||
- A line that a native command writes to stderr is a terminating error in |
|||
Windows PowerShell 5.1 under `$ErrorActionPreference = 'Stop'` when `2>&1` |
|||
redirects it. The first "The directory is not empty" of PowerShell 7 ended the |
|||
fixture removal on Server 2019 before the retry. |
|||
- `Get-LocalGroupMember` fails with "Failed to compare two elements in the |
|||
array" when a group holds an orphaned SID, which stopped the setup of the next |
|||
run. The setup now adds members and ignores `MemberExistsException`. |
|||
- A profile that is gone in the meantime failed the cleanup of the client. |
|||
|
|||
All three are fixed in the controller (`7d47316`). |
|||
|
|||
### The effective-access failures of the Admin role |
|||
|
|||
In the cells of the Windows Server 2022 file server, and only there, the Admin |
|||
role failed two effective-access tests of case 3 in `rc7f`, `rc7h`, `rc7i`, and |
|||
`rc7j` (`rc7j` ran the candidate `962887a`; `rc7i` failed only in Windows |
|||
PowerShell): `Get-NTFSEffectiveAccess` returned no access (Synchronize only, |
|||
`0x100000`) for the subject of case 3, where the tests expect the rights |
|||
through the nested domain groups (`0x1200A9`, and `0x1201BF` with the local |
|||
group of the file server), either with the default `-ServerName` (the |
|||
authorization manager of the client) or with the name of the file server, or |
|||
both. The baseline had passed the same position in `rc7e` and `rc7k`, and the |
|||
audit read of `962887a` is the only change of the module on the path of the |
|||
cmdlet before `fdd7a8b`, so the module was the first suspect. It isn't the |
|||
cause, as the replay below shows. In `rc7c`, the failed cleanup of the first cell |
|||
had left the accounts in place through all three cells; in the later sequences |
|||
the fixture was removed after most cells and created again for the next one, with |
|||
the same names and new SIDs. |
|||
|
|||
**The replay.** The controller of `db04ef2` (the controller of `rc7f` to |
|||
`rc7k`, blob `683aee91ec8805d77a33b2d368acaf876724fa32`, the fixed name of the |
|||
account), Windows PowerShell only, the file server OSFile22, seven cells (`ab0` |
|||
to `ab6`, 04:57 to 05:33 UTC) back to back after a restart of the client, the |
|||
module alternating between the baseline `83149ee` and the final |
|||
candidate `fdd7a8b`. The live tests were the blob `efe36e5073b9b10742ca7de242ddcbe90d8eda62` |
|||
with one test added for this replay (the file then has the blob |
|||
`72c12fe09e4005e048a8aed0fa02b9a922c58f34`), which isn't committed: after the |
|||
three effective-access tests of the Admin role it prints, in the same second, the |
|||
state of the subject account (see below); it runs after them, so it can't change |
|||
their results. `ab0` is the warm-up and has a new fixture. |
|||
|
|||
| Cell | Module | Admin role at (UTC) | Minutes since the previous removal | Test 1, name of the file server | Test 2, default server name | |
|||
| --- | --- | --- | ---: | --- | --- | |
|||
| `ab0` | final | 05:00:57 | 54.0 | pass | pass | |
|||
| `ab1` | baseline | 05:06:09 | 3.8 | FAIL `0x100000` | FAIL `0x100000` | |
|||
| `ab2` | final | 05:11:12 | 3.7 | pass | pass | |
|||
| `ab3` | final | 05:16:23 | 3.9 | FAIL `0x100000` | FAIL `0x100000` | |
|||
| `ab4` | baseline | 05:21:34 | 3.9 | pass | pass | |
|||
| `ab5` | baseline | 05:26:57 | 3.9 | FAIL `0x100000` | FAIL `0x100000` | |
|||
| `ab6` | final | 05:32:04 | 3.8 | pass | pass | |
|||
|
|||
In every cell from `ab1` on, the accounts were created 1.0 minute after the |
|||
removal of the previous fixture, and the Admin role ran 3.7 to 3.9 minutes |
|||
after it. The cells differ in the module, in the outcome, and in one more |
|||
variable: the age of the entry that an earlier cell left for the same account |
|||
name, counted from that cell's Admin role (5.2 to 5.4 minutes in the failing |
|||
cells, 10.25 to 10.5 minutes in the passing ones). Not counting the warm-up |
|||
`ab0`, the baseline fails two of its three cells and the final candidate one of |
|||
its three, and the failing and the passing cells alternate. If the module |
|||
decided, the baseline wouldn't fail. |
|||
|
|||
**What is wrong in a failing cell.** The test that runs right after the three |
|||
tests printed the same in `ab1`, `ab3`, and `ab5`: the name `osmatrix\NtfsLiveSubject` |
|||
resolves to the current SID; a Kerberos S4U logon of `NtfsLiveSubject@osmatrix.net` |
|||
on the client returns the current SID with nine groups, among them `NtfsLiveInner` |
|||
and `NtfsLiveOuter` (this logon is the oracle of the controller); `Get-NTFSEffectiveAccess` |
|||
with the unreachable server name, which falls back to the local authorization |
|||
manager, returns `0x1200A9`; and every call that asks a remote authorization |
|||
manager, the one of the client by the default `-ServerName` and the one of the |
|||
file server by its name, returns `0x100000`, by name and by SID alike. In the |
|||
passing cells all five calls were right. So the remote authorization managers |
|||
answer as if the account had no groups, while the name resolution, the Kerberos |
|||
logon, and the local manager are right in the same second. The module makes the |
|||
same Authz calls for both kinds of manager; only the manager differs. |
|||
|
|||
**A model that fits.** The pattern is the one of a cache. The model: a remote |
|||
authorization manager computes the groups of an account at the first request |
|||
for the account name and answers from that result for L minutes, also when the |
|||
account was deleted and created again under the same name in the meantime. The |
|||
file server and the client have one entry each for a name, and use doesn't |
|||
renew it. `Test-StaleAuthzModel.ps1` replays the Admin roles of the timeline of |
|||
all cells ([Timeline.csv](Acceptance-2026-10-10-os-matrix-Timeline.csv): `rc7c` |
|||
to `rc7l` and `ab0` to `ab10`, 43 runs in 27 cells, and `rc7d`, which stopped |
|||
before its tests) against the model (`-StepMinutes 0.05`, so every bound is known |
|||
to within 0.05 minute). With L from 9.35 to 10.25 minutes the model predicts the |
|||
result of the first test (the file server) of all 43 runs, and with L from 9.95 |
|||
to 10.25 minutes that of the second (the client): 43 of 43 for each, with 6 and 9 |
|||
failures. One L from 9.95 to 10.25 minutes serves both tests (86 of 86). That |
|||
includes the cells where the two tests differ (`rc7f`, `rc7h`: the entry of the |
|||
client was stale, the one of the file server had expired), the cells of the |
|||
baseline that passed (`rc7e`, `rc7k`), and the cells that passed with an account |
|||
name that was new. A random assignment of the observed outcomes to the runs (the |
|||
same number of failures) never fits that well: none of 5,000 assignments reaches |
|||
43 of 43 for any L, and the best of them reaches 41 for the first test and 39 |
|||
for the second (`-Permutations 5000`, fixed seed). I fitted the model after |
|||
`ab3` and wrote down its predictions before they ran (in the night log of the |
|||
session, outside the repository, at 05:20 UTC): `ab4` passes, `ab5` fails, `ab6` |
|||
passes. All three held, and `ab5` is the baseline failing; if the module decided, |
|||
`ab5` would have passed and `ab6` would have failed. `ab6` is the weakest of the |
|||
three: its entry was 10.5 minutes old, a little above the lifetimes that fit. |
|||
|
|||
**The probes of the night.** Three probes (the second is |
|||
`Probe-AccountRecreation.ps1` of the kit) deleted and created the accounts again |
|||
within seconds. In that regime, the Kerberos S4U logon itself returned the old |
|||
account on the domain controller, the client, and the file server for more than |
|||
seven and less than fifteen minutes, and both modules returned `0x100000` for |
|||
every call. In the cells, with one minute between the deletion and the new |
|||
creation, the Kerberos logon is right (the oracle of the controller never failed, |
|||
and the replay prints it). Both are state that Windows keeps for a name beyond |
|||
the deletion of the account; the cells show the variant of the remote |
|||
authorization managers. |
|||
|
|||
The first loop probe, with the baseline and the final candidate: |
|||
|
|||
| Round | Name resolves to | S4U token of the account on the client | Baseline and final candidate, by name and by SID, with the default `-ServerName` and with the file server | |
|||
| --- | --- | --- | --- | |
|||
| 1 (new names) | the current SID | holds the outer group | `0x1200A9`, both modules, all four calls | |
|||
| 2 to 6 | the SID of the previous round in the first process of a round, the current SID in the second | lacks the new outer group | `0x100000`, both modules, all four calls | |
|||
|
|||
The probe of the kit, `Probe-AccountRecreation.ps1`, which also logs the user on |
|||
with Kerberos S4U on the domain controller, the client, and the file server, gave |
|||
the same picture in four rounds with the baseline and the final candidate (04:19 |
|||
UTC): in round 1, all three machines returned the current account and both |
|||
modules `0x1200A9` for every call; in rounds 2 to 4, all three returned the old |
|||
account, without the new outer group, and both modules `0x100000` for every call. |
|||
The own ticket cache of the computers (logon session `0x3e7`) held no ticket for |
|||
the account, and a purge of it changed nothing. |
|||
|
|||
A third probe created five sets of accounts, logged each user on with S4U on the |
|||
three machines, deleted and created them again with the same names within a |
|||
second, and asked once per set after a delay (the sets after the first were |
|||
asked after a `klist purge` on the client, so the rows of the client for them |
|||
aren't independent): |
|||
|
|||
| Question | Domain controller | File server | Client | |
|||
| --- | --- | --- | --- | |
|||
| At once | old account | old account | old account; Authz by SID `0x100000` | |
|||
| After `klist purge` on the client | old account | old account | current account (the token of the session); Authz by SID still `0x100000` | |
|||
| After `nltest /sc_reset`, a DNS flush on the client, and a restart of the Kerberos service of the domain controller | old account | old account | unchanged | |
|||
| 60 seconds after the accounts were created again | old account | old account | Authz by SID `0x100000` | |
|||
| 180 seconds | old account | old account | Authz by SID `0x100000` | |
|||
| 420 seconds | old account | old account | Authz by SID `0x100000` | |
|||
| 900 seconds | current account | current account | Authz by SID `0x1200A9`, also with the name of the file server | |
|||
|
|||
`WindowsIdentity` with the user principal name, which the module doesn't call, |
|||
returns the old account in this regime, so the module isn't involved in it |
|||
either. |
|||
|
|||
**What the evidence supports.** The failures of the Admin role depend on the |
|||
position of the cell relative to the previous fixture with the same account |
|||
name, and the module doesn't decide the outcome: not counting the warm-up, the |
|||
baseline fails in two of its three replay cells and the final candidate in one |
|||
of its three, and one model with one parameter predicts all 43 runs, including |
|||
three that it predicted before they ran. In a failing cell the remote |
|||
authorization managers of the client and of the file server are the wrong layer: |
|||
the name resolution, a Kerberos logon of the account, and the local |
|||
authorization manager are right in the same second, and the module makes the |
|||
same Authz calls for both kinds of manager. The replay gives no reason to change |
|||
the module for it. |
|||
|
|||
**What it doesn't establish.** How Windows does it: which component keeps the |
|||
state, and why for about ten minutes. L is estimated from 43 runs on one client |
|||
and three file servers with a cell every five minutes or so, so a different |
|||
spacing of the cells could tell more. The window of L that fits the client test |
|||
is 0.3 minute wide, and its bounds come from two runs (`rc7h`, whose Core run is |
|||
9.92 minutes after the entry of `rc7g`, and `ab2`, 10.25 minutes after `ab0`). |
|||
The times of the model are those of the start of the Admin role, some seconds |
|||
before the first request, and the offset may differ between the editions, so the |
|||
bounds of L are uncertain by about that much. The model describes the |
|||
observations that it was fitted to, and the three predictions are the only ones |
|||
that it didn't see. The replay rules out a module effect that decides the |
|||
outcome (every position that the model predicts to fail failed for the |
|||
baseline, the final candidate, and the baseline again, and every position that |
|||
it predicts to pass passed for the final candidate, the baseline, and the final |
|||
candidate), but six runs can't rule out a small or a random effect of the |
|||
module. The replay ran one edition against one file server. Whether a user can |
|||
meet it, an administrator who deletes an account, creates it again under the same |
|||
name, and asks within ten minutes for its effective access on a remote computer, |
|||
wasn't tried outside the lab. The cmdlet can't detect it: the answer of a manager |
|||
that has no groups for the account looks like the answer for an account without |
|||
access. |
|||
|
|||
**The change of the controller.** A new fixture gets a new name for the account |
|||
of case 3 (`NtfsLiveSubject` and four digits, `1dec389`), and a fixture that |
|||
exists keeps its account. No cache has to be flushed, and the module isn't |
|||
changed by this. With it, the Windows Server 2022 cell passed in `rc7l`, where the |
|||
cells of the old controller had failed in `rc7f`, `rc7h`, `rc7i`, and `rc7j`, and |
|||
so did the other two cells of that sequence. |
|||
|
|||
The controller of `1dec389` (blob `9917cac5820ed20ed2eb5592eff06894677f9874`) |
|||
then ran four more cells of the replay, `ab7` to `ab10`: baseline, final, |
|||
baseline, final, in Windows PowerShell against OSFile22, back to back after a |
|||
restart of the client (05:37 to 05:58 UTC), with the same diagnostic test. Each |
|||
cell created a fixture with a new name for the account of case 3. I wrote the |
|||
prediction down before the Admin role of `ab7` ran (night log, about 05:40 UTC): |
|||
all four pass. For a controller that reuses the name, the model with L = 10.1 |
|||
minutes predicts failures in `ab7` (the entry of `ab6` would have been 8.1 |
|||
minutes old) and in `ab9` (5.4 minutes after `ab8`): |
|||
|
|||
| Cell | Module | Account of case 3 | Admin role at (UTC) | Test 1 | Test 2 | Test 3, local manager | The model, had the name been reused (`-AsIfSameSubject`) | |
|||
| --- | --- | --- | --- | --- | --- | --- | --- | |
|||
| `ab7` | baseline | `NtfsLiveSubject8013` | 05:40:11 | pass | pass | pass | Test 1 FAIL; Test 2 FAIL, unless the restart of the client at 05:34 cleared its entry | |
|||
| `ab8` | final | `NtfsLiveSubject0900` | 05:45:32 | pass | pass | pass | pass | |
|||
| `ab9` | baseline | `NtfsLiveSubject7705` | 05:50:54 | pass | pass | pass | both tests FAIL | |
|||
| `ab10` | final | `NtfsLiveSubject8799` | 05:56:12 | pass | pass | pass | pass | |
|||
|
|||
The model doesn't know about restarts, and the client restarted ten times between |
|||
23:37 and 05:34 UTC, nine of them after the first cell had started (Hyper-V worker |
|||
log, UTC: 23:37, 00:38, 01:43, 01:58, 02:43, 03:23, |
|||
03:42, 04:07, 04:55, and 05:34; the file servers and the domain controller |
|||
didn't restart between the first and the last run, except OSFile22 at 01:36). |
|||
If the entry of a remote manager lives in the memory of the computer, a restart |
|||
clears it. For the fit this changes no prediction: of the entries that the model |
|||
keeps, only one lives across a restart and is read by a later run (the entry |
|||
that `rc7e` made at 00:35:48 on OSFile25, read by its Core run after the restart |
|||
at 00:38), and that run has the same account, so it passes either way. For the |
|||
counterfactual it matters once, in the table: the restart at 05:34 came between |
|||
`ab6` and `ab7`, so the client test of `ab7` is a prediction only if the state |
|||
survives a restart, while the file-server test (OSFile22 didn't restart) is one |
|||
in any case. |
|||
|
|||
In each cell the diagnostic test printed the right rights for all five calls |
|||
(`0x1200A9` for the client, `0x1201BF` for the file server). In the timeline, the |
|||
old controller failed in 7 of its 20 cells, all on OSFile22 (`rc7f`, `rc7h`, |
|||
`rc7i`, `rc7j`, `ab1`, `ab3`, `ab5`); the new one failed in none of its 7 (`rc7l` |
|||
and `ab7` to `ab10`), where the model for a reused name predicts failures in 3 |
|||
(the OSFile22 cell of `rc7l`, `ab7`, `ab9`). The cells aren't paired runs and |
|||
seven cells are few, so this doesn't prove that the new names are the reason; it |
|||
shows that the failures are absent where the model says that a reused name |
|||
fails, which the reuse of the name explains and the module doesn't. |
|||
|
|||
## Limits and open items |
|||
|
|||
- Every run is a validation of a local build (`-ModulePath`). The acceptance of |
|||
a release is the run with `-Version` of the exact prerelease from the |
|||
PowerShell Gallery in every cell, which handoff 3 sequences after the maintainer |
|||
decides which fixes belong to 5.0.0-rc7. The same cells have to be repeated for |
|||
a changed binary. The `-Version` path of `Run-MatrixSequence.ps1` ran once as a |
|||
dry run with the published 5.0.0-rc6 on OSFile19 in Windows PowerShell (07:39 to |
|||
07:45 UTC, kit at `664ef3a`): the controller used the published module, the |
|||
validation reported `LIVE_RESULT_NOT_ACCEPTED` as it must (151 passed, 78 |
|||
failed, 2 skipped: the live tests that rc6 predates, such as the later-command |
|||
tests, `Get-ChildItem2 -Filter`, `InheritedFrom`, and the two new ServerAdmin |
|||
tests), and the cleanup verdict was CLEAN. That tests the mechanics only and |
|||
accepts nothing. |
|||
- Case 9 (accounts of other domains and forests) needs trusts that the matrix |
|||
lab doesn't have; it runs only in `WindowsAccessControlLab`, where the |
|||
baseline passed it and the final candidate passed it in run `fl1` (see "First |
|||
lab, final candidate (case 9)"). The published package has to run there too. |
|||
- The file servers are Windows. A server of another kind is the subject of |
|||
Decision 23. |
|||
- Windows 11 26H1 has no domain cell until the domain controller or the |
|||
mismatch changes. The Windows 11 client of the cells is the 22H2 evaluation |
|||
build, which has to be started shortly before a run (see above). |
|||
- `GetEffectiveAccess` ignores what the initialization of the resource manager |
|||
throws (an outer `catch { }` that is older than this work). An operating |
|||
system that refused another computer at that step, not at the context as every |
|||
machine of the matrix did, would give a result without rights and a warning |
|||
instead of the documented error; and a failure of the local fallback for a name |
|||
of this computer would give a result without rights and neither a warning nor an |
|||
error, because the flag that suppresses the warning is set before the fallback |
|||
runs (also older than this work). The help and the CHANGELOG say that the error |
|||
stays for another computer, which holds for the denial at the creation of the |
|||
context. This is unverified on every machine of the matrix and outside the |
|||
fixes (Decision 16 asks for reproducible defects only); recording the |
|||
initialization exceptions in `authzException` would close it. |
|||
- The built-in `security-review` agent (the custom `security-reviewer` couldn't |
|||
start: its model isn't offered, and I didn't override it) read `83149ee..664ef3a` |
|||
and found no exploitable vulnerability in the changes of the module: the |
|||
decision "this name is this computer" is an exact, case-insensitive match |
|||
(true for `.`, `localhost`, the machine name, the host name, and the name with |
|||
the DNS domain; false for null, empty, whitespace, a trailing dot, an IP |
|||
address, UNC forms, an embedded NUL, and a name of 100,000 characters, all of |
|||
which keep the remote path and its denial), the fallback runs in the caller's |
|||
own process and token, and the separate audit read uses the privilege handling |
|||
of the combined read. It reported two LOW items. The first is the item above. |
|||
The second is that the kit creates staging folders directly under `C:\` |
|||
(`C:\NTFSSecurityLab`, `C:\NtfsMatrixLocal`, `C:\NtfsMatrixProbe`, |
|||
`C:\NtfsProbeModules`) without an ACL, so they inherit Authenticated Users: |
|||
Modify, while scripts and the module's DLL in them run elevated or as the role |
|||
accounts: a principal that can run code on a lab VM during a run could replace |
|||
them. The labs are isolated and the role accounts must read the tests and write |
|||
results there, so protecting the folders is a design change of the controller |
|||
that needs a new acceptance; I left it for the maintainer. The reviewer also |
|||
noted that the lab password crosses remoting and `Register-ScheduledTask |
|||
-Password` (module or script-block logging on a machine would record it), that |
|||
the controller reaches the client with CredSSP to an IP address, where the |
|||
logon inside CredSSP is NTLM-only and the server isn't authenticated (the |
|||
policy comes from AutomatedLab), and that the help says that a user who isn't an |
|||
administrator gets the result on a domain computer without saying that a local |
|||
standard user who asks about a domain account still gets "Access is denied" (the |
|||
probe table above) or that the answer now comes from the caller's own token and |
|||
manager. It could not check the ACL of `C:\` on the lab VMs, the behavior of the |
|||
fallback as a non-administrator on a domain computer, or whether the local |
|||
manager equals the remote one for every token. |
|||
- The scripts of the kit were read by a reviewer who ran none of them; module |
|||
logging or script-block logging on a machine would record the lab password |
|||
that `Register-ScheduledTask -Password` needs. |
|||
- The mechanism isn't known. The replay shows that the remote authorization |
|||
managers answer for an account name from state that outlives the account, and |
|||
the model puts the lifetime at about ten minutes (9.95 to 10.25 minutes for |
|||
both tests, from 43 runs), but I didn't find which component keeps it, why that |
|||
long, or whether it is constant: all runs have the same timing, and it was |
|||
fitted to them. The replay ran one edition (Windows PowerShell, Desktop) |
|||
against one file server (OSFile22). The probes of the first regime (accounts |
|||
deleted and created again within seconds), in which the Kerberos S4U logon |
|||
returned the old account for more than seven and less than fifteen minutes, |
|||
were measured once, with no repetition, and five remedies (`klist purge`, |
|||
`nltest /sc_reset`, a DNS flush, a restart of the Kerberos service of the |
|||
domain controller, and waiting) were tried: only waiting helped. A script of |
|||
the kit that creates accounts again under one name would meet the state; the |
|||
controller doesn't any more. |
|||
- The end-state check of the matrix reported the staging folders of the suite |
|||
runs (`C:\NtfsMatrixLocal`) as residue in the three cells of `rc7l`, which |
|||
made their verdict DIRTY, although the fixture was gone. The suite runner now |
|||
removes its stage after it has copied the results back. The check counts the |
|||
items in the stage folders, each of the folders `C:\NtfsProbeRecreation` and |
|||
`C:\NtfsProbeModules` that exists, the scheduled tasks of the matrix, the local |
|||
`NtfsProbe*` users, their profiles and profile folders (`C:\Users\NtfsProbe*`), |
|||
their entries in Performance Log Users, and the `NtfsProbe*` objects of the |
|||
directory, and `-Mode Repair` removes what it finds. `Verify` and `Repair` treat |
|||
every unresolved `S-1-5-21-…` member of Performance Log Users as the probe's |
|||
(the probe is the only writer of that group in these labs, and its own cleanup |
|||
uses the same pattern); on a machine where something else leaves such members, |
|||
the check would report them and `Repair` would remove them. A `Verify` on the |
|||
two machines of the first lab (07:29 UTC) found none. The check ran with real |
|||
residue on the five machines three times: at 06:03 UTC with the script that |
|||
`9344ff7` committed at 06:08 UTC, at |
|||
06:44 UTC with the handling of profiles and of the entries in Performance Log |
|||
Users that the follow-up review asked for, and at 06:51 UTC after the second run |
|||
had shown that the check missed the entry of a local user (`net localgroup` |
|||
lists a local user by its bare name, and the pattern wanted a domain prefix). A |
|||
first attempt at 05:58 UTC died while it made the residue, without a log (the |
|||
cause is unknown; decision log D37), so the run at 06:03 started in a lab where |
|||
that attempt might have made some items; its first `Verify` listed exactly the |
|||
expected ones. |
|||
The last run made residue of every kind at once: a stage item and a local user |
|||
`NtfsProbeDummy` on OSFile19; a local user with a profile and an entry in |
|||
Performance Log Users on OSFile19; a local user with a profile that was deleted |
|||
afterwards (an orphaned profile) on OSFile22; `C:\NtfsProbeRecreation` on |
|||
OSFile22; a domain account that was made a member of Performance Log Users on |
|||
OSFile22 and then deleted in the directory (`net localgroup` still showed it by |
|||
its cached name); a scheduled task `NtfsMatrix-dummy` on OSFile25; |
|||
`C:\NtfsProbeModules` on OSWin11E; and a disabled directory user |
|||
`NtfsProbeDummy`. `Verify` counted every item (on OSFile19 `probe users=2 probe |
|||
profiles=1 probe group members=1`, on OSFile22 `probe profiles=1 probe group |
|||
members=1`, and so on), and the verdict expression of `Run-MatrixSequence.ps1`, |
|||
read from the script with the parser and not copied, gave DIRTY. `Repair` |
|||
removed every item, and a second `Verify` gave CLEAN. Not tried: a profile that |
|||
stays loaded (the retries of `Repair` never needed a second attempt), and an |
|||
entry that `net localgroup` shows as a bare SID, so the branch for a SID and |
|||
`Remove-LocalGroupMember` with a SID didn't meet real residue (the cached name |
|||
of the deleted domain account was removed by name). |
|||
- Decision 24 is the agent's decision under the maintainer's delegation and stays |
|||
`proposed`. So do Decisions 22 and 23. |
|||
|
|||
## Evidence |
|||
|
|||
The raw logs, result files, probe outputs, and the packages are local, outside |
|||
Git, in the session files of the run; they aren't part of this commit. The |
|||
tables of this record are in the files next to it: |
|||
|
|||
- [the suite results of the three candidates](Acceptance-2026-10-10-os-matrix-LocalSuite.csv), |
|||
- [the failing tests of every suite run](Acceptance-2026-10-10-os-matrix-Failures.csv), |
|||
- [the controller cells of `rc7c` to `rc7l`](Acceptance-2026-10-10-os-matrix-Cells.csv), |
|||
- [the timeline of the Admin role of every cell and edition, `rc7c` to `rc7l` and the replay `ab0` to `ab10`, with the three effective-access tests](Acceptance-2026-10-10-os-matrix-Timeline.csv), |
|||
- [the counts of the first-lab run `fl1`](Acceptance-2026-10-10-os-matrix-FirstLab.csv). |
|||
|
|||
The scripts that produced them are in [Acceptance](Acceptance), and the |
|||
decision is `.memory-bank\decisions\0024-os-matrix-lab.md`. |
|||
@ -0,0 +1,125 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $Name, |
|||
[Parameter(Mandatory)] [string] $OperatingSystem, |
|||
[Parameter(Mandatory)] [string] $IpAddress, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[ValidateRange(2, 16)] [int] $MemoryGB = 4, |
|||
[ValidateRange(1, 8)] [int] $Processors = 2, |
|||
[ValidateRange(5, 240)] [int] $StartTimeoutMinutes = 40, |
|||
[string] $BackupRoot = 'C:\ProgramData\AutomatedLab\Backups' |
|||
) |
|||
|
|||
# Adds one machine to the already deployed matrix lab (Decision 24) and creates only that machine. AutomatedLab 5.61 has no supported way to |
|||
# extend a deployed lab: Add-LabMachineDefinition refuses while a lab is imported or exported, and Install-Lab creates every machine of the |
|||
# lab again. This script copies the lab metadata first (the copy is readable by administrators only, because the files hold the lab |
|||
# credentials), reloads the definition with Import-LabDefinition (never Import-Lab), adds the machine, exports the definition, and then runs |
|||
# the same steps Install-Lab runs for a single machine: base image, hosts entries, virtual machine, start. The other machines are neither |
|||
# created, started, nor changed. Windows PowerShell 5.1 on the host; run it elevated. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START add-os-matrix-machine lab=$LabName name=$Name os='$OperatingSystem' ip=$IpAddress" | Set-Content -LiteralPath $LogPath |
|||
$lockPath = $null |
|||
try { |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
if ((Get-Lab -List) -notcontains $LabName) { throw "The lab '$LabName' does not exist." } |
|||
if (Get-VM -Name $Name -ErrorAction SilentlyContinue) { throw "A virtual machine named '$Name' exists already." } |
|||
$hostsText = Get-Content -LiteralPath (Join-Path -Path $env:SystemRoot -ChildPath 'System32\drivers\etc\hosts') -Raw |
|||
if ($hostsText -match ('(?im)^\s*[^#\s]+\s+{0}(\.|\s|$)' -f [regex]::Escape($Name)) -or $hostsText -match ('(?im)^\s*{0}\s' -f [regex]::Escape($IpAddress))) { |
|||
throw "The hosts file mentions '$Name' or $IpAddress already." |
|||
} |
|||
|
|||
$labFolder = Join-Path -Path (Get-LabConfigurationItem -Name LabAppDataRoot) -ChildPath "Labs\$LabName" |
|||
$backup = Join-Path -Path $BackupRoot -ChildPath ('{0}-{1:yyyyMMdd-HHmmss}' -f $LabName, [DateTime]::UtcNow) |
|||
$null = New-Item -ItemType Directory -Path $backup -Force |
|||
$null = & icacls.exe $backup /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' |
|||
if ($LASTEXITCODE -ne 0) { throw "icacls failed on the backup folder (exit code $LASTEXITCODE)." } |
|||
Copy-Item -LiteralPath $labFolder -Destination $backup -Recurse |
|||
Write-Step "lab metadata copied to $backup" |
|||
|
|||
Import-LabDefinition -Name $LabName |
|||
$definition = Get-LabDefinition |
|||
$before = @(Get-LabMachineDefinition | ForEach-Object -Process { $_.Name }) |
|||
$domainName = $definition.Domains[0].Name |
|||
$rootDc = Get-LabMachineDefinition | Where-Object -FilterScript { 'RootDC' -in $_.Roles.Name } | Select-Object -First 1 |
|||
$dcAddress = ($rootDc.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString |
|||
$dcPrefix = ($dcAddress -split '\.')[0..2] -join '.' |
|||
$newPrefix = ($IpAddress -split '\.')[0..2] -join '.' |
|||
if ($dcPrefix -ne $newPrefix) { throw "$IpAddress isn't in the /24 of the domain controller ($dcAddress)." } |
|||
Write-Step ("definition loaded: domain {0}; machines {1}; installation account {2}" -f $domainName, ($before -join ','), $definition.DefaultInstallationCredential.UserName) |
|||
|
|||
$parameters = @{ |
|||
Name = $Name; DomainName = $domainName; OperatingSystem = $OperatingSystem; Memory = ($MemoryGB * 1GB) |
|||
Processors = $Processors; Network = $LabName; IpAddress = $IpAddress |
|||
} |
|||
if ($OperatingSystem -like 'Windows 11*') { |
|||
$parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' } |
|||
} |
|||
|
|||
Add-LabMachineDefinition @parameters |
|||
Export-LabDefinition -Force -ExportDefaultUnattendedXml |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$after = @(Get-LabVM -IncludeLinux | ForEach-Object -Process { $_.Name }) |
|||
$difference = @(Compare-Object -ReferenceObject ($before + $Name) -DifferenceObject $after) |
|||
if ($difference.Count -gt 0) { throw "The exported lab doesn't hold exactly the old machines plus $Name. Restore the metadata from $backup." } |
|||
Write-Step 'definition extended and exported' |
|||
|
|||
$lockCandidate = Get-LabConfigurationItem -Name DiskDeploymentInProgressPath |
|||
if (Test-Path -LiteralPath $lockCandidate) { throw "Another lab disk deployment seems to be in progress ($lockCandidate)." } |
|||
$null = New-Item -Path $lockCandidate -ItemType File -Value $LabName |
|||
# Only a lock that this script created is removed in the finally block below. |
|||
$lockPath = $lockCandidate |
|||
New-LabBaseImages |
|||
Write-Step 'base images ready' |
|||
|
|||
$machine = Get-LabVM -ComputerName $Name |
|||
$address = ($machine.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString |
|||
$null = Add-HostEntry -HostName $machine.Name -IpAddress $address -Section $LabName |
|||
$null = Add-HostEntry -HostName $machine.FQDN -IpAddress $address -Section $LabName |
|||
New-LabVM -Name $Name |
|||
Set-LabDefinition -Machines (Get-Lab).Machines |
|||
Export-LabDefinition -Force -ExportDefaultUnattendedXml -Silent |
|||
Write-Step 'virtual machine created and definition exported' |
|||
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue |
|||
$lockPath = $null |
|||
|
|||
Start-LabVM -ComputerName $Name -ProgressIndicator 30 -TimeoutInMinutes $StartTimeoutMinutes -Wait |
|||
Write-Step 'machine started and reachable with the lab credentials' |
|||
|
|||
$userName = (Get-Lab).DefaultInstallationCredential.UserName |
|||
Invoke-LabCommand -ActivityName 'Setting PasswordNeverExpires for local deployment accounts' -ComputerName $Name -NoDisplay -Variable (Get-Variable -Name userName) -ScriptBlock { |
|||
Get-CimInstance -Query "Select * from Win32_UserAccount where name = '$userName' and localaccount='true'" | Set-CimInstance -Property @{ PasswordExpires = $false } |
|||
} |
|||
|
|||
$evidence = Invoke-LabCommand -ComputerName $Name -ActivityName 'Readiness of the new member' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $domainName -ScriptBlock { |
|||
param ($Domain) |
|||
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
[pscustomobject]@{ |
|||
Build = '{0}.{1}' -f $current.CurrentBuildNumber, $current.UBR |
|||
Product = $current.ProductName |
|||
Domain = (Get-CimInstance -ClassName Win32_ComputerSystem).Domain |
|||
SecureChannel = [bool] (Test-ComputerSecureChannel) |
|||
Verify = (@(& nltest.exe "/sc_verify:$Domain" 2>&1) -join ' | ') |
|||
} |
|||
} |
|||
Write-Step ('new member: build {0} ({1}); domain {2}; secure channel {3}; nltest: {4}' -f $evidence.Build, $evidence.Product, $evidence.Domain, $evidence.SecureChannel, $evidence.Verify) |
|||
if (-not $evidence.SecureChannel) { throw "The secure channel of $Name is broken." } |
|||
|
|||
Write-Step 'add-os-matrix-machine-DONE' |
|||
exit 0 |
|||
} |
|||
catch { |
|||
Write-Step ('add-os-matrix-machine-FAILED: {0}' -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
exit 1 |
|||
} |
|||
finally { |
|||
if ($lockPath) { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string[]] $Member = @('OSDC1', 'OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E'), |
|||
[string[]] $LocalCredentialMember = @(), |
|||
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', |
|||
[string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi' |
|||
) |
|||
|
|||
# Finishes machines of the matrix lab after a deployment that stopped in AutomatedLab's file server step (a job that never completed |
|||
# although its remote side was idle) or after Add-OsMatrixMachine.ps1: detaches the installation ISO from the file servers, installs |
|||
# PowerShell 7 from the MSI of the host, and copies Pester 5.7.1 into the module folders of both editions. It uses no AutomatedLab job |
|||
# (no -AsJob), only synchronous remoting. A member in -LocalCredentialMember is reached with the local installation account through a |
|||
# session, for a machine whose secure channel to the domain controller fails. Windows PowerShell 5.1 on the host; run it elevated. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$LocalCredentialMember = @($LocalCredentialMember | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$installBlock = { |
|||
param ($Msi) |
|||
$msiPath = Join-Path -Path 'C:\Windows\Temp' -ChildPath $Msi |
|||
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f $msiPath), '/quiet', '/norestart', 'ADD_PATH=1', '/l*v', 'C:\Windows\Temp\pwsh-install.log') -Wait -PassThru |
|||
$pwsh = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' |
|||
[pscustomobject]@{ ExitCode = $process.ExitCode; Pwsh = $(if (Test-Path -LiteralPath $pwsh) { (Get-Item -LiteralPath $pwsh).VersionInfo.ProductVersion } else { 'missing' }) } |
|||
} |
|||
$createBlock = { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } |
|||
$checkBlock = { param ($Path) '{0}: {1}' -f $env:COMPUTERNAME, (Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath '5.7.1\Pester.psd1')) } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START complete-os-matrix-lab lab=$LabName members=$($Member -join ',') localCredential=$($LocalCredentialMember -join ',')" | Set-Content -LiteralPath $LogPath |
|||
try { |
|||
foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } } |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$fileServers = @($Member | Where-Object -FilterScript { $_ -like 'OSFile*' -and $_ -notin $LocalCredentialMember }) |
|||
if ($fileServers) { |
|||
Dismount-LabIsoImage -ComputerName $fileServers -SupressOutput |
|||
Write-Step "installation ISO detached from $($fileServers -join ',')" |
|||
} |
|||
|
|||
$msiName = Split-Path -Path $PowerShell7Msi -Leaf |
|||
$domainMembers = @($Member | Where-Object -FilterScript { $_ -notin $LocalCredentialMember }) |
|||
foreach ($name in $Member) { |
|||
if ($name -in $LocalCredentialMember) { |
|||
$session = New-LabPSSession -ComputerName $name -UseLocalCredential |
|||
try { |
|||
Copy-Item -LiteralPath $PowerShell7Msi -Destination 'C:\Windows\Temp\' -ToSession $session -Force |
|||
$outcome = Invoke-Command -Session $session -ScriptBlock $installBlock -ArgumentList $msiName |
|||
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { |
|||
$destination = Join-Path -Path $modulesRoot -ChildPath 'Pester' |
|||
Invoke-Command -Session $session -ScriptBlock $createBlock -ArgumentList $destination |
|||
Copy-Item -LiteralPath $PesterModulePath -Destination $destination -ToSession $session -Recurse -Force |
|||
Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (Invoke-Command -Session $session -ScriptBlock $checkBlock -ArgumentList $destination)) |
|||
} |
|||
} |
|||
finally { |
|||
Remove-PSSession -Session $session -ErrorAction SilentlyContinue |
|||
} |
|||
} |
|||
else { |
|||
Copy-LabFileItem -Path $PowerShell7Msi -ComputerName $name -DestinationFolderPath 'C:\Windows\Temp' |
|||
$outcome = Invoke-LabCommand -ComputerName $name -ActivityName "Install PowerShell 7 on $name" -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $msiName -ScriptBlock $installBlock |
|||
} |
|||
|
|||
Write-Step ("PowerShell 7 on {0}: msiexec exit code {1}; pwsh {2}" -f $name, $outcome.ExitCode, $outcome.Pwsh) |
|||
if ($outcome.ExitCode -notin 0, 3010 -or $outcome.Pwsh -eq 'missing') { throw "PowerShell 7 isn't installed on $name (exit code $($outcome.ExitCode))." } |
|||
} |
|||
|
|||
if ($domainMembers) { |
|||
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { |
|||
$destination = Join-Path -Path $modulesRoot -ChildPath 'Pester' |
|||
Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Create the Pester module directory' -NoDisplay -ErrorAction Stop -ArgumentList $destination -ScriptBlock $createBlock |
|||
Copy-LabFileItem -Path $PesterModulePath -ComputerName $domainMembers -DestinationFolderPath $destination -Recurse |
|||
$found = Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Check Pester' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $destination -ScriptBlock $checkBlock |
|||
Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (@($found) -join '; ')) |
|||
if (@($found | Where-Object -FilterScript { $_ -notmatch ': True$' }).Count -gt 0) { throw "Pester 5.7.1 isn't in $destination on every member." } |
|||
} |
|||
} |
|||
|
|||
Write-Step 'complete-os-matrix-lab-DONE' |
|||
exit 0 |
|||
} |
|||
catch { |
|||
Write-Step ("complete-os-matrix-lab-FAILED: {0}" -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
exit 1 |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $DomainName = 'osmatrix.net', |
|||
[string] $VmPath = 'V:\AutomatedLab-VMs', |
|||
[string] $AddressSpace = '192.168.12.0/24', |
|||
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', |
|||
[string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi' |
|||
) |
|||
|
|||
# Deploys an isolated AutomatedLab lab for the NTFSSecurity operating-system matrix (Decision 24): one domain controller, three file |
|||
# servers (Server 2019, 2022, 2025), and a Windows 11 client, in a domain and on a switch of their own. It touches none of the |
|||
# existing labs, machines, switches, or domains, never calls Remove-Lab, and refuses to run when the lab or a machine name exists. |
|||
# The installation password is generated here, kept in memory, and stored only where AutomatedLab stores it for every lab. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START deploy-os-matrix-lab lab=$LabName" | Set-Content -LiteralPath $LogPath |
|||
try { |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } } |
|||
|
|||
$machines = @( |
|||
@{ Name = 'OSDC1'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('RootDC'); Memory = 4GB; Address = '192.168.12.10' } |
|||
@{ Name = 'OSFile25'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.25' } |
|||
@{ Name = 'OSFile22'; Os = 'Windows Server 2022 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.22' } |
|||
@{ Name = 'OSFile19'; Os = 'Windows Server 2019 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.19' } |
|||
@{ Name = 'OSWin11'; Os = 'Windows 11 Pro'; Roles = @(); Memory = 4GB; Address = '192.168.12.11' } |
|||
) |
|||
|
|||
# Collision checks from AutomatedLab metadata and from Hyper-V; the existing labs are only read. |
|||
$existingNames = New-Object System.Collections.Generic.List[string] |
|||
$labs = @(Get-Lab -List) |
|||
if ($labs -contains $LabName) { throw "The lab '$LabName' exists already. Refusing to redefine it." } |
|||
foreach ($existing in $labs) { |
|||
Import-Lab -Name $existing -NoValidation -NoDisplay -ErrorAction Stop |
|||
foreach ($vm in Get-LabVM -IncludeLinux) { $existingNames.Add($vm.Name) } |
|||
} |
|||
foreach ($vm in Get-VM) { $existingNames.Add($vm.Name) } |
|||
$collisions = @($machines.Name | Where-Object { $_ -in $existingNames }) |
|||
if ($collisions) { throw "Machine name collision: $($collisions -join ', ')" } |
|||
if (Get-VMSwitch -Name $LabName -ErrorAction SilentlyContinue) { throw "A virtual switch named '$LabName' exists already." } |
|||
$usedAddresses = @(Get-NetIPAddress -AddressFamily IPv4 | ForEach-Object { $_.IPAddress }) |
|||
if ($usedAddresses | Where-Object { $_ -like '192.168.12.*' }) { throw 'The address space 192.168.12.0/24 is in use on the host.' } |
|||
Write-Step ('preflight ok; existing labs: {0}; existing machine names: {1}' -f ($labs -join ', '), $existingNames.Count) |
|||
|
|||
$characters = ([char[]](48..57) + [char[]](65..90) + [char[]](97..122) + '!', '#', '%', '+', '-', '=') |
|||
# A cryptographic generator, without the bias of a remainder: this is the installation and domain administrator password of the lab. |
|||
$generator = [Security.Cryptography.RandomNumberGenerator]::Create() |
|||
$limit = 256 - (256 % $characters.Count) |
|||
$buffer = New-Object -TypeName 'byte[]' -ArgumentList 1 |
|||
$chosen = New-Object -TypeName 'System.Text.StringBuilder' |
|||
while ($chosen.Length -lt 24) { |
|||
$generator.GetBytes($buffer) |
|||
if ($buffer[0] -lt $limit) { $null = $chosen.Append($characters[$buffer[0] % $characters.Count]) } |
|||
} |
|||
|
|||
$password = 'Aa1!' + $chosen.ToString() |
|||
|
|||
New-LabDefinition -Name $LabName -DefaultVirtualizationEngine HyperV -VmPath $VmPath |
|||
Add-LabVirtualNetworkDefinition -Name $LabName -AddressSpace $AddressSpace |
|||
Add-LabDomainDefinition -Name $DomainName -AdminUser 'install' -AdminPassword $password |
|||
Set-LabInstallationCredential -Username 'install' -Password $password |
|||
foreach ($definition in $machines) { |
|||
$parameters = @{ |
|||
Name = $definition.Name; DomainName = $DomainName; OperatingSystem = $definition.Os; Memory = $definition.Memory |
|||
Processors = 2; Network = $LabName; IpAddress = $definition.Address |
|||
} |
|||
if ($definition.Roles.Count -gt 0) { $parameters.Roles = $definition.Roles } |
|||
if ($definition.Os -like 'Windows 11*') { |
|||
$parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' } |
|||
} |
|||
Add-LabMachineDefinition @parameters |
|||
} |
|||
Write-Step 'lab defined; installing network switches and base images' |
|||
|
|||
Install-Lab -NetworkSwitches -BaseImages |
|||
Write-Step 'network switches and base images done' |
|||
Install-Lab |
|||
Write-Step 'machines, domain, and roles done' |
|||
|
|||
$labMachines = Get-LabVM |
|||
Install-LabSoftwarePackage -ComputerName $labMachines -Path $PowerShell7Msi -CommandLine '/quiet /norestart ADD_PATH=1' -Timeout 30 |
|||
Write-Step 'PowerShell 7 installed' |
|||
foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { |
|||
$destination = Join-Path $modulesRoot 'Pester' |
|||
Invoke-LabCommand -ComputerName $labMachines -ActivityName 'Create the Pester module directory' -ScriptBlock { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } -ArgumentList $destination -NoDisplay |
|||
Copy-LabFileItem -Path $PesterModulePath -ComputerName $labMachines -DestinationFolderPath $destination -Recurse |
|||
} |
|||
Write-Step 'Pester 5.7.1 copied' |
|||
Show-LabDeploymentSummary -Summary |
|||
Write-Step "deploy-os-matrix-lab-DONE" |
|||
exit 0 |
|||
} |
|||
catch { |
|||
Write-Step ("deploy-os-matrix-lab-FAILED: {0}" -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
exit 1 |
|||
} |
|||
@ -0,0 +1,131 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $MatrixRoot, |
|||
[Parameter(Mandatory)] [string[]] $Label, |
|||
[Parameter(Mandatory)] [string] $OutputPath |
|||
) |
|||
|
|||
# The timeline of the cells of the operating-system matrix (Decision 24): for each cell and edition in which the Admin role ran, in the order in which the |
|||
# cells ran, the module under test, the account of case 3 (its name and its relative ID, which tells two accounts of one name apart within a domain), |
|||
# whether the previous cell had the same name and the same account, when the previous fixture was removed, when the accounts were created, when the |
|||
# Admin role started, the minutes between them, and the three effective-access tests of case 3 in the Admin role (result, milliseconds, and the rights |
|||
# that a failing test received). A failing effective-access test of the Admin role is easy to blame on the module or on the environment; this table |
|||
# puts it beside the module, the position of the cell in the sequence, and the age of the accounts. Pass every label of a series, also a run that |
|||
# stopped before its tests (it writes no row, but it created and removed the accounts, which the next cell reports as the previous removal). The |
|||
# times come from the logs of Run-MatrixSequence.ps1 and of the controller (UTC). It reads files only; Windows PowerShell 5.1 or PowerShell 7. |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$Label = @($Label | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
function Get-LogTime { |
|||
param ([string[]] $Lines, [string] $Pattern) |
|||
$line = $Lines | Where-Object -FilterScript { $_ -match $Pattern } | Select-Object -First 1 |
|||
if ($line -and $line -match '^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)Z?\]') { |
|||
[DateTime]::ParseExact($Matches[1], 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) |
|||
} |
|||
} |
|||
|
|||
$cells = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
foreach ($name in $Label) { |
|||
$sequenceLog = Join-Path -Path $MatrixRoot -ChildPath ('{0}-sequence.log' -f $name) |
|||
if (-not (Test-Path -LiteralPath $sequenceLog)) { continue } |
|||
$candidate = if ((Get-Content -LiteralPath $sequenceLog -TotalCount 1) -match 'candidate-(\w+)') { $Matches[1] } else { '' } |
|||
foreach ($folder in (Get-ChildItem -LiteralPath $MatrixRoot -Directory -Filter ('{0}-*' -f $name))) { |
|||
$runLog = Join-Path -Path $folder.FullName -ChildPath 'run.log' |
|||
if (-not (Test-Path -LiteralPath $runLog)) { continue } |
|||
$run = @(Get-Content -LiteralPath $runLog) |
|||
$removeLog = Join-Path -Path $folder.FullName -ChildPath 'cleanup-2-remove.log' |
|||
$removed = if (Test-Path -LiteralPath $removeLog) { Get-LogTime -Lines @(Get-Content -LiteralPath $removeLog) -Pattern 'Removed the live tests' } |
|||
$configuration = Get-ChildItem -LiteralPath (Join-Path -Path $folder.FullName -ChildPath 'Results') -Recurse -Filter 'local-*.json' -ErrorAction SilentlyContinue | |
|||
Where-Object -FilterScript { $_.Name -match '-\d{14}\.json$' } | Select-Object -First 1 |
|||
$subjectName = '' |
|||
$subjectRid = '' |
|||
if ($configuration) { |
|||
$subject = (Get-Content -LiteralPath $configuration.FullName -Raw | ConvertFrom-Json).Accounts.Subject |
|||
$subjectName = $subject.Name |
|||
$subjectRid = ($subject.Sid -split '-')[-1] |
|||
} |
|||
else { |
|||
# A cell that stopped before its tests has no result file, but it created and removed the accounts, so the snapshot of its fixture names them. |
|||
$snapshotLog = Join-Path -Path $folder.FullName -ChildPath 'cleanup-1-snapshot.log' |
|||
$snapshot = if (Test-Path -LiteralPath $snapshotLog) { Get-Content -LiteralPath $snapshotLog -Raw } |
|||
if ($snapshot -match '(?m)^(\w+)\.\S+\s+OU NTFSSecurityLive.*\b(NtfsLiveSubject\w*)=S-[\d-]+-(\d+)') { |
|||
$subjectName = '{0}\{1}' -f $Matches[1], $Matches[2] |
|||
$subjectRid = $Matches[3] |
|||
} |
|||
} |
|||
|
|||
$cells.Add([pscustomobject]@{ |
|||
Run = $name |
|||
Candidate = $candidate |
|||
FileServer = $folder.Name.Substring($name.Length + 1) |
|||
Folder = $folder.FullName |
|||
Lines = $run |
|||
Subject = $subjectName |
|||
SubjectRid = $subjectRid |
|||
Started = Get-LogTime -Lines $run -Pattern 'START live tests' |
|||
Created = Get-LogTime -Lines $run -Pattern 'Preparing the accounts' |
|||
Removed = $removed |
|||
}) |
|||
} |
|||
} |
|||
|
|||
$rows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
$previous = $null |
|||
foreach ($cell in ($cells | Sort-Object -Property Started)) { |
|||
foreach ($edition in 'Desktop', 'Core') { |
|||
$adminStart = Get-LogTime -Lines $cell.Lines -Pattern "local-$edition-\d+: role Admin$" |
|||
if (-not $adminStart) { continue } |
|||
$tests = @{ T1 = ''; T2 = ''; T3 = '' } |
|||
$failures = 0 |
|||
$adminLog = Get-ChildItem -LiteralPath (Join-Path -Path $cell.Folder -ChildPath 'Results') -Recurse -Filter "local-$edition-*-Admin.log" | Select-Object -First 1 |
|||
if ($adminLog) { |
|||
$text = @(Get-Content -LiteralPath $adminLog.FullName) |
|||
$start = ($text | Select-String -Pattern 'Describing Get-NTFSEffectiveAccess for a domain account on a share folder' | Select-Object -First 1).LineNumber |
|||
$seen = 0 |
|||
for ($index = $start; $start -and $index -lt $text.Count -and $seen -lt 3; $index++) { |
|||
if ($text[$index] -notmatch '^\s+\[([+-])\] (.+?) (\d+(?:\.\d+)?m?s) \(') { continue } |
|||
$outcome = $Matches[1]; $title = $Matches[2]; $duration = $Matches[3] |
|||
$received = '' |
|||
if ($outcome -eq '-') { |
|||
$failures++ |
|||
for ($next = $index + 1; $next -lt [Math]::Min($index + 12, $text.Count); $next++) { |
|||
if ($text[$next] -match "But was:\s+'(0x\w+)'") { $received = ' ' + $Matches[1]; break } |
|||
if ($text[$next] -match 'Expected \$null or empty') { $received = ' errors'; break } |
|||
} |
|||
} |
|||
|
|||
$key = if ($title -match 'with -ServerName, without') { 'T1' } elseif ($title -match 'without -ServerName') { 'T2' } else { 'T3' } |
|||
$tests[$key] = '{0} {1}{2}' -f $(if ($outcome -eq '+') { 'pass' } else { 'FAIL' }), $duration, $received |
|||
$seen++ |
|||
} |
|||
} |
|||
|
|||
$hasPrevious = $null -ne $previous -and $null -ne $previous.Removed |
|||
$sameName = $null -ne $previous -and $cell.Subject -and $previous.Subject -eq $cell.Subject |
|||
$rows.Add([pscustomobject][ordered]@{ |
|||
Run = $cell.Run |
|||
Candidate = $cell.Candidate |
|||
FileServer = $cell.FileServer |
|||
Edition = $edition |
|||
Subject = $cell.Subject |
|||
SubjectRid = $cell.SubjectRid |
|||
SameNameAsPreviousCell = [bool] $sameName |
|||
SameAccountAsPreviousCell = [bool] ($sameName -and $previous.SubjectRid -eq $cell.SubjectRid) |
|||
PreviousRemoval = $(if ($hasPrevious) { '{0:yyyy-MM-dd HH:mm:ss}' -f $previous.Removed }) |
|||
AccountsCreated = '{0:yyyy-MM-dd HH:mm:ss}' -f $cell.Created |
|||
AdminRoleStarted = '{0:yyyy-MM-dd HH:mm:ss}' -f $adminStart |
|||
MinutesRemovalToCreation = $(if ($hasPrevious) { '{0:N1}' -f ($cell.Created - $previous.Removed).TotalMinutes }) |
|||
MinutesCreationToAdmin = '{0:N1}' -f ($adminStart - $cell.Created).TotalMinutes |
|||
MinutesRemovalToAdmin = $(if ($hasPrevious) { '{0:N1}' -f ($adminStart - $previous.Removed).TotalMinutes }) |
|||
T1ServerNameFileServer = $tests.T1 |
|||
T2DefaultServerName = $tests.T2 |
|||
T3UnreachableServerName = $tests.T3 |
|||
EffectiveAccessFailures = $failures |
|||
}) |
|||
} |
|||
|
|||
$previous = [pscustomobject]@{ Removed = $cell.Removed; Subject = $cell.Subject; SubjectRid = $cell.SubjectRid } |
|||
} |
|||
|
|||
$rows | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding ASCII |
|||
'{0} rows for {1} cells written to {2}' -f $rows.Count, $cells.Count, $OutputPath |
|||
@ -0,0 +1,101 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $OutputPrefix, |
|||
[string] $MatrixRoot, |
|||
[string] $Label, |
|||
[string[]] $LocalSuiteFolder = @(), |
|||
[string] $ReferenceMachine = 'LOCAL' |
|||
) |
|||
|
|||
# Turns the raw results of the operating-system matrix (Decision 24) into the tables of the acceptance record, in Windows PowerShell 5.1. |
|||
# -MatrixRoot and -Label name the sequences of Run-MatrixSequence.ps1 (folders <Label>-<file server>): per cell, edition, and role the |
|||
# counts, the skipped tests, and the operating systems of the readiness log. -LocalSuiteFolder lists the result folders of |
|||
# Run-MatrixLocalSuite.ps1 (folders <label>-<machine> with one JSON file per run): per machine, mode, and edition the counts, and the |
|||
# difference of the skipped tests to the reference machine (a skipped test that only one side skips is a difference). Every input file is |
|||
# listed with its SHA-256 in <OutputPrefix>-hashes.csv. Nothing is changed in the lab. |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$LocalSuiteFolder = @($LocalSuiteFolder | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$hashRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
function Add-Hash { param ([string] $Path) $hashRows.Add([pscustomobject]@{ File = $Path; Sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash; Bytes = (Get-Item -LiteralPath $Path).Length }) } |
|||
function Get-Multiset { param ([string[]] $Name) $set = @{}; foreach ($item in @($Name | Where-Object -FilterScript { $_ })) { $set[$item] = 1 + [int] $set[$item] }; $set } |
|||
function Get-Excess { |
|||
param ([hashtable] $Left, [hashtable] $Right) |
|||
foreach ($key in ($Left.Keys | Sort-Object)) { $extra = [int] $Left[$key] - [int] $Right[$key]; if ($extra -gt 0) { '{0} (x{1})' -f $key, $extra } } |
|||
} |
|||
|
|||
if ($MatrixRoot) { |
|||
$cellRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
$skipRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
foreach ($folder in (Get-ChildItem -LiteralPath $MatrixRoot -Directory -Filter "$Label-*" | Sort-Object -Property Name)) { |
|||
$server = $folder.Name.Substring($Label.Length + 1) |
|||
$readiness = Join-Path -Path $folder.FullName -ChildPath 'readiness.log' |
|||
$operatingSystems = @{} |
|||
foreach ($match in (Select-String -LiteralPath $readiness -Pattern '^(\S+)\s+wsman=ok .* os=(.+?) type=(\d)')) { |
|||
$groups = $match.Matches[0].Groups |
|||
$operatingSystems[$groups[1].Value] = '{0} ({1})' -f ($groups[2].Value -replace '^Microsoft ', ''), $(if ($groups[3].Value -eq '1') { 'client' } else { 'server' }) |
|||
} |
|||
|
|||
$clientName = @($operatingSystems.Keys | Where-Object -FilterScript { $operatingSystems[$_] -like '*client*' })[0] |
|||
$counts = Import-Csv -LiteralPath (Join-Path -Path $folder.FullName -ChildPath "$Label-$server-counts.csv") |
|||
foreach ($row in $counts) { |
|||
$cellRows.Add([pscustomobject]@{ |
|||
FileServer = $server; FileServerOs = $operatingSystems[$server]; Client = $clientName; ClientOs = $operatingSystems[$clientName] |
|||
Edition = $row.Edition; Role = $row.Role; Account = $row.Account; ExitCode = $row.ExitCode; Passed = $row.Passed; Failed = $row.Failed; Skipped = $row.Skipped |
|||
}) |
|||
} |
|||
|
|||
foreach ($test in (Import-Csv -LiteralPath (Join-Path -Path $folder.FullName -ChildPath "$Label-$server-tests.csv") | Where-Object -FilterScript { $_.Result -ne 'Passed' })) { |
|||
$skipRows.Add([pscustomobject]@{ FileServer = $server; Edition = $test.Edition; Role = $test.Role; Result = $test.Result; Test = $test.Test; Message = $test.Message }) |
|||
} |
|||
|
|||
foreach ($name in "$Label-$server-counts.csv", "$Label-$server-tests.csv", "$Label-$server-failures.csv", 'readiness.log', 'validation.log', 'run.log', 'fixture-sids.json', 'cleanup-1-snapshot.log', 'cleanup-2-remove.log', 'cleanup-3-verify.log') { |
|||
$path = Join-Path -Path $folder.FullName -ChildPath $name |
|||
if (Test-Path -LiteralPath $path) { Add-Hash -Path $path } |
|||
} |
|||
|
|||
foreach ($summary in (Get-ChildItem -LiteralPath (Join-Path -Path $folder.FullName -ChildPath 'Results') -Filter 'Summary.json' -Recurse -ErrorAction SilentlyContinue)) { Add-Hash -Path $summary.FullName } |
|||
} |
|||
|
|||
$cellRows | Export-Csv -LiteralPath "$OutputPrefix-cells.csv" -NoTypeInformation |
|||
$skipRows | Export-Csv -LiteralPath "$OutputPrefix-cells-skipped.csv" -NoTypeInformation |
|||
'{0} role rows and {1} tests that did not pass, in {2} cell(s)' -f $cellRows.Count, $skipRows.Count, @($cellRows | Select-Object -ExpandProperty FileServer -Unique).Count |
|||
} |
|||
|
|||
if ($LocalSuiteFolder) { |
|||
$runs = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
foreach ($folder in $LocalSuiteFolder) { |
|||
$machine = ((Split-Path -Path $folder -Leaf) -split '-', 2)[1] |
|||
foreach ($json in (Get-ChildItem -LiteralPath $folder -Filter '*.json' | Sort-Object -Property Name)) { |
|||
$summary = Get-Content -LiteralPath $json.FullName -Raw | ConvertFrom-Json |
|||
Add-Hash -Path $json.FullName |
|||
$log = [IO.Path]::ChangeExtension($json.FullName, '.log') |
|||
if (Test-Path -LiteralPath $log) { Add-Hash -Path $log } |
|||
$runs.Add([pscustomobject]@{ |
|||
Machine = $machine; Os = $summary.Os; Mode = $(if ($summary.Elevated) { 'Elevated' } else { 'Basic' }); Edition = $summary.Edition; PowerShell = $summary.PowerShell |
|||
Result = $summary.Result; Passed = $summary.Passed; Failed = $summary.Failed; Skipped = $summary.Skipped; Total = $summary.Total; Seconds = $summary.Seconds |
|||
SkippedTests = @($summary.SkippedTests | Where-Object -FilterScript { $_ }); FailedTests = @($summary.FailedTests | Where-Object -FilterScript { $_ }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
$runs | Select-Object -Property Machine, Os, Mode, Edition, PowerShell, Result, Passed, Failed, Skipped, Total, Seconds | Sort-Object -Property Machine, Mode, Edition | |
|||
Export-Csv -LiteralPath "$OutputPrefix-localsuite.csv" -NoTypeInformation |
|||
$differences = foreach ($run in ($runs | Where-Object -FilterScript { $_.Machine -ne $ReferenceMachine })) { |
|||
$reference = $runs | Where-Object -FilterScript { $_.Machine -eq $ReferenceMachine -and $_.Mode -eq $run.Mode -and $_.Edition -eq $run.Edition } | Select-Object -First 1 |
|||
if (-not $reference) { continue } |
|||
$mine = Get-Multiset -Name $run.SkippedTests |
|||
$theirs = Get-Multiset -Name $reference.SkippedTests |
|||
[pscustomobject]@{ |
|||
Machine = $run.Machine; Mode = $run.Mode; Edition = $run.Edition; Skipped = $run.Skipped; ReferenceSkipped = $reference.Skipped |
|||
OnlyOnMachine = (@(Get-Excess -Left $mine -Right $theirs) -join ' | '); OnlyOnReference = (@(Get-Excess -Left $theirs -Right $mine) -join ' | ') |
|||
Failed = $run.Failed; FailedTests = ($run.FailedTests -join ' | ') |
|||
} |
|||
} |
|||
|
|||
$differences | Sort-Object -Property Machine, Mode, Edition | Export-Csv -LiteralPath "$OutputPrefix-localsuite-skipdiff.csv" -NoTypeInformation |
|||
'{0} local-suite run(s) of {1} machine(s)' -f $runs.Count, @($runs | Select-Object -ExpandProperty Machine -Unique).Count |
|||
} |
|||
|
|||
$hashRows | Export-Csv -LiteralPath "$OutputPrefix-hashes.csv" -NoTypeInformation |
|||
'{0} input file(s) hashed' -f $hashRows.Count |
|||
@ -0,0 +1,93 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $ModulePath, |
|||
[Parameter(Mandatory)] [string] $OutFile, |
|||
[Parameter(Mandatory)] [string] $Variant, |
|||
[string] $OtherServer = '' |
|||
) |
|||
|
|||
# Runs inside a machine of the operating-system matrix, in Windows PowerShell 5.1 under the token that Probe-EffectiveAccess.ps1 chose for |
|||
# the variant, and asks Get-NTFSEffectiveAccess the same question in several ways: for the account of the token, Everyone, the local |
|||
# Administrator, and the domain Administrator and Domain Users on a computer in a domain, each for the default server name, localhost, an |
|||
# empty name, the names of this computer, and the computers of -OtherServer (a comma-separated list). For every call it writes the result, |
|||
# the number of warnings, and the native error with the failing method, so that the failing call of the authorization manager shows. It |
|||
# changes nothing but a folder below $env:TEMP. |
|||
$ErrorActionPreference = 'Continue' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
$stamp = '[{0:HH:mm:ss}]' |
|||
function Write-Probe { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $OutFile } |
|||
|
|||
$null = New-Item -ItemType Directory -Path (Split-Path -Path $OutFile -Parent) -Force |
|||
Set-Content -LiteralPath $OutFile -Value '' |
|||
try { |
|||
Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop |
|||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent() |
|||
$principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList $identity |
|||
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
Write-Probe ('START variant={0} user={1} sid={2} administrator={3} os={4} {5}.{6} dll={7}' -f $Variant, $identity.Name, $identity.User.Value, |
|||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator), $current.ProductName, $current.CurrentBuildNumber, $current.UBR, |
|||
(Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash.Substring(0, 12)) |
|||
$groups = @(& whoami.exe /groups /fo csv | ConvertFrom-Csv) |
|||
Write-Probe ('groups={0}; deny only: {1}' -f $groups.Count, ((@($groups | Where-Object -FilterScript { $_.Attributes -match 'deny' } | ForEach-Object -Process { $_.'Group Name' })) -join ', ')) |
|||
Write-Probe ('integrity: {0}' -f ((@($groups | Where-Object -FilterScript { $_.'Group Name' -like 'Mandatory Label*' } | ForEach-Object -Process { $_.'Group Name' })) -join ', ')) |
|||
$privileges = @(& whoami.exe /priv /fo csv | ConvertFrom-Csv) |
|||
Write-Probe ('privileges present={0} enabled: {1}' -f $privileges.Count, ((@($privileges | Where-Object -FilterScript { $_.State -eq 'Enabled' } | ForEach-Object -Process { $_.'Privilege Name' })) -join ', ')) |
|||
|
|||
$folder = Join-Path -Path $env:TEMP -ChildPath ('probe-{0}' -f [guid]::NewGuid().ToString('N')) |
|||
$null = New-Item -ItemType Directory -Path $folder |
|||
$fqdn = try { [Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName } catch { $env:COMPUTERNAME } |
|||
function Resolve-Sid { |
|||
param ([string] $Name) |
|||
try { (New-Object -TypeName 'Security.Principal.NTAccount' -ArgumentList $Name).Translate([Security.Principal.SecurityIdentifier]).Value } catch { '' } |
|||
} |
|||
|
|||
$computer = Get-CimInstance -ClassName Win32_ComputerSystem |
|||
Write-Probe ('computer {0} domain joined={1} domain={2}' -f $env:COMPUTERNAME, $computer.PartOfDomain, $computer.Domain) |
|||
$accounts = [ordered]@{ 'self' = ''; 'Everyone' = 'S-1-1-0'; 'local Administrator' = (Resolve-Sid -Name ('{0}\Administrator' -f $env:COMPUTERNAME)) } |
|||
if ($computer.PartOfDomain) { |
|||
$accounts['domain Administrator'] = Resolve-Sid -Name ('{0}\Administrator' -f $computer.Domain) |
|||
$accounts['Domain Users'] = Resolve-Sid -Name ('{0}\Domain Users' -f $computer.Domain) |
|||
} |
|||
|
|||
$servers = [ordered]@{ 'no -ServerName' = $null; 'localhost' = 'localhost'; 'empty name' = ''; 'computer name' = $env:COMPUTERNAME; 'fqdn' = $fqdn } |
|||
foreach ($name in @($OtherServer -split ',' | Where-Object -FilterScript { $_ })) { $servers["other computer $name"] = $name } |
|||
$cases = foreach ($accountName in $accounts.Keys) { |
|||
if ($accountName -ne 'self' -and -not $accounts[$accountName]) { continue } |
|||
foreach ($serverName in $servers.Keys) { |
|||
$arguments = @{} |
|||
if ($accounts[$accountName]) { $arguments.Account = $accounts[$accountName] } |
|||
if ($null -ne $servers[$serverName]) { $arguments.ServerName = $servers[$serverName] } |
|||
@{ Name = ('{0}, {1}' -f $accountName, $serverName); Arguments = $arguments } |
|||
} |
|||
} |
|||
|
|||
foreach ($case in $cases) { |
|||
$arguments = $case.Arguments |
|||
$errorList = $null |
|||
$warningList = $null |
|||
try { |
|||
$result = @(Get-NTFSEffectiveAccess -Path $folder @arguments -ErrorVariable errorList -WarningVariable warningList -ErrorAction SilentlyContinue -WarningAction SilentlyContinue) |
|||
} |
|||
catch { |
|||
$result = @() |
|||
$errorList = @($_) |
|||
} |
|||
|
|||
$access = if ($result.Count -gt 0) { ('{0}' -f $result[0].AccessRights) } else { 'none' } |
|||
$warnings = @($warningList | ForEach-Object -Process { ('{0}' -f $_.Message) -replace '\s+', ' ' } | ForEach-Object -Process { if ($_.Length -gt 60) { $_.Substring(0, 60) } else { $_ } }) |
|||
Write-Probe ('CASE {0}: results={1} access={2} errors={3} warnings={4}' -f $case.Name, $result.Count, $access, @($errorList).Count, $warnings.Count) |
|||
foreach ($record in @($errorList)) { |
|||
$inner = $record.Exception |
|||
while ($inner.InnerException) { $inner = $inner.InnerException } |
|||
$native = if ($inner -is [ComponentModel.Win32Exception]) { $inner.NativeErrorCode } else { '' } |
|||
$frames = (('{0}' -f $inner.StackTrace) -split "`r?`n" | Select-Object -First 1 | ForEach-Object -Process { $_.Trim() -replace '^at ', '' -replace '\(.*$', '' }) -join ' <- ' |
|||
Write-Probe (' ERROR id={0} type={1} native={2} message={3} frames={4}' -f $record.FullyQualifiedErrorId, $inner.GetType().Name, $native, $inner.Message, $frames) |
|||
} |
|||
} |
|||
|
|||
Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue |
|||
Write-Probe 'DONE' |
|||
} |
|||
catch { |
|||
Write-Probe ('FAILED: {0}' -f $_) |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $Root, |
|||
[Parameter(Mandatory)] [string] $OutDir, |
|||
[string] $PesterVersion = '5.7.1', |
|||
[string] $PesterModulePath |
|||
) |
|||
|
|||
# Runs the Pester files of <Root>\Tests in this process, Windows PowerShell 5.1 or PowerShell 7, against the module in |
|||
# <Root>\NTFSSecurity\bin\Release, like .github\scripts\Invoke-Tests.ps1 does for the repository. It writes the log, the NUnit result, a JSON |
|||
# summary, and an exit code file to <OutDir>. Run it in a new process for every edition. The tests keep to their own sandbox folders |
|||
# below $env:TEMP. |
|||
$ErrorActionPreference = 'Stop' |
|||
$null = New-Item -ItemType Directory -Path $OutDir -Force |
|||
$log = Join-Path -Path $OutDir -ChildPath "$Label.log" |
|||
$script:exitCode = 1 |
|||
if ($PSVersionTable.PSEdition -eq 'Desktop') { |
|||
# A Windows PowerShell process started by PowerShell 7 would inherit the module path of PowerShell 7. |
|||
$env:PSModulePath = @( |
|||
(Join-Path -Path ([Environment]::GetFolderPath('MyDocuments')) -ChildPath 'WindowsPowerShell\Modules'), |
|||
(Join-Path -Path $env:ProgramFiles -ChildPath 'WindowsPowerShell\Modules'), |
|||
(Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\Modules') |
|||
) -join ';' |
|||
} |
|||
|
|||
& { |
|||
$principal = New-Object -TypeName 'Security.Principal.WindowsPrincipal' -ArgumentList ([Security.Principal.WindowsIdentity]::GetCurrent()) |
|||
$elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
|||
$current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] START {1} edition={2} {3} elevated={4} os={5} build={6}.{7} user={8}' -f [DateTime]::UtcNow, $Label, $PSVersionTable.PSEdition, |
|||
$PSVersionTable.PSVersion, $elevated, $current.ProductName, $current.CurrentBuildNumber, $current.UBR, [Security.Principal.WindowsIdentity]::GetCurrent().Name |
|||
try { |
|||
$watch = [Diagnostics.Stopwatch]::StartNew() |
|||
if ($PesterModulePath) { Import-Module -Name (Join-Path -Path $PesterModulePath -ChildPath 'Pester.psd1') -Force -ErrorAction Stop } |
|||
else { Import-Module -Name Pester -RequiredVersion $PesterVersion -Force -ErrorAction Stop } |
|||
$configuration = New-PesterConfiguration |
|||
$configuration.Run.Path = @(Join-Path -Path $Root -ChildPath 'Tests') |
|||
$configuration.Run.PassThru = $true |
|||
$configuration.Output.Verbosity = 'Normal' |
|||
# The NUnit file is written below, after the summary: its writer asks WMI for the environment, which a restricted token may not do. |
|||
$configuration.TestResult.Enabled = $false |
|||
$result = Invoke-Pester -Configuration $configuration |
|||
'RESULT result={0} passed={1} failed={2} skipped={3} notrun={4} total={5} failedContainers={6} seconds={7:N0}' -f $result.Result, $result.PassedCount, |
|||
$result.FailedCount, $result.SkippedCount, $result.NotRunCount, $result.TotalCount, $result.FailedContainersCount, $watch.Elapsed.TotalSeconds |
|||
foreach ($test in $result.Failed) { 'FAILED: {0}: {1}' -f $test.ExpandedPath, ("$(@($test.ErrorRecord)[0])" -replace '\s+', ' ') } |
|||
foreach ($test in $result.Skipped) { 'SKIPPED: {0}' -f $test.ExpandedPath } |
|||
[pscustomobject]@{ |
|||
Label = $Label; Edition = $PSVersionTable.PSEdition; PowerShell = $PSVersionTable.PSVersion.ToString(); Elevated = $elevated |
|||
Os = '{0} {1}.{2}' -f $current.ProductName, $current.CurrentBuildNumber, $current.UBR; Result = [string] $result.Result |
|||
Passed = $result.PassedCount; Failed = $result.FailedCount; Skipped = $result.SkippedCount; NotRun = $result.NotRunCount; Total = $result.TotalCount |
|||
FailedContainers = $result.FailedContainersCount; Seconds = [int] $watch.Elapsed.TotalSeconds |
|||
FailedTests = @($result.Failed | ForEach-Object -Process { $_.ExpandedPath }); SkippedTests = @($result.Skipped | ForEach-Object -Process { $_.ExpandedPath }) |
|||
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath "$Label.json") -Encoding UTF8 |
|||
try { Export-NUnitReport -Result $result -Path (Join-Path -Path $OutDir -ChildPath "$Label.xml") } |
|||
catch { 'NUnit report not written: {0}' -f $_.Exception.Message } |
|||
if ($result.Result -eq 'Passed') { $script:exitCode = 0 } |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] {1}-DONE' -f [DateTime]::UtcNow, $Label |
|||
} |
|||
catch { |
|||
'ERROR: {0}' -f $_ |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] {1}-FAILED' -f [DateTime]::UtcNow, $Label |
|||
} |
|||
} *>&1 | Out-File -FilePath $log -Encoding utf8 -Width 400 |
|||
Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath "$Label.exit") -Value $script:exitCode |
|||
exit $script:exitCode |
|||
@ -0,0 +1,151 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string[]] $ModulePath, |
|||
[Parameter(Mandatory)] [string] $OutFile, |
|||
[string] $Client = 'OSWin11E', |
|||
[string] $DomainController = 'OSDC1', |
|||
[string] $FileServer = 'OSFile22', |
|||
[ValidateRange(2, 20)] [int] $Rounds = 4, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab' |
|||
) |
|||
|
|||
# Probe of the groups that a computer reports for an account that was deleted and created again with the same name (Decision 24). In each |
|||
# round it creates a user in a group that is in another group, with the same names and new SIDs, and a folder on the file server whose DACL |
|||
# grants the outer group ReadAndExecute. Then it logs the user on with Kerberos S4U, like the oracle of the live tests does, on the domain |
|||
# controller, the client, and the file server, and asks from the client, in a new process for each module, for the effective access of the |
|||
# account on the folder by name and by SID, with the default server name and with the name of the file server. -ModulePath takes module |
|||
# folders as label=path, such as baseline=C:\Build\NTFSSecurity. From the second round on, a computer that still holds the deleted account |
|||
# reports its SID, and every module reports no access (Synchronize only), whichever its version. The probe removes everything it created; the |
|||
# accounts, the folder, and the files on the client are named NtfsProbe*, so Test-MatrixCleanup.ps1 reports a leftover. The password of the |
|||
# user is random and exists only in memory. Windows PowerShell 5.1 on the Hyper-V host, with AutomatedLab. |
|||
& { |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$modules = @( |
|||
foreach ($entry in @($ModulePath | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ })) { |
|||
$label, $path = $entry -split '=', 2 |
|||
if (-not $path -or -not (Test-Path -LiteralPath (Join-Path -Path $path -ChildPath 'NTFSSecurity.psd1'))) { throw "-ModulePath takes label=folder with a module; '$entry' has none." } |
|||
[pscustomobject]@{ Label = $label; Path = $path } |
|||
} |
|||
) |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$domainName = (Get-Lab).Domains[0].Name |
|||
$netBiosName = $domainName.Split('.')[0].ToUpperInvariant() |
|||
$dcSession = New-LabPSSession -ComputerName $DomainController |
|||
$clientSession = New-LabPSSession -ComputerName $Client |
|||
$serverSession = New-LabPSSession -ComputerName $FileServer |
|||
$machines = [ordered]@{ $DomainController = $dcSession; $Client = $clientSession; $FileServer = $serverSession } |
|||
$userName = 'NtfsProbeSubject' |
|||
$innerName = 'NtfsProbeInner' |
|||
$outerName = 'NtfsProbeOuter' |
|||
$folderName = 'NtfsProbeRecreation' |
|||
$stageName = 'C:\NtfsProbeModules' |
|||
$report = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
$bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 |
|||
$generator = [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 of a domain's complexity rules. |
|||
$secret = New-Object -TypeName 'System.Security.SecureString' |
|||
foreach ($character in ([Convert]::ToBase64String($bytes) + '!a1Z').ToCharArray()) { $secret.AppendChar($character) } |
|||
$secret.MakeReadOnly() |
|||
|
|||
$removeOnDcScript = { |
|||
param ($User, $Inner, $Outer) |
|||
Import-Module -Name ActiveDirectory |
|||
foreach ($name in $User) { Get-ADUser -Filter "SamAccountName -eq '$name'" | Remove-ADUser -Confirm:$false } |
|||
foreach ($name in $Inner, $Outer) { Get-ADGroup -Filter "SamAccountName -eq '$name'" | Remove-ADGroup -Confirm:$false } |
|||
} |
|||
$createOnDcScript = { |
|||
param ($User, $Inner, $Outer, [securestring] $Secret) |
|||
Import-Module -Name ActiveDirectory |
|||
$null = New-ADGroup -Name $Outer -SamAccountName $Outer -GroupScope Global |
|||
$null = New-ADGroup -Name $Inner -SamAccountName $Inner -GroupScope Global |
|||
Add-ADGroupMember -Identity $Outer -Members $Inner |
|||
$null = New-ADUser -Name $User -SamAccountName $User -UserPrincipalName ('{0}@{1}' -f $User, (Get-ADDomain).DNSRoot) -AccountPassword $Secret -Enabled $true |
|||
Add-ADGroupMember -Identity $Inner -Members $User |
|||
[pscustomobject]@{ User = (Get-ADUser -Identity $User).SID.Value; Outer = (Get-ADGroup -Identity $Outer).SID.Value } |
|||
} |
|||
$setFolderScript = { |
|||
param ($Folder, $OuterSid) |
|||
$path = Join-Path -Path $env:SystemDrive -ChildPath $Folder |
|||
if (-not (Test-Path -LiteralPath $path)) { $null = New-Item -ItemType Directory -Path $path } |
|||
$acl = New-Object -TypeName 'System.Security.AccessControl.DirectorySecurity' |
|||
$acl.SetAccessRuleProtection($true, $false) |
|||
foreach ($entry in @(@('S-1-5-32-544', 'FullControl'), @('S-1-5-18', 'FullControl'), @($OuterSid, 'ReadAndExecute'))) { |
|||
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ([Security.Principal.SecurityIdentifier] $entry[0]), $entry[1], 'ContainerInherit,ObjectInherit', 'None', 'Allow')) |
|||
} |
|||
|
|||
Set-Acl -LiteralPath $path -AclObject $acl |
|||
} |
|||
$tokenScript = { |
|||
param ($User, $Domain, $UserSid, $OuterSid) |
|||
try { |
|||
$identity = New-Object -TypeName 'System.Security.Principal.WindowsIdentity' -ArgumentList ('{0}@{1}' -f $User, $Domain) |
|||
$groups = @($identity.Groups | ForEach-Object -Process { $_.Value }) |
|||
'S4U token of the {0} account, outer group {1}' -f $(if ($identity.User.Value -eq $UserSid) { 'CURRENT' } else { 'OLD' }), ($groups -contains $OuterSid) |
|||
} |
|||
catch { 'S4U logon failed: ' + $_.Exception.Message } |
|||
} |
|||
$effectiveScript = { |
|||
param ($ModuleFolder, $Folder, $Server, $Domain, $NetBios, $User, $UserSid) |
|||
Import-Module -Name (Join-Path -Path $ModuleFolder -ChildPath 'NTFSSecurity.psd1') -Force |
|||
$unc = '\\{0}.{1}\C$\{2}' -f $Server, $Domain, $Folder |
|||
$name = '{0}\{1}' -f $NetBios, $User |
|||
function Measure-Answer { |
|||
param ([hashtable] $Arguments) |
|||
$result = @(Get-NTFSEffectiveAccess @Arguments -WarningAction SilentlyContinue -ErrorAction SilentlyContinue -ErrorVariable failures) |
|||
$value = if ($result.Count) { '0x{0:X}' -f ([long] $result[0].AccessRights) } else { 'none' } |
|||
if (@($failures).Count) { $value += ' ERR ' + $failures[0].Exception.Message } |
|||
$value |
|||
} |
|||
|
|||
$serverName = '{0}.{1}' -f $Server, $Domain |
|||
'effective access by name {0}, by name and server {1}, by SID {2}, by SID and server {3}' -f |
|||
(Measure-Answer -Arguments @{ Path = $unc; Account = $name }), |
|||
(Measure-Answer -Arguments @{ Path = $unc; Account = $name; ServerName = $serverName }), |
|||
(Measure-Answer -Arguments @{ Path = $unc; Account = $UserSid }), |
|||
(Measure-Answer -Arguments @{ Path = $unc; Account = $UserSid; ServerName = $serverName }) |
|||
} |
|||
$runEffectiveScript = { |
|||
param ($Stage, $ModuleLabel, $ScriptText, $Folder, $Server, $Domain, $NetBios, $User, $UserSid) |
|||
$block = [scriptblock]::Create($ScriptText) |
|||
$powershell = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' |
|||
& $powershell -NoProfile -ExecutionPolicy Bypass -Command $block -args (Join-Path -Path $Stage -ChildPath $ModuleLabel), $Folder, $Server, $Domain, $NetBios, $User, $UserSid |
|||
} |
|||
try { |
|||
Invoke-Command -Session $dcSession -ScriptBlock $removeOnDcScript -ArgumentList $userName, $innerName, $outerName |
|||
Invoke-Command -Session $clientSession -ArgumentList $stageName -ScriptBlock { param ($Stage) if (Test-Path -LiteralPath $Stage) { Remove-Item -LiteralPath $Stage -Recurse -Force }; $null = New-Item -ItemType Directory -Path $Stage -Force } |
|||
foreach ($module in $modules) { |
|||
Invoke-Command -Session $clientSession -ArgumentList (Join-Path -Path $stageName -ChildPath $module.Label) -ScriptBlock { param ($Path) $null = New-Item -ItemType Directory -Path $Path -Force } |
|||
Copy-Item -Path (Join-Path -Path $module.Path -ChildPath '*') -Destination (Join-Path -Path $stageName -ChildPath $module.Label) -ToSession $clientSession -Recurse -Force |
|||
} |
|||
|
|||
for ($round = 1; $round -le $Rounds; $round++) { |
|||
$created = Invoke-Command -Session $dcSession -ScriptBlock $createOnDcScript -ArgumentList $userName, $innerName, $outerName, $secret |
|||
Invoke-Command -Session $serverSession -ScriptBlock $setFolderScript -ArgumentList $folderName, $created.Outer |
|||
$report.Add(('round {0} at {1:HH:mm:ss}Z: subject {2}, outer group {3}' -f $round, [DateTime]::UtcNow, $created.User, $created.Outer)) |
|||
foreach ($machine in $machines.Keys) { |
|||
$report.Add((' {0}: {1}' -f $machine, (Invoke-Command -Session $machines[$machine] -ScriptBlock $tokenScript -ArgumentList $userName, $domainName, $created.User, $created.Outer))) |
|||
} |
|||
|
|||
# The order of the modules alternates, so that the module that asks first is not always the same. |
|||
$ordered = if ($round % 2) { $modules } else { @($modules)[($modules.Count - 1)..0] } |
|||
foreach ($module in $ordered) { |
|||
$answer = Invoke-Command -Session $clientSession -ArgumentList $stageName, $module.Label, $effectiveScript.ToString(), $folderName, $FileServer, $domainName, $netBiosName, $userName, $created.User -ScriptBlock $runEffectiveScript |
|||
$report.Add((' {0} on {1}: {2}' -f $module.Label, $Client, (@($answer) -join ' '))) |
|||
} |
|||
|
|||
Invoke-Command -Session $dcSession -ScriptBlock $removeOnDcScript -ArgumentList $userName, $innerName, $outerName |
|||
} |
|||
} |
|||
finally { |
|||
try { Invoke-Command -Session $dcSession -ScriptBlock $removeOnDcScript -ArgumentList $userName, $innerName, $outerName } catch { $report.Add("cleanup on the domain controller failed: $($_.Exception.Message)") } |
|||
try { Invoke-Command -Session $serverSession -ArgumentList $folderName -ScriptBlock { param ($Folder) Remove-Item -LiteralPath (Join-Path -Path $env:SystemDrive -ChildPath $Folder) -Recurse -Force -ErrorAction SilentlyContinue } } catch { $report.Add("cleanup on the file server failed: $($_.Exception.Message)") } |
|||
try { Invoke-Command -Session $clientSession -ArgumentList $stageName -ScriptBlock { param ($Stage) Remove-Item -LiteralPath $Stage -Recurse -Force -ErrorAction SilentlyContinue } } catch { $report.Add("cleanup on the client failed: $($_.Exception.Message)") } |
|||
$report | Set-Content -LiteralPath $OutFile -Encoding utf8 |
|||
Remove-PSSession -Session @($machines.Values) -ErrorAction SilentlyContinue |
|||
} |
|||
|
|||
'done' |
|||
} |
|||
@ -0,0 +1,278 @@ |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The lab installation password comes from the AutomatedLab lab definition, which stores it as text, and the passwords of the probe users are random and exist only in memory; no credential is written.' |
|||
)] |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $Machine, |
|||
[Parameter(Mandatory)] [string] $ModulePath, |
|||
[Parameter(Mandatory)] [string] $OutputRoot, |
|||
[string] $Variant = 'Elevated,Safer,Standard', |
|||
[string] $OtherServer = '', |
|||
[string] $DomainController = 'OSDC1', |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $LocalCredentialMachine = '', |
|||
[string] $RepositoryRoot, |
|||
[ValidateRange(1, 60)] [int] $TimeoutMinutes = 10 |
|||
) |
|||
|
|||
# Diagnostic of the operating-system matrix (Decision 24), in Windows PowerShell 5.1 on the Hyper-V host: runs Invoke-EffectiveAccessProbe.ps1 |
|||
# on one machine under up to four tokens, one after the other, and copies the output back. |
|||
# Elevated the lab account in a scheduled task at the highest run level (the elevated mode of the local suite) |
|||
# Limited the same account at the limited run level; a task with a batch logon doesn't get a filtered token, so this repeats Elevated |
|||
# Safer the token of a basic user that Run-MatrixLocalSuite.ps1 -Mode Basic uses (SAFER level Normal User) |
|||
# Standard a local standard user that this script creates on the machine and removes again, with a password that only exists there |
|||
# DomainStandard a standard user of the domain, created on the domain controller (-DomainController) and removed again |
|||
# The standard users get the batch logon right through the group Performance Log Users, which has no other right that the check needs. |
|||
# Nothing secret is written; the lab password stays in memory, as in Run-MatrixLocalSuite.ps1. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. |
|||
if (-not $RepositoryRoot) { $RepositoryRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent } |
|||
$variants = @($Variant -split ',' | Where-Object -FilterScript { $_ }) |
|||
if ($variants | Where-Object -FilterScript { $_ -notin 'Elevated', 'Limited', 'Safer', 'Standard', 'DomainStandard' }) { throw '-Variant takes Elevated, Limited, Safer, Standard, and DomainStandard, separated by commas.' } |
|||
$localCredential = @($LocalCredentialMachine -split ',' | Where-Object -FilterScript { $_ }) |
|||
$cellFolder = Join-Path -Path $OutputRoot -ChildPath "$Label-$Machine" |
|||
$null = New-Item -ItemType Directory -Path $cellFolder -Force |
|||
$log = Join-Path -Path $cellFolder -ChildPath "$Label-probe.log" |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $log } |
|||
Set-Content -LiteralPath $log -Value (($stamp -f [DateTime]::UtcNow) + " START probe-$Label machine=$Machine variants=$($variants -join ',')") |
|||
|
|||
$wrapper = Get-Content -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath '.github\scripts\Invoke-TestsAsBasicUser.ps1') -Raw |
|||
$class = [regex]::Match($wrapper, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@").Groups[1].Value |
|||
if (-not $class) { throw 'The class of the basic-user wrapper was not found in .github\scripts\Invoke-TestsAsBasicUser.ps1.' } |
|||
$saferHead = @' |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $Executable, |
|||
[Parameter(Mandatory)] [string] $Arguments, |
|||
[Parameter(Mandatory)] [string] $WorkDirectory, |
|||
[Parameter(Mandatory)] [string] $Console |
|||
) |
|||
|
|||
# Generated by Probe-EffectiveAccess.ps1: starts a process with the token of a basic user (SAFER level Normal User) through the class of |
|||
# .github\scripts\Invoke-TestsAsBasicUser.ps1 and waits for it. |
|||
$ErrorActionPreference = 'Stop' |
|||
'@ |
|||
$saferTail = @' |
|||
$commandLine = 'cmd.exe /d /s /c ""{0}" {1} > "{2}" 2>&1"' -f $Executable, $Arguments, $Console |
|||
exit [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $WorkDirectory) |
|||
'@ |
|||
$stage = Join-Path -Path $env:TEMP -ChildPath "ntfs-probe-$Label" |
|||
if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } |
|||
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'module') -Force |
|||
Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination (Join-Path -Path $stage -ChildPath 'module') -Recurse |
|||
Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-EffectiveAccessProbe.ps1') -Destination $stage |
|||
Set-Content -LiteralPath (Join-Path -Path $stage -ChildPath 'Start-SaferProcess.ps1') -Encoding UTF8 -Value ($saferHead + "`r`nAdd-Type -TypeDefinition @'`r`n" + $class + "`r`n'@`r`n" + $saferTail) |
|||
$dllHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash |
|||
Write-Step "module dll=$dllHash" |
|||
|
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$sessionParameters = @{ ComputerName = $Machine } |
|||
if ($Machine -in $localCredential) { $sessionParameters.UseLocalCredential = $true } |
|||
$session = New-LabPSSession @sessionParameters |
|||
$machineDefinition = Get-LabVM -ComputerName $Machine |
|||
$runCredential = if ($Machine -in $localCredential) { |
|||
New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ('{0}\{1}' -f $Machine, $machineDefinition.InstallationUser.UserName), (ConvertTo-SecureString -String $machineDefinition.InstallationUser.Password -AsPlainText -Force) |
|||
} |
|||
else { |
|||
$machineDefinition.GetCredential((Get-Lab)) |
|||
} |
|||
|
|||
$root = 'C:\NtfsMatrixProbe\' + $Label |
|||
# A new name for every run: Windows keeps the SID of a deleted account for its name for a while, and a profile that stays loaded keeps the |
|||
# folder, so a name that is used again meets the leftovers of its predecessor. |
|||
$suffix = [DateTime]::UtcNow.ToString('MMddHHmmss') |
|||
$standardUser = 'NtfsProbeS' + $suffix |
|||
$domainUser = 'NtfsProbeD' + $suffix |
|||
$dcSession = $null |
|||
$domainSid = '' |
|||
function Get-RandomProbePassword { |
|||
# Random and never written; it exists in memory and in the account that the probe removes. |
|||
$bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 |
|||
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) |
|||
$alphabet = [char[]] 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' |
|||
'Aa1!' + (-join ($bytes | ForEach-Object -Process { $alphabet[$_ % $alphabet.Length] })) |
|||
} |
|||
try { |
|||
Invoke-Command -Session $session -ArgumentList $root -ScriptBlock { |
|||
param ($Path) |
|||
if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force } |
|||
$null = New-Item -ItemType Directory -Path (Join-Path -Path $Path -ChildPath 'out') -Force |
|||
} |
|||
|
|||
Copy-Item -Path (Join-Path -Path $stage -ChildPath '*') -Destination $root -ToSession $session -Recurse -Force |
|||
Write-Step "staged to $root" |
|||
|
|||
$start = { |
|||
param ($Root, $Variant, $UserName, $Password, $OtherServer, $StandardUser, $DomainSid) |
|||
$powershell = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' |
|||
$out = Join-Path -Path $Root -ChildPath 'out' |
|||
$outFile = Join-Path -Path $out -ChildPath ('{0}.txt' -f $Variant) |
|||
$probe = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -ModulePath "{1}" -OutFile "{2}" -Variant {3}' -f (Join-Path -Path $Root -ChildPath 'Invoke-EffectiveAccessProbe.ps1'), |
|||
(Join-Path -Path $Root -ChildPath 'module'), $outFile, $Variant |
|||
if ($OtherServer) { $probe += ' -OtherServer "{0}"' -f $OtherServer } |
|||
$taskName = 'NtfsMatrixProbe-' + $Variant |
|||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue |
|||
$runLevel = 'Highest' |
|||
if ($Variant -eq 'Standard') { |
|||
# The password exists only here: random, never written, and the account is removed after the run. |
|||
$bytes = New-Object -TypeName 'byte[]' -ArgumentList 24 |
|||
$generator = [Security.Cryptography.RandomNumberGenerator]::Create() |
|||
$generator.GetBytes($bytes) |
|||
$alphabet = [char[]] 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' |
|||
$Password = 'Aa1!' + (-join ($bytes | ForEach-Object -Process { $alphabet[$_ % $alphabet.Length] })) |
|||
$UserName = '{0}\{1}' -f $env:COMPUTERNAME, $StandardUser |
|||
if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $StandardUser } |
|||
$null = New-LocalUser -Name $StandardUser -Password (ConvertTo-SecureString -String $Password -AsPlainText -Force) -PasswordNeverExpires -UserMayNotChangePassword -Description 'Probe of the matrix, removed after the run' |
|||
Add-LocalGroupMember -SID 'S-1-5-32-559' -Member $StandardUser |
|||
$null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $StandardUser) |
|||
$runLevel = 'Limited' |
|||
$execute = $powershell |
|||
$argument = $probe |
|||
} |
|||
elseif ($Variant -eq 'DomainStandard') { |
|||
# By SID: a name of a deleted account of an earlier run can still resolve to its old SID. |
|||
try { Add-LocalGroupMember -SID 'S-1-5-32-559' -Member $DomainSid } |
|||
catch { if ($_.Exception.GetType().Name -ne 'MemberExistsException') { throw } } |
|||
$null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $UserName) |
|||
$runLevel = 'Limited' |
|||
$execute = $powershell |
|||
$argument = $probe |
|||
} |
|||
elseif ($Variant -eq 'Limited') { |
|||
$runLevel = 'Limited' |
|||
$execute = $powershell |
|||
$argument = $probe |
|||
} |
|||
elseif ($Variant -eq 'Safer') { |
|||
$null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f $UserName) |
|||
$execute = $powershell |
|||
$argument = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Executable "{1}" -Arguments "{2}" -WorkDirectory "{3}" -Console "{4}"' -f (Join-Path -Path $Root -ChildPath 'Start-SaferProcess.ps1'), |
|||
$powershell, ($probe -replace '"', '\"'), $Root, (Join-Path -Path $out -ChildPath 'safer.console.txt') |
|||
} |
|||
else { |
|||
$execute = $powershell |
|||
$argument = $probe |
|||
} |
|||
|
|||
$action = New-ScheduledTaskAction -Execute $execute -Argument $argument |
|||
$null = Register-ScheduledTask -TaskName $taskName -Action $action -RunLevel $runLevel -User $UserName -Password $Password |
|||
Start-ScheduledTask -TaskName $taskName |
|||
$taskName |
|||
} |
|||
$isRunning = { |
|||
param ($TaskName) |
|||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue |
|||
[bool] ($task -and $task.State -eq 'Running') |
|||
} |
|||
$finish = { |
|||
param ($TaskName) |
|||
$result = (Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue).LastTaskResult |
|||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue |
|||
"task result $result" |
|||
} |
|||
|
|||
foreach ($name in $variants) { |
|||
$password = if ($name -in 'Elevated', 'Limited', 'Safer') { $runCredential.GetNetworkCredential().Password } else { '' } |
|||
$userName = if ($name -in 'Elevated', 'Limited', 'Safer') { $runCredential.UserName } else { '' } |
|||
if ($name -eq 'DomainStandard') { |
|||
if (-not $dcSession) { $dcSession = New-LabPSSession -ComputerName $DomainController } |
|||
$password = Get-RandomProbePassword |
|||
$domainSid = Invoke-Command -Session $dcSession -ArgumentList $domainUser, $password -ScriptBlock { |
|||
param ($Name, $Secret) |
|||
Import-Module -Name ActiveDirectory |
|||
New-ADUser -Name $Name -SamAccountName $Name -AccountPassword (ConvertTo-SecureString -String $Secret -AsPlainText -Force) -Enabled $true -PasswordNeverExpires $true -CannotChangePassword $true -Description 'Probe of the matrix, removed after the run' |
|||
(Get-ADUser -Identity $Name).SID.Value |
|||
} |
|||
$userName = '{0}\{1}' -f ((Get-Lab).Domains[0].Name -split '\.')[0], $domainUser |
|||
Write-Step "domain user $domainUser created ($domainSid)" |
|||
} |
|||
$taskName = Invoke-Command -Session $session -ScriptBlock $start -ArgumentList $root, $name, $userName, $password, $OtherServer, $standardUser, $domainSid |
|||
Write-Step "variant $name started ($taskName)" |
|||
$deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) |
|||
do { |
|||
Start-Sleep -Seconds 5 |
|||
$alive = Invoke-Command -Session $session -ScriptBlock $isRunning -ArgumentList $taskName |
|||
} while ($alive -and [DateTime]::UtcNow -lt $deadline) |
|||
if ($alive) { Write-Step "variant $name TIMED OUT after $TimeoutMinutes minutes" } |
|||
Write-Step ("variant $name finished: " + (Invoke-Command -Session $session -ScriptBlock $finish -ArgumentList $taskName)) |
|||
} |
|||
|
|||
Copy-Item -FromSession $session -Path (Join-Path -Path $root -ChildPath 'out\*') -Destination $cellFolder -Recurse -Force |
|||
Write-Step 'results copied back' |
|||
} |
|||
finally { |
|||
# The domain account goes first: its member entry on the machine is then an orphaned SID, which the cleanup of the machine removes. |
|||
if ($dcSession) { |
|||
# A failure here must not skip the cleanup of the machine below. |
|||
try { |
|||
$dcLeftOver = Invoke-Command -Session $dcSession -ArgumentList $domainUser -ScriptBlock { |
|||
param ($Name) |
|||
Import-Module -Name ActiveDirectory |
|||
if (Get-ADUser -Filter "SamAccountName -eq '$Name'") { Remove-ADUser -Identity $Name -Confirm:$false } |
|||
if (Get-ADUser -Filter "SamAccountName -eq '$Name'") { "domain user $Name still exists" } |
|||
} |
|||
Write-Step ('cleanup of the domain controller: ' + $(if (@($dcLeftOver).Count -eq 0) { 'nothing left' } else { @($dcLeftOver) -join '; ' })) |
|||
} |
|||
catch { |
|||
Write-Step ("cleanup of the domain controller FAILED, remove the domain user $domainUser by hand: " + $_.Exception.Message) |
|||
} |
|||
|
|||
Remove-PSSession -Session $dcSession -ErrorAction SilentlyContinue |
|||
} |
|||
|
|||
if ($session) { |
|||
# The accounts and the files of the probe don't stay on the machine. The cleanup finds them by name and by orphaned SID, not by |
|||
# what this run created, so it also repairs what a run that stopped early left. |
|||
$leftOver = Invoke-Command -Session $session -ArgumentList $root, $standardUser, $domainSid -ScriptBlock { |
|||
param ($Root, $StandardUser, $DomainSid) |
|||
$report = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
$users = Join-Path -Path $env:SystemDrive -ChildPath 'Users' |
|||
function Get-ProbeProfile { @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.LocalPath -like (Join-Path -Path $users -ChildPath 'NtfsProbe*') }) } |
|||
function Get-ProbeMember { |
|||
# net.exe shows the member of a deleted account as its SID. |
|||
foreach ($line in @(cmd.exe /d /c 'net localgroup "Performance Log Users" 2>&1')) { |
|||
$member = ('{0}' -f $line).Trim() |
|||
if ($member -match '^S-1-5-21-[\d-]+$' -or $member -match '\\NtfsProbe') { $member } |
|||
} |
|||
} |
|||
|
|||
@(Get-ScheduledTask -TaskName 'NtfsMatrixProbe-*' -ErrorAction SilentlyContinue) | ForEach-Object -Process { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue } |
|||
if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $StandardUser } |
|||
# net.exe doesn't take the SID of an account that its name cache still resolves, so the member goes by its SID through the cmdlet. |
|||
if ($DomainSid) { |
|||
try { Remove-LocalGroupMember -SID 'S-1-5-32-559' -Member $DomainSid -ErrorAction Stop } |
|||
catch { if ("$($_.Exception.Message)" -notlike '*was not found*') { $report.Add("member ${DomainSid}: $($_.Exception.Message)") } } |
|||
} |
|||
|
|||
# A profile that the last task of the account used stays loaded for a few seconds, so the removal is repeated. |
|||
$attempt = 0 |
|||
do { |
|||
$profiles = Get-ProbeProfile |
|||
if ($profiles.Count -gt 0) { |
|||
$profiles | Remove-CimInstance -ErrorAction SilentlyContinue |
|||
if ((Get-ProbeProfile).Count -gt 0) { Start-Sleep -Seconds 3 } |
|||
} |
|||
|
|||
$attempt++ |
|||
} while ((Get-ProbeProfile).Count -gt 0 -and $attempt -lt 10) |
|||
|
|||
Get-ChildItem -LiteralPath $users -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue |
|||
if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue } |
|||
if (Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue) { $report.Add("user $StandardUser still exists") } |
|||
foreach ($member in @(Get-ProbeMember)) { $report.Add("$member is still in Performance Log Users") } |
|||
foreach ($folder in @(Get-ChildItem -LiteralPath $users -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue)) { $report.Add("profile folder $($folder.Name) still exists") } |
|||
foreach ($userProfile in (Get-ProbeProfile)) { $report.Add("profile $($userProfile.LocalPath) still exists") } |
|||
if (Test-Path -LiteralPath $Root) { $report.Add("folder $Root still exists") } |
|||
@(Get-ScheduledTask -TaskName 'NtfsMatrixProbe-*' -ErrorAction SilentlyContinue) | ForEach-Object -Process { $report.Add("task $($_.TaskName) still exists") } |
|||
$report |
|||
} |
|||
Write-Step ('cleanup: ' + $(if (@($leftOver).Count -eq 0) { 'nothing left on the machine' } else { @($leftOver) -join '; ' })) |
|||
Remove-PSSession -Session $session -ErrorAction SilentlyContinue |
|||
} |
|||
} |
|||
|
|||
Write-Step "probe-$Label-DONE" |
|||
@ -0,0 +1,224 @@ |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Pester passes the data to the blocks of the container.')] |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'The tests read the variables that BeforeAll sets.')] |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $ModulePath, |
|||
[Parameter(Mandatory)] [string] $OutFile, |
|||
[string] $PesterPath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1' |
|||
) |
|||
|
|||
# Diagnostic of the acceptance in Acceptance-2026-10-09-quality-gate-paths.md, not part of it: why do the Select-Object rows of case 10 |
|||
# fail on the base of the branch without a message? It runs the bodies of those tests (Assert-LabPipelineStop and |
|||
# Assert-LabDownstreamFailure of NTFSSecurity.Live.Tests.ps1) against files in a new folder below TEMP, with the settings of the |
|||
# runner (Pester 5.7.1, ErrorActionPreference Stop, detailed plain text), and lists for each test its result and error records, and |
|||
# the state of the items afterwards. One module build in one edition per process, never imported into another session; the script |
|||
# removes its own folder at the end after it has checked the path. Windows only. For example: |
|||
# powershell.exe -NoProfile -File Probe-LaterCommand.ps1 -ModulePath <folder with NTFSSecurity.psd1> -OutFile <result.txt> |
|||
$ErrorActionPreference = 'Stop' |
|||
Import-Module -Name (Join-Path -Path $PesterPath -ChildPath 'Pester.psd1') -Force |
|||
$root = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('mute-probe-' + [guid]::NewGuid().ToString('N')) |
|||
$null = New-Item -ItemType Directory -Path $root |
|||
$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value |
|||
$lines = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
try { |
|||
$container = New-PesterContainer -ScriptBlock { |
|||
param ($ModulePath, $Root, $Account) |
|||
BeforeAll { |
|||
Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop |
|||
$everyone = 'S-1-1-0' |
|||
$administrators = 'S-1-5-32-544' |
|||
$privateData = (Get-Module -Name NTFSSecurity).PrivateData |
|||
$privateData['EnablePrivileges'] = $false |
|||
$account = $Account |
|||
|
|||
function Get-ProbeOwner { |
|||
param ([string] $Path) |
|||
(Get-Acl -LiteralPath $Path).GetOwner([System.Security.Principal.SecurityIdentifier]).Value |
|||
} |
|||
|
|||
function New-ProbeFolder { |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Helper that writes only to the folder of this run.' |
|||
)] |
|||
param ([string] $Name) |
|||
$path = Join-Path -Path $Root -ChildPath $Name |
|||
$null = New-Item -ItemType Directory -Path $path -Force |
|||
$path |
|||
} |
|||
|
|||
function New-ProbePair { |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Helper that writes only to the folder of this run.' |
|||
)] |
|||
param ([string] $Name) |
|||
$directory = New-ProbeFolder -Name $Name |
|||
foreach ($item in 'First', 'Second') { |
|||
Set-Content -LiteralPath (Join-Path -Path $directory -ChildPath "$item.txt") -Value $item -NoNewline |
|||
} |
|||
|
|||
@{ Directory = $directory; First = (Join-Path -Path $directory -ChildPath 'First.txt'); Second = (Join-Path -Path $directory -ChildPath 'Second.txt') } |
|||
} |
|||
|
|||
$cases = @{ |
|||
'Remove-Item2' = @{ |
|||
Prepare = { param ($Slug) New-ProbePair -Name "RemoveItem2-$Slug" } |
|||
Run = { param ($Context) Remove-Item2 -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } |
|||
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } |
|||
} |
|||
'Copy-Item2' = @{ |
|||
Prepare = { param ($Slug) $c = New-ProbePair -Name "CopyItem2-$Slug"; $c.Destination = New-ProbeFolder -Name "CopyItem2-$Slug-To"; $c } |
|||
Run = { param ($Context) Copy-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue } |
|||
Untouched = { param ($Context) -not (Test-Path -LiteralPath (Join-Path -Path $Context.Destination -ChildPath 'Second.txt')) } |
|||
} |
|||
'Move-Item2' = @{ |
|||
Prepare = { param ($Slug) $c = New-ProbePair -Name "MoveItem2-$Slug"; $c.Destination = New-ProbeFolder -Name "MoveItem2-$Slug-To"; $c } |
|||
Run = { param ($Context) Move-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue } |
|||
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } |
|||
} |
|||
'Set-NTFSOwner' = @{ |
|||
Prepare = { param ($Slug) New-ProbePair -Name "SetOwner-$Slug" } |
|||
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $account -PassThru -ErrorAction SilentlyContinue } |
|||
Untouched = { param ($Context) (Get-ProbeOwner -Path $Context.Second) -eq $administrators } |
|||
} |
|||
'Set-NTFSSecurityDescriptor' = @{ |
|||
Prepare = { |
|||
param ($Slug) |
|||
$c = New-ProbePair -Name "SetDescriptor-$Slug" |
|||
$c.Descriptors = @(Get-NTFSSecurityDescriptor -Path $c.First, $c.Second -ErrorAction Stop) |
|||
Add-NTFSAccess -SecurityDescriptor $c.Descriptors -Account $everyone -AccessRights ReadData -ErrorAction Stop |
|||
$c |
|||
} |
|||
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -PassThru -ErrorAction SilentlyContinue } |
|||
Untouched = { param ($Context) -not (@((Get-Acl -LiteralPath $Context.Second).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $everyone }).Count) } |
|||
} |
|||
} |
|||
$streamCases = @{ |
|||
'Set-NTFSSecurityDescriptor/verbose' = @{ |
|||
Prepare = $cases['Set-NTFSSecurityDescriptor'].Prepare |
|||
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -Verbose -ErrorAction SilentlyContinue 4>&1 } |
|||
Untouched = $cases['Set-NTFSSecurityDescriptor'].Untouched |
|||
RecordType = [System.Management.Automation.VerboseRecord] |
|||
} |
|||
'Set-NTFSOwner/debug' = @{ |
|||
Prepare = $cases['Set-NTFSOwner'].Prepare |
|||
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $account -ErrorAction SilentlyContinue 5>&1 } |
|||
Untouched = $cases['Set-NTFSOwner'].Untouched |
|||
RecordType = [System.Management.Automation.DebugRecord] |
|||
} |
|||
} |
|||
|
|||
function Assert-ProbePipelineStop { |
|||
param ([hashtable] $Case, [string] $Slug, [string] $Stream) |
|||
|
|||
if ($Stream -eq 'debug') { $DebugPreference = 'Continue' } |
|||
$context = & $Case.Prepare $Slug |
|||
$Error.Clear() |
|||
|
|||
$result = @(& $Case.Run $context | Select-Object -First 1) |
|||
|
|||
$result | Should -HaveCount 1 |
|||
if ($Case.RecordType) { |
|||
$result[0] | Should -BeOfType $Case.RecordType |
|||
} |
|||
|
|||
$Error.Count | Should -Be 0 |
|||
if ($Case.Untouched) { |
|||
(& $Case.Untouched $context) | Should -BeTrue |
|||
} |
|||
} |
|||
|
|||
function Assert-ProbeDownstreamFailure { |
|||
param ([hashtable] $Case, [string] $Slug, [string] $Stream) |
|||
|
|||
if ($Stream -eq 'debug') { $DebugPreference = 'Continue' } |
|||
$context = & $Case.Prepare $Slug |
|||
$emitted = 0 |
|||
$caught = $null |
|||
$Error.Clear() |
|||
try { |
|||
& $Case.Run $context | ForEach-Object -Process { |
|||
$emitted++ |
|||
throw 'Downstream failure' |
|||
} |
|||
} |
|||
catch { |
|||
$caught = $_ |
|||
} |
|||
|
|||
$caught.Exception.Message | Should -BeLike '*Downstream failure*' |
|||
$emitted | Should -Be 1 |
|||
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty |
|||
if ($Case.Untouched) { |
|||
(& $Case.Untouched $context) | Should -BeTrue |
|||
} |
|||
} |
|||
} |
|||
|
|||
Describe 'Mirror of the later-command tests' { |
|||
It '<Name> should stop after the first object for Select-Object -First 1' -ForEach @( |
|||
@{ Name = 'Remove-Item2' }, @{ Name = 'Copy-Item2' }, @{ Name = 'Move-Item2' }, @{ Name = 'Set-NTFSOwner' }, @{ Name = 'Set-NTFSSecurityDescriptor' } |
|||
) { |
|||
Assert-ProbePipelineStop -Case $cases[$Name] -Slug 'Select' |
|||
} |
|||
|
|||
It '<Name> should stop after the first object for throw' -ForEach @( |
|||
@{ Name = 'Remove-Item2' }, @{ Name = 'Copy-Item2' }, @{ Name = 'Move-Item2' }, @{ Name = 'Set-NTFSOwner' }, @{ Name = 'Set-NTFSSecurityDescriptor' } |
|||
) { |
|||
Assert-ProbeDownstreamFailure -Case $cases[$Name] -Slug 'Throw' |
|||
} |
|||
|
|||
It '<Key> should stop at the message for Select-Object -First 1' -ForEach @( |
|||
@{ Key = 'Set-NTFSSecurityDescriptor/verbose'; Stream = 'verbose' }, @{ Key = 'Set-NTFSOwner/debug'; Stream = 'debug' } |
|||
) { |
|||
Assert-ProbePipelineStop -Case $streamCases[$Key] -Slug ('{0}Select' -f $Stream) -Stream $Stream |
|||
} |
|||
} |
|||
} -Data @{ ModulePath = $ModulePath; Root = $root; Account = $account } |
|||
|
|||
$configuration = New-PesterConfiguration |
|||
$configuration.Run.Container = $container |
|||
$configuration.Run.PassThru = $true |
|||
$configuration.Output.Verbosity = 'Detailed' |
|||
$configuration.Output.RenderMode = 'Plaintext' |
|||
$lines.Add(('Edition {0} {1}; module {2}' -f $PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $ModulePath)) |
|||
$lines.Add('--- Pester output') |
|||
$output = & { Invoke-Pester -Configuration $configuration } *>&1 |
|||
$result = @($output | Where-Object -FilterScript { $_ -is [Pester.Run] }) | Select-Object -First 1 |
|||
foreach ($entry in @($output | Where-Object -FilterScript { $_ -isnot [Pester.Run] })) { $lines.Add('{0}' -f $entry) } |
|||
$lines.Add('--- Results') |
|||
foreach ($test in $result.Tests) { |
|||
$messages = @(@($test.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { ($_.ToString() -split '\r?\n')[0] }) |
|||
$lines.Add(('{0} | {1} | error records: {2} | {3}' -f $test.Result, $test.ExpandedName, @($test.ErrorRecord).Count, ($messages -join ' // '))) |
|||
} |
|||
|
|||
$lines.Add(('Totals: passed {0}, failed {1}, not run {2}; result {3}' -f $result.PassedCount, $result.FailedCount, $result.NotRunCount, $result.Result)) |
|||
$lines.Add('--- State of the items after the run') |
|||
foreach ($folder in Get-ChildItem -LiteralPath $root -Directory | Sort-Object -Property Name) { |
|||
$files = @(Get-ChildItem -LiteralPath $folder.FullName -File | ForEach-Object -Process { $_.Name }) |
|||
$lines.Add(('{0}: {1}' -f $folder.Name, ($files -join ', '))) |
|||
} |
|||
|
|||
$lines.Add('--- Owner (SetOwner folders) and explicit entry for Everyone (SetDescriptor folders) after the run') |
|||
foreach ($folder in Get-ChildItem -LiteralPath $root -Directory | Where-Object -FilterScript { $_.Name -like 'SetOwner-*' -or $_.Name -like 'SetDescriptor-*' } | Sort-Object -Property Name) { |
|||
foreach ($name in 'First.txt', 'Second.txt') { |
|||
$path = Join-Path -Path $folder.FullName -ChildPath $name |
|||
$acl = Get-Acl -LiteralPath $path |
|||
if ($folder.Name -like 'SetOwner-*') { |
|||
$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value |
|||
$lines.Add(('{0}\{1}: owner {2}' -f $folder.Name, $name, $(if ($owner -eq 'S-1-5-32-544') { 'Administrators (as created)' } elseif ($owner -eq $account) { 'the account of the run (changed)' } else { $owner }))) |
|||
} |
|||
else { |
|||
$entries = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) |
|||
$lines.Add(('{0}\{1}: explicit entry for Everyone: {2}' -f $folder.Name, $name, $(if ($entries.Count) { 'yes (changed)' } else { 'no (as created)' }))) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
finally { |
|||
$full = [System.IO.Path]::GetFullPath($root) |
|||
if ($full.StartsWith([System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()), [System.StringComparison]::OrdinalIgnoreCase) -and (Split-Path -Path $full -Leaf) -like 'mute-probe-*') { |
|||
Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue |
|||
} |
|||
|
|||
Set-Content -LiteralPath $OutFile -Value $lines -Encoding utf8 |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $LogPath, |
|||
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $VmName, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[switch] $Start |
|||
) |
|||
|
|||
# Repairs the boot files of one virtual machine of the matrix lab. AutomatedLab builds a base image with the bcdboot of the host and |
|||
# ignores its exit code; when the host's bcdboot can't process the boot files of an older image (it fails with exit code 193 on Windows |
|||
# Server 2019 and on Windows 11 22H2), the EFI system partition stays empty and the generation 2 virtual machine fails with Hyper-V event |
|||
# 18603. This script turns the machine off, mounts the machine's own differencing disk (never the shared base image), runs the bcdboot of |
|||
# the image itself, adds the removable-media path EFI\Boot\bootx64.efi that a new virtual machine boots from, checks the files, and |
|||
# dismounts the disk. It refuses a machine that isn't connected to the switch of the lab. Windows PowerShell 5.1 on the host, elevated. |
|||
$ErrorActionPreference = 'Stop' |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } |
|||
|
|||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } |
|||
|
|||
($stamp -f [DateTime]::UtcNow) + " START repair-os-matrix-boot vm=$VmName lab=$LabName" | Set-Content -LiteralPath $LogPath |
|||
$diskPath = $null |
|||
$letters = @() |
|||
try { |
|||
$vm = Get-VM -Name $VmName |
|||
if ($vm.Generation -ne 2) { throw "$VmName isn't a generation 2 machine." } |
|||
$switches = @(Get-VMNetworkAdapter -VMName $VmName | ForEach-Object -Process { $_.SwitchName }) |
|||
# An array comparison with -ne returns the elements that differ, and an empty result is false: a machine without an adapter, or with an |
|||
# adapter that has no switch, would pass, so the guard counts. |
|||
if ($switches.Count -eq 0 -or @($switches | Where-Object -FilterScript { $_ -ne $LabName }).Count -gt 0) { throw "$VmName isn't connected only to the switch '$LabName' (switches: $($switches -join ', ')). Refusing." } |
|||
if ($vm.State -ne 'Off') { |
|||
Stop-VM -Name $VmName -TurnOff -Force |
|||
Write-Step "$VmName turned off" |
|||
} |
|||
|
|||
$drive = Get-VMHardDiskDrive -VMName $VmName | Select-Object -First 1 |
|||
$diskPath = $drive.Path |
|||
$vhd = Get-VHD -Path $diskPath |
|||
if ($vhd.VhdType -ne 'Differencing') { throw "$diskPath isn't a differencing disk; refusing to change a base image." } |
|||
Write-Step "disk $diskPath (parent $($vhd.ParentPath))" |
|||
|
|||
$image = Mount-VHD -Path $diskPath -Passthru |
|||
$disk = $image | Get-Disk |
|||
$partitions = @(Get-Partition -DiskNumber $disk.Number) |
|||
$esp = $partitions | Where-Object -FilterScript { $_.GptType -eq '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' } | Select-Object -First 1 |
|||
$system = $partitions | Where-Object -FilterScript { $_.Type -eq 'Basic' } | Sort-Object -Property Size -Descending | Select-Object -First 1 |
|||
if (-not $esp -or -not $system) { throw 'The disk has no EFI system partition or no Windows partition.' } |
|||
foreach ($partition in $esp, $system) { |
|||
# The host may have assigned a letter to the Windows partition on mount already. |
|||
if (-not (Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber).DriveLetter) { |
|||
Add-PartitionAccessPath -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber -AssignDriveLetter |
|||
} |
|||
|
|||
$letters += (Get-Partition -DiskNumber $disk.Number -PartitionNumber $partition.PartitionNumber).DriveLetter |
|||
} |
|||
|
|||
$espLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $esp.PartitionNumber).DriveLetter |
|||
$systemLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $system.PartitionNumber).DriveLetter |
|||
$windows = '{0}:\Windows' -f $systemLetter |
|||
$bcdboot = Join-Path -Path $windows -ChildPath 'System32\bcdboot.exe' |
|||
if (-not (Test-Path -LiteralPath $bcdboot)) { throw "$bcdboot is missing." } |
|||
$before = @(Get-ChildItem -LiteralPath ('{0}:\' -f $espLetter) -Recurse -Force -File -ErrorAction SilentlyContinue).Count |
|||
Write-Step ("system partition {0}: {1}; ESP {2}: holds {3} files; image build {4}" -f $systemLetter, $windows, $espLetter, $before, (Get-Item -LiteralPath $bcdboot).VersionInfo.ProductVersion) |
|||
|
|||
$output = & $bcdboot $windows /s ('{0}:' -f $espLetter) /f UEFI 2>&1 | Out-String |
|||
Write-Step ("bcdboot of the image: exit code {0}: {1}" -f $LASTEXITCODE, ($output -replace '\s+', ' ').Trim()) |
|||
if ($LASTEXITCODE -ne 0) { throw "bcdboot of the image failed with exit code $LASTEXITCODE." } |
|||
|
|||
$bootManager = '{0}:\EFI\Microsoft\Boot\bootmgfw.efi' -f $espLetter |
|||
if (-not (Test-Path -LiteralPath $bootManager)) { throw "$bootManager is missing after bcdboot." } |
|||
$removable = '{0}:\EFI\Boot' -f $espLetter |
|||
$null = New-Item -ItemType Directory -Path $removable -Force |
|||
Copy-Item -LiteralPath $bootManager -Destination (Join-Path -Path $removable -ChildPath 'bootx64.efi') -Force |
|||
$after = @(Get-ChildItem -LiteralPath ('{0}:\' -f $espLetter) -Recurse -Force -File -ErrorAction SilentlyContinue).Count |
|||
$hasBcd = Test-Path -LiteralPath ('{0}:\EFI\Microsoft\Boot\BCD' -f $espLetter) |
|||
Write-Step ("ESP now holds {0} files; BCD present: {1}; bootx64.efi present: {2}" -f $after, $hasBcd, (Test-Path -LiteralPath (Join-Path -Path $removable -ChildPath 'bootx64.efi'))) |
|||
if ($after -lt 20 -or -not $hasBcd) { throw "The EFI system partition still looks empty ($after files, BCD $hasBcd)." } |
|||
} |
|||
catch { |
|||
Write-Step ('repair-os-matrix-boot-FAILED: {0}' -f $_) |
|||
$_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath |
|||
$failed = $true |
|||
} |
|||
finally { |
|||
if ($diskPath) { |
|||
try { |
|||
foreach ($partition in @(Get-Partition -DiskNumber (Get-VHD -Path $diskPath).DiskNumber -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.DriveLetter })) { |
|||
Remove-PartitionAccessPath -DiskNumber $partition.DiskNumber -PartitionNumber $partition.PartitionNumber -AccessPath ('{0}:\' -f $partition.DriveLetter) -ErrorAction SilentlyContinue |
|||
} |
|||
} |
|||
catch { Write-Step ('access path cleanup: {0}' -f $_) } |
|||
Dismount-VHD -Path $diskPath -ErrorAction SilentlyContinue |
|||
Write-Step 'disk dismounted' |
|||
} |
|||
} |
|||
|
|||
if ($failed) { exit 1 } |
|||
if ($Start) { |
|||
Start-VM -Name $VmName |
|||
Write-Step "$VmName started" |
|||
} |
|||
|
|||
Write-Step 'repair-os-matrix-boot-DONE' |
|||
exit 0 |
|||
@ -0,0 +1,265 @@ |
|||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
|||
'PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The lab installation password comes from the AutomatedLab lab definition, which stores it as text; the credential is built in memory and never written.' |
|||
)] |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $Machine, |
|||
[Parameter(Mandatory)] [string] $ModulePath, |
|||
[Parameter(Mandatory)] [string] $OutputRoot, |
|||
[string] $Edition = 'Desktop,Core', |
|||
[string] $Mode = 'Elevated', |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $LocalCredentialMachine = '', |
|||
[string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', |
|||
[string] $RepositoryRoot, |
|||
[ValidateRange(5, 480)] [int] $TimeoutMinutes = 90 |
|||
) |
|||
|
|||
# The module's own Pester suite on the machines of the operating-system matrix (Decision 24), in Windows PowerShell 5.1 on the Hyper-V |
|||
# host. The live controller proves the behavior against a domain and remote servers; this proves the module and its tests run on each |
|||
# operating system and edition. It stages the behavior test files of the repository and the module under test (the same bits for every |
|||
# machine), copies them to the machine, runs Invoke-LocalSuite.ps1 in a new Windows PowerShell and a new PowerShell 7 process one after |
|||
# the other, and copies the log, the NUnit result, and the JSON summary back. -Mode Basic runs each edition with the token of a basic |
|||
# user, the way .github\scripts\Invoke-TestsAsBasicUser.ps1 does (the class of that script is extracted, not copied): the tests that |
|||
# need a missing privilege skip in the elevated mode and run in this one. The machine name LOCAL runs the same stage on this host, as |
|||
# the reference. A machine that can't use the domain account (a Windows 11 build whose secure channel to the domain controller fails) is |
|||
# listed in -LocalCredentialMachine and reached with the local installation account. Nothing secret is written. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. |
|||
if (-not $RepositoryRoot) { $RepositoryRoot = Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent } |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
$targets = @($Machine -split ',' | Where-Object -FilterScript { $_ }) |
|||
$editions = @($Edition -split ',' | Where-Object -FilterScript { $_ }) |
|||
$modes = @($Mode -split ',' | Where-Object -FilterScript { $_ }) |
|||
if ($modes | Where-Object -FilterScript { $_ -notin 'Elevated', 'Basic' }) { throw "-Mode takes Elevated, Basic, or both, separated by a comma." } |
|||
$localCredential = @($LocalCredentialMachine -split ',' | Where-Object -FilterScript { $_ }) |
|||
$behaviorFiles = 'Access', 'Audit', 'DriveRoot', 'FileHash', 'Inheritance', 'ItemCmdlets', 'Links', 'ObjectApis', 'OutputTypes', 'Owner', 'PathErrors', |
|||
'PermissionScopes', 'PipelineControl', 'Privileges', 'Remove-Item2', 'SecurityDescriptor', 'SecurityDescriptorSets', 'TestHelpers' |
|||
$null = New-Item -ItemType Directory -Path $OutputRoot -Force |
|||
$sequenceLog = Join-Path -Path $OutputRoot -ChildPath "$Label-localsuite.log" |
|||
function Write-Sequence { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $sequenceLog } |
|||
|
|||
$stage = Join-Path -Path $env:TEMP -ChildPath "ntfs-localsuite-$Label" |
|||
if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } |
|||
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'Tests') -Force |
|||
$null = New-Item -ItemType Directory -Path (Join-Path -Path $stage -ChildPath 'NTFSSecurity\bin\Release') -Force |
|||
foreach ($name in $behaviorFiles) { |
|||
$file = if ($name -eq 'TestHelpers') { 'TestHelpers.Tests.ps1' } else { "$name.Tests.ps1" } |
|||
Copy-Item -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath "Tests\$file") -Destination (Join-Path -Path $stage -ChildPath 'Tests') |
|||
} |
|||
|
|||
Copy-Item -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath 'Tests\TestHelpers.psm1') -Destination (Join-Path -Path $stage -ChildPath 'Tests') |
|||
Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination (Join-Path -Path $stage -ChildPath 'NTFSSecurity\bin\Release') -Recurse |
|||
Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-LocalSuite.ps1') -Destination $stage |
|||
if ('Basic' -in $modes) { |
|||
$wrapper = Get-Content -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath '.github\scripts\Invoke-TestsAsBasicUser.ps1') -Raw |
|||
$class = [regex]::Match($wrapper, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@").Groups[1].Value |
|||
if (-not $class) { throw 'The class of the basic-user wrapper was not found in .github\scripts\Invoke-TestsAsBasicUser.ps1.' } |
|||
$helperHead = @' |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $Executable, |
|||
[Parameter(Mandatory)] [string] $Root, |
|||
[Parameter(Mandatory)] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $OutDir, |
|||
[string] $PesterModulePath |
|||
) |
|||
|
|||
# Generated by Run-MatrixLocalSuite.ps1: starts Invoke-LocalSuite.ps1 with the token of a basic user (SAFER level Normal User) through the |
|||
# class of .github\scripts\Invoke-TestsAsBasicUser.ps1, waits for it, and writes its exit code. |
|||
$ErrorActionPreference = 'Stop' |
|||
'@ |
|||
$helperTail = @' |
|||
$runnerArguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Label {1} -Root "{2}" -OutDir "{3}"' -f (Join-Path -Path $Root -ChildPath 'Invoke-LocalSuite.ps1'), $Label, $Root, $OutDir |
|||
if ($PesterModulePath) { $runnerArguments += ' -PesterModulePath "{0}"' -f $PesterModulePath } |
|||
$console = Join-Path -Path $OutDir -ChildPath ('{0}.console.txt' -f $Label) |
|||
$commandLine = 'cmd.exe /d /s /c ""{0}" {1} > "{2}" 2>&1"' -f $Executable, $runnerArguments, $console |
|||
$exitCode = [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $Root) |
|||
Set-Content -LiteralPath (Join-Path -Path $OutDir -ChildPath ('{0}.basic.exit' -f $Label)) -Value $exitCode |
|||
exit $exitCode |
|||
'@ |
|||
$helperText = $helperHead + "`r`nAdd-Type -TypeDefinition @'`r`n" + $class + "`r`n'@`r`n" + $helperTail |
|||
Set-Content -LiteralPath (Join-Path -Path $stage -ChildPath 'Start-BasicUserProcess.ps1') -Value $helperText -Encoding UTF8 |
|||
} |
|||
$dllHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash |
|||
$testHashes = Get-ChildItem -LiteralPath (Join-Path -Path $stage -ChildPath 'Tests') -File | Sort-Object -Property Name | ForEach-Object -Process { '{0}={1}' -f $_.Name, (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.Substring(0, 12) } |
|||
($stamp -f [DateTime]::UtcNow) + " START localsuite-$Label machines=$($targets -join ',') editions=$($editions -join ',') dll=$dllHash" | Set-Content -LiteralPath $sequenceLog |
|||
Write-Sequence ('staged test files: {0}' -f ($testHashes -join ' ')) |
|||
$summaryRows = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
|
|||
if ($targets | Where-Object -FilterScript { $_ -ne 'LOCAL' }) { |
|||
Import-Module -Name AutomatedLab -ErrorAction Stop |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
} |
|||
|
|||
foreach ($name in $targets) { |
|||
$cellFolder = Join-Path -Path $OutputRoot -ChildPath "$Label-$name" |
|||
$null = New-Item -ItemType Directory -Path $cellFolder -Force |
|||
Write-Sequence "machine $name START" |
|||
$session = $null |
|||
$runCredential = $null |
|||
$resultsCopied = $false |
|||
try { |
|||
if ($name -eq 'LOCAL') { |
|||
$root = Join-Path -Path $env:TEMP -ChildPath "ntfs-localsuite-run-$Label" |
|||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } |
|||
Copy-Item -LiteralPath $stage -Destination $root -Recurse |
|||
} |
|||
else { |
|||
$sessionParameters = @{ ComputerName = $name } |
|||
if ($name -in $localCredential) { $sessionParameters.UseLocalCredential = $true } |
|||
$session = New-LabPSSession @sessionParameters |
|||
# The account for the scheduled tasks: the lab account of the machine, or its local installation account. AutomatedLab keeps the |
|||
# installation password in clear text in the lab file; here it stays in memory. |
|||
$machineDefinition = Get-LabVM -ComputerName $name |
|||
$runCredential = if ($name -in $localCredential) { |
|||
New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ('{0}\{1}' -f $name, $machineDefinition.InstallationUser.UserName), (ConvertTo-SecureString -String $machineDefinition.InstallationUser.Password -AsPlainText -Force) |
|||
} |
|||
else { |
|||
$machineDefinition.GetCredential((Get-Lab)) |
|||
} |
|||
|
|||
$root = 'C:\NtfsMatrixLocal\' + $Label |
|||
Invoke-Command -Session $session -ArgumentList $root -ScriptBlock { |
|||
param ($Path) |
|||
if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force } |
|||
$null = New-Item -ItemType Directory -Path $Path -Force |
|||
} |
|||
|
|||
Copy-Item -Path (Join-Path -Path $stage -ChildPath '*') -Destination $root -ToSession $session -Recurse -Force |
|||
Write-Sequence "machine $name stage copied to $root" |
|||
} |
|||
|
|||
foreach ($modeName in $modes) { |
|||
foreach ($editionName in $editions) { |
|||
$runLabel = ('{0}-{1}-{2}{3}' -f $Label, $name, $editionName, $(if ($modeName -eq 'Basic') { '-basic' } else { '' })).ToLowerInvariant() |
|||
$arguments = @{ Root = $root; Edition = $editionName; RunLabel = $runLabel; Pester = $(if ($name -eq 'LOCAL') { $PesterModulePath } else { '' }); Mode = $modeName } |
|||
$start = { |
|||
param ($Root, $Edition, $RunLabel, $Pester, $ModeName, $Credential) |
|||
$exe = if ($Edition -eq 'Desktop') { Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' } else { Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' } |
|||
$out = Join-Path -Path $Root -ChildPath 'Results' |
|||
$null = New-Item -ItemType Directory -Path $out -Force |
|||
if ($ModeName -eq 'Basic') { |
|||
# The basic-user token writes the results, so the account of the run needs Modify on the folder. |
|||
$null = & icacls.exe $out /grant ('{0}:(OI)(CI)M' -f [Security.Principal.WindowsIdentity]::GetCurrent().Name) |
|||
$list = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f (Join-Path -Path $Root -ChildPath 'Start-BasicUserProcess.ps1')), |
|||
'-Executable', ('"{0}"' -f $exe), '-Root', ('"{0}"' -f $Root), '-Label', $RunLabel, '-OutDir', ('"{0}"' -f $out)) |
|||
if ($Pester) { $list += '-PesterModulePath', ('"{0}"' -f $Pester) } |
|||
$exe = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' |
|||
} |
|||
else { |
|||
$list = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f (Join-Path -Path $Root -ChildPath 'Invoke-LocalSuite.ps1')), |
|||
'-Label', $RunLabel, '-Root', ('"{0}"' -f $Root), '-OutDir', ('"{0}"' -f $out)) |
|||
if ($Pester) { $list += '-PesterModulePath', ('"{0}"' -f $Pester) } |
|||
} |
|||
|
|||
if ($Credential) { |
|||
# A process started from a remoting session inherits a token with every privilege enabled and no credentials of its own, |
|||
# which the tests don't expect (eight of them fail). A scheduled task with a batch logon at the highest run level gets |
|||
# the token of an elevated interactive session: privileges present but disabled, and the credentials of the account. |
|||
$taskName = 'NtfsMatrixLocal-' + $RunLabel |
|||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue |
|||
$action = New-ScheduledTaskAction -Execute $exe -Argument ($list -join ' ') |
|||
$null = Register-ScheduledTask -TaskName $taskName -Action $action -RunLevel Highest -User $Credential.UserName -Password $Credential.GetNetworkCredential().Password |
|||
Start-ScheduledTask -TaskName $taskName |
|||
$taskName |
|||
} |
|||
else { |
|||
(Start-Process -FilePath $exe -ArgumentList $list -PassThru -WindowStyle Hidden).Id |
|||
} |
|||
} |
|||
$isRunning = { |
|||
param ($Handle) |
|||
if ($Handle -is [string]) { $task = Get-ScheduledTask -TaskName $Handle -ErrorAction SilentlyContinue; [bool] ($task -and $task.State -eq 'Running') } |
|||
else { [bool] (Get-Process -Id $Handle -ErrorAction SilentlyContinue) } |
|||
} |
|||
$stop = { |
|||
param ($Handle) |
|||
if ($Handle -is [string]) { Stop-ScheduledTask -TaskName $Handle -ErrorAction SilentlyContinue } else { Stop-Process -Id $Handle -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
$finish = { |
|||
param ($Handle) |
|||
if ($Handle -is [string]) { |
|||
$result = (Get-ScheduledTaskInfo -TaskName $Handle -ErrorAction SilentlyContinue).LastTaskResult |
|||
Unregister-ScheduledTask -TaskName $Handle -Confirm:$false -ErrorAction SilentlyContinue |
|||
"task result $result" |
|||
} |
|||
} |
|||
$startArguments = $arguments.Root, $arguments.Edition, $arguments.RunLabel, $arguments.Pester, $arguments.Mode, $runCredential |
|||
$handle = if ($session) { Invoke-Command -Session $session -ScriptBlock $start -ArgumentList $startArguments } else { & $start @startArguments } |
|||
Write-Sequence "machine $name $modeName $editionName started ($handle)" |
|||
$deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) |
|||
do { |
|||
Start-Sleep -Seconds 20 |
|||
$alive = if ($session) { Invoke-Command -Session $session -ScriptBlock $isRunning -ArgumentList $handle } else { & $isRunning $handle } |
|||
} while ($alive -and [DateTime]::UtcNow -lt $deadline) |
|||
if ($alive) { |
|||
if ($session) { Invoke-Command -Session $session -ScriptBlock $stop -ArgumentList $handle } else { & $stop $handle } |
|||
Write-Sequence "machine $name $modeName $editionName TIMED OUT after $TimeoutMinutes minutes; stopped" |
|||
} |
|||
|
|||
$outcome = if ($session) { Invoke-Command -Session $session -ScriptBlock $finish -ArgumentList $handle } else { & $finish $handle } |
|||
Write-Sequence "machine $name $modeName $editionName finished $outcome" |
|||
} |
|||
} |
|||
|
|||
if ($session) { Copy-Item -FromSession $session -Path (Join-Path -Path $root -ChildPath 'Results\*') -Destination $cellFolder -Recurse -Force } |
|||
else { Copy-Item -Path (Join-Path -Path $root -ChildPath 'Results\*') -Destination $cellFolder -Recurse -Force } |
|||
$resultsCopied = $true |
|||
foreach ($modeName in $modes) { |
|||
foreach ($editionName in $editions) { |
|||
$expected = ('{0}-{1}-{2}{3}' -f $Label, $name, $editionName, $(if ($modeName -eq 'Basic') { '-basic' } else { '' })).ToLowerInvariant() |
|||
if (-not (Test-Path -LiteralPath (Join-Path -Path $cellFolder -ChildPath "$expected.json"))) { |
|||
Write-Sequence "machine ${name}: NO RESULT FILE for $expected" |
|||
$summaryRows.Add([pscustomobject]@{ Machine = $name; Edition = $editionName; Os = ''; PowerShell = ''; Elevated = ''; Result = 'NoResult'; Passed = ''; Failed = ''; Skipped = ''; Total = ''; Seconds = '' }) |
|||
} |
|||
} |
|||
} |
|||
foreach ($json in Get-ChildItem -LiteralPath $cellFolder -Filter '*.json') { |
|||
$summary = Get-Content -LiteralPath $json.FullName -Raw | ConvertFrom-Json |
|||
$summaryRows.Add([pscustomobject]@{ |
|||
Machine = $name; Edition = $summary.Edition; Os = $summary.Os; PowerShell = $summary.PowerShell; Elevated = $summary.Elevated; Result = $summary.Result |
|||
Passed = $summary.Passed; Failed = $summary.Failed; Skipped = $summary.Skipped; Total = $summary.Total; Seconds = $summary.Seconds |
|||
}) |
|||
Write-Sequence ('machine {0} {1}: {2} passed={3} failed={4} skipped={5} total={6} elevated={7} os={8}' -f $name, $summary.Edition, $summary.Result, $summary.Passed, $summary.Failed, $summary.Skipped, $summary.Total, $summary.Elevated, $summary.Os) |
|||
} |
|||
} |
|||
catch { |
|||
Write-Sequence "machine $name FAILED: $_" |
|||
} |
|||
finally { |
|||
if ($session) { |
|||
# The tasks of this run store the password of the account that runs them. A run that stops early must not leave them on the machine. |
|||
try { |
|||
Invoke-Command -Session $session -ArgumentList ('NtfsMatrixLocal-{0}-*' -f $Label.ToLowerInvariant()) -ScriptBlock { |
|||
param ($Pattern) |
|||
Get-ScheduledTask -TaskName $Pattern -ErrorAction SilentlyContinue | ForEach-Object -Process { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue } |
|||
} |
|||
} |
|||
catch { |
|||
Write-Sequence "machine ${name}: the scheduled tasks of this run could not be removed: $($_.Exception.Message)" |
|||
} |
|||
|
|||
# The results are on the host, so the stage on the machine (the module, the tests, and the logs) is not needed any more. After an |
|||
# early stop it stays for the diagnosis. |
|||
if ($resultsCopied) { |
|||
try { |
|||
Invoke-Command -Session $session -ArgumentList $root -ScriptBlock { param ($Path) Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
catch { |
|||
Write-Sequence "machine ${name}: the stage $root could not be removed: $($_.Exception.Message)" |
|||
} |
|||
} |
|||
|
|||
Remove-PSSession -Session $session -ErrorAction SilentlyContinue |
|||
} |
|||
} |
|||
|
|||
Write-Sequence "machine $name END" |
|||
} |
|||
|
|||
$summaryRows | Export-Csv -LiteralPath (Join-Path -Path $OutputRoot -ChildPath "$Label-localsuite-summary.csv") -NoTypeInformation |
|||
Write-Sequence "localsuite-$Label-DONE" |
|||
exit 0 |
|||
@ -0,0 +1,105 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^[\w-]+$')] [string] $Label, |
|||
[Parameter(Mandatory)] [string] $FileServer, |
|||
[string] $Client = 'OSWin11E', |
|||
[string] $ModulePath, |
|||
[string] $Version, |
|||
[string] $Edition = 'Desktop,Core', |
|||
[Parameter(Mandatory)] [string] $OutputRoot, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string] $DomainController = 'OSDC1', |
|||
[string] $LabFolder, |
|||
[string] $Machines = 'OSFile19,OSFile22,OSFile25,OSWin11E' |
|||
) |
|||
|
|||
# One detached sequence of the operating-system matrix (Decision 24) in Windows PowerShell 5.1 on the Hyper-V host. For each cell, a file |
|||
# server with the client, it runs: readiness of every machine, the unmodified controller of the repository for the source (a build |
|||
# folder or an exact Gallery version) in both editions, the validation of every role from the result files, a snapshot of the fixture |
|||
# SIDs, the removal of the fixture, and an independent check of the end state. An infrastructure failure (no summary of the controller) |
|||
# stops the later cells; failing tests don't. Case 9 (accounts of other forests) needs trusts that this lab doesn't have, so the cells |
|||
# run with -ForeignDomainController @(). Nothing secret is written: the controller keeps the passwords in memory. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
# Windows PowerShell 5.1 leaves $PSScriptRoot empty in a parameter default when the script runs with -File. |
|||
if (-not $LabFolder) { $LabFolder = Split-Path -Path $PSScriptRoot -Parent } |
|||
$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' |
|||
$kit = $PSScriptRoot |
|||
$cells = @($FileServer -split ',' | Where-Object -FilterScript { $_ }) |
|||
$editions = @($Edition -split ',' | Where-Object -FilterScript { $_ }) |
|||
$allMachines = @($Machines -split ',' | Where-Object -FilterScript { $_ }) |
|||
if ([bool] $ModulePath -eq [bool] $Version) { throw 'Pass exactly one of -ModulePath and -Version.' } |
|||
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null |
|||
$sequenceLog = Join-Path -Path $OutputRoot -ChildPath "$Label-sequence.log" |
|||
function Write-Sequence { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $sequenceLog } |
|||
$source = if ($ModulePath) { "module=$ModulePath dll=$((Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.dll')).Hash)" } else { "version=$Version" } |
|||
($stamp -f [DateTime]::UtcNow) + " START matrix-sequence-$Label client=$Client cells=$($cells -join ',') editions=$($editions -join ',') $source" | Set-Content -LiteralPath $sequenceLog |
|||
Write-Sequence ('controller blob {0}' -f ((& git -C (Split-Path -Path (Split-Path -Path $LabFolder -Parent) -Parent) hash-object (Join-Path -Path $LabFolder -ChildPath 'Invoke-NTFSSecurityLabTest.ps1')) -join '')) |
|||
Write-Sequence ('live tests blob {0}' -f ((& git -C (Split-Path -Path (Split-Path -Path $LabFolder -Parent) -Parent) hash-object (Join-Path -Path $LabFolder -ChildPath 'NTFSSecurity.Live.Tests.ps1')) -join '')) |
|||
$controller = Join-Path -Path $LabFolder -ChildPath 'Invoke-NTFSSecurityLabTest.ps1' |
|||
$infrastructureFailed = $false |
|||
|
|||
foreach ($fileServerName in $cells) { |
|||
if ($infrastructureFailed) { Write-Sequence "cell $fileServerName SKIPPED after an infrastructure failure"; continue } |
|||
$cell = Join-Path -Path $OutputRoot -ChildPath "$Label-$fileServerName" |
|||
New-Item -ItemType Directory -Path $cell -Force | Out-Null |
|||
$runLog = Join-Path -Path $cell -ChildPath 'run.log' |
|||
$ran = $false |
|||
Write-Sequence "cell $fileServerName START (client $Client)" |
|||
try { |
|||
$readinessLog = Join-Path -Path $cell -ChildPath 'readiness.log' |
|||
& (Join-Path -Path $kit -ChildPath 'Test-MatrixReadiness.ps1') -LabName $LabName -DomainController @($DomainController) -Member @($allMachines) -OutFile $readinessLog |
|||
$readiness = Get-Content -LiteralPath $readinessLog -Raw |
|||
$notReady = 'wsman=failed|secure channel False|PowerShell 7 missing|Core missing|Pester Desktop (?!5\.7\.1)|=False|kerberos: .*Error' |
|||
$problem = [regex]::Match($readiness, $notReady).Value |
|||
if ($readiness -notmatch 'matrix-readiness-DONE' -or $problem) { throw "Readiness of cell $fileServerName failed ('$problem'); see $readinessLog" } |
|||
Write-Sequence "cell $fileServerName readiness ok" |
|||
|
|||
$arguments = @{ |
|||
LabName = $LabName; DomainController = $DomainController; FileServer = $fileServerName; Client = $Client |
|||
ForeignDomainController = @(); Edition = $editions; OutputPath = $cell; Confirm = $false |
|||
} |
|||
if ($ModulePath) { $arguments.Version = @(); $arguments.ModulePath = $ModulePath } else { $arguments.Version = @($Version) } |
|||
($stamp -f [DateTime]::UtcNow) + " START controller cell=$fileServerName" | Set-Content -LiteralPath $runLog |
|||
$ran = $true |
|||
& { & $controller @arguments } *>&1 | ForEach-Object -Process { '{0}' -f $_ } | Out-File -LiteralPath $runLog -Append -Encoding utf8 -Width 500 |
|||
$summaryPath = Get-ChildItem -LiteralPath (Join-Path -Path $cell -ChildPath 'Results') -Filter 'Summary.json' -Recurse -ErrorAction SilentlyContinue | |
|||
Sort-Object -Property LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName |
|||
if (-not $summaryPath) { throw "The controller wrote no Summary.json for cell $fileServerName; see $runLog" } |
|||
Write-Sequence "cell $fileServerName controller done: $summaryPath" |
|||
|
|||
$validationLog = Join-Path -Path $cell -ChildPath 'validation.log' |
|||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path -Path $kit -ChildPath 'Validate-LabResults.ps1') -ResultsFolder (Split-Path -Path $summaryPath -Parent) -OutputPrefix (Join-Path -Path $cell -ChildPath "$Label-$fileServerName") -Edition ($editions -join ',') -Expect Candidate *>&1 | |
|||
Out-File -LiteralPath $validationLog -Encoding utf8 -Width 400 |
|||
Write-Sequence "cell $fileServerName validation exit code $LASTEXITCODE (see validation.log)" |
|||
} |
|||
catch { |
|||
Write-Sequence "cell $fileServerName FAILED before the cleanup: $_" |
|||
if (-not $ran) { Write-Sequence "cell ${fileServerName}: the controller did not start" } |
|||
$infrastructureFailed = $true |
|||
} |
|||
|
|||
try { |
|||
$sidFile = Join-Path -Path $cell -ChildPath 'fixture-sids.json' |
|||
$common = @{ LabName = $LabName; DomainController = @($DomainController); Machine = @($allMachines) } |
|||
if ($ran) { |
|||
& (Join-Path -Path $kit -ChildPath 'Test-MatrixCleanup.ps1') -Mode Snapshot -SidFile $sidFile -OutFile (Join-Path -Path $cell -ChildPath 'cleanup-1-snapshot.log') @common |
|||
& { & $controller -RemoveFixture -LabName $LabName -DomainController $DomainController -FileServer $fileServerName -Client $Client -ForeignDomainController @() -Confirm:$false } *>&1 | |
|||
ForEach-Object -Process { '{0}' -f $_ } | Out-File -LiteralPath (Join-Path -Path $cell -ChildPath 'cleanup-2-remove.log') -Encoding utf8 -Width 500 |
|||
& (Join-Path -Path $kit -ChildPath 'Test-MatrixCleanup.ps1') -Mode Verify -SidFile $sidFile -OutFile (Join-Path -Path $cell -ChildPath 'cleanup-3-verify.log') @common |
|||
$verify = Get-Content -LiteralPath (Join-Path -Path $cell -ChildPath 'cleanup-3-verify.log') -Raw |
|||
$clean = ($verify -match 'matrix-cleanup-Verify-DONE') -and ($verify -notmatch 'OU NTFSSecurityLive: True') -and ($verify -notmatch 'accounts: NtfsLive') -and |
|||
($verify -notmatch 'share=True|C:\\NTFSSecurityLive=True|C:\\NTFSSecurityLab=True|NtfsLiveLocal=True') -and ($verify -notmatch 'profiles=[1-9]') -and ($verify -notmatch ': [1-9]\d* fixture member') -and |
|||
($verify -notmatch 'probe accounts: [1-9]') -and ($verify -notmatch 'residue: [^\r\n]*=[1-9]') |
|||
Write-Sequence ("cell $fileServerName cleanup verdict from the verify log: {0}" -f $(if ($clean) { 'CLEAN' } else { 'DIRTY (read cleanup-3-verify.log)' })) |
|||
} |
|||
} |
|||
catch { |
|||
Write-Sequence "cell $fileServerName cleanup FAILED: $_" |
|||
} |
|||
|
|||
Write-Sequence "cell $fileServerName END" |
|||
} |
|||
|
|||
Write-Sequence "matrix-sequence-$Label-DONE" |
|||
exit 0 |
|||
@ -0,0 +1,190 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidateSet('Snapshot', 'Verify', 'Repair')] [string] $Mode, |
|||
[Parameter(Mandatory)] [string] $SidFile, |
|||
[Parameter(Mandatory)] [string] $OutFile, |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string[]] $DomainController = @('OSDC1'), |
|||
[string[]] $Machine = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E') |
|||
) |
|||
|
|||
# Independent end-state check of the fixture of Invoke-NTFSSecurityLabTest.ps1 in a lab (Windows PowerShell 5.1, on the host). Snapshot |
|||
# records the SIDs of the NtfsLive* accounts while the fixture exists. Verify reads the domains and every machine again, and reports |
|||
# the organizational unit, the accounts, the share, the folders, the local group, the memberships of Administrators, Access Control |
|||
# Assistance Operators, and Remote Management Users, and the profiles of those SIDs, and what the suite runs and the probes of the kit leave |
|||
# behind (scheduled tasks, items in the stage folders, the folders of the account probe, standard users NtfsProbe* with their profiles and their |
|||
# entries in Performance Log Users, probe accounts of the domain). The result is judged from this log, never from the wrapper of the controller |
|||
# or a global error count. Repair is for a run whose removal failed: with the SIDs of the snapshot, it removes what that run left on the machines |
|||
# (the memberships, also of orphaned SIDs, which net localgroup deletes by SID; the share; the local group; the folders) and what the kit leaves |
|||
# (the items in the stage folders, the folders of the account probe, the scheduled tasks NtfsMatrix*, the standard users NtfsProbe* with their |
|||
# profiles and their entries in Performance Log Users, and the domain accounts NtfsProbe*), and then reports like Verify. The patterns are the |
|||
# prefixes of the kit, matched in the whole domain and on the whole machine, not only in the organizational unit and the folders of the kit: |
|||
# every AD object whose sAMAccountName starts with NtfsProbe (a computer account too), the NtfsLive* objects of the domain (their SIDs go to the |
|||
# snapshot), every local-group member whose name contains NtfsLive, every scheduled task NtfsMatrix*, every local user NtfsProbe* and its profile |
|||
# folder, and every unresolved S-1-5-21-* member of Performance Log Users (a real principal of a trust that is down shows as one). The probe is the |
|||
# only writer of that group in these labs and uses the same pattern in its own cleanup. Run Repair only in a lab where nothing else has these |
|||
# names or leaves such members: Verify reports them, and Repair removes them. |
|||
& { |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$DomainController = @($DomainController | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$Machine = @($Machine | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] START matrix-cleanup-{1} lab={2}' -f [DateTime]::UtcNow, $Mode, $LabName |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$labCommand = @{ NoDisplay = $true; PassThru = $true; ErrorAction = 'Stop' } |
|||
if ($Mode -eq 'Repair') { |
|||
# The accounts that the probes of the kit create in the domain, by their prefix; this runs before the directory is read, so that the report shows the result. |
|||
$repairDirectoryScript = { |
|||
Import-Module -Name ActiveDirectory |
|||
$domain = Get-ADDomain |
|||
$objects = @(Get-ADObject -LDAPFilter '(sAMAccountName=NtfsProbe*)' -SearchBase $domain.DistinguishedName -Server $domain.PDCEmulator) |
|||
foreach ($object in $objects) { Remove-ADObject -Identity $object -Recursive -Confirm:$false -Server $domain.PDCEmulator } |
|||
'{0}: removed {1} account(s) named NtfsProbe*' -f $domain.DNSRoot, $objects.Count |
|||
} |
|||
|
|||
foreach ($name in $DomainController) { |
|||
foreach ($message in @(Invoke-LabCommand -ComputerName $name -ActivityName "Repair the directory of $name" -ScriptBlock $repairDirectoryScript @labCommand)) { |
|||
'{0,-9} repair: {1}' -f $name, $message |
|||
} |
|||
} |
|||
} |
|||
|
|||
$directoryScript = { |
|||
Import-Module -Name ActiveDirectory |
|||
$domain = Get-ADDomain |
|||
$unit = Get-ADOrganizationalUnit -LDAPFilter '(ou=NTFSSecurityLive)' -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $domain.PDCEmulator |
|||
[pscustomobject]@{ |
|||
Domain = $domain.DNSRoot |
|||
Unit = [bool] $unit |
|||
Sids = @(Get-ADObject -LDAPFilter '(sAMAccountName=NtfsLive*)' -SearchBase $domain.DistinguishedName -Server $domain.PDCEmulator -Properties objectSid, sAMAccountName | |
|||
ForEach-Object -Process { '{0}={1}' -f $_.sAMAccountName, $_.objectSid.Value }) |
|||
ProbeAccounts = @(Get-ADObject -LDAPFilter '(sAMAccountName=NtfsProbe*)' -SearchBase $domain.DistinguishedName -Server $domain.PDCEmulator).Count |
|||
} |
|||
} |
|||
|
|||
$directory = @(foreach ($name in $DomainController) { Invoke-LabCommand -ComputerName $name -ActivityName "Read the fixture of $name" -ScriptBlock $directoryScript @labCommand }) |
|||
foreach ($state in $directory) { |
|||
'{0,-14} OU NTFSSecurityLive: {1,-5} NtfsLive* accounts: {2}; probe accounts: {3}' -f $state.Domain, $state.Unit, ($(if ($state.Sids) { $state.Sids -join ', ' } else { 'none' })), $state.ProbeAccounts |
|||
} |
|||
|
|||
if ($Mode -eq 'Snapshot') { |
|||
$sids = @($directory | ForEach-Object -Process { $_.Sids } | ForEach-Object -Process { ($_ -split '=', 2)[1] }) |
|||
ConvertTo-Json -InputObject $sids | Set-Content -LiteralPath $SidFile -Encoding utf8 |
|||
"saved $($sids.Count) SIDs to $SidFile" |
|||
} |
|||
else { |
|||
$sids = [string[]] (Get-Content -LiteralPath $SidFile -Raw | ConvertFrom-Json) |
|||
"checking $($sids.Count) SIDs of the snapshot" |
|||
$machineScript = { |
|||
param ($Sid) |
|||
# net localgroup lists an orphaned SID, which Get-LocalGroupMember in Windows PowerShell 5.1 fails on and skips. |
|||
$groups = foreach ($groupSid in 'S-1-5-32-544', 'S-1-5-32-579', 'S-1-5-32-580') { |
|||
$groupName = ([System.Security.Principal.SecurityIdentifier] $groupSid).Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', '' |
|||
$members = @(& net.exe localgroup $groupName 2>&1 | ForEach-Object -Process { "$_".Trim() }) |
|||
$hits = @($members | Where-Object -FilterScript { $_ -match 'NtfsLive' -or $_ -in $Sid }) |
|||
'{0}: {1} fixture member(s)' -f $groupSid, $hits.Count |
|||
} |
|||
|
|||
# What the account probe leaves: the profiles and the profile folders of its users, and its entries in Performance Log Users. net.exe lists a |
|||
# local user by its bare name, and an entry of a deleted domain account as its SID, or as its name for a while (the cache of names). |
|||
$usersFolder = Join-Path -Path $env:SystemDrive -ChildPath 'Users' |
|||
$probePaths = @(@(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.LocalPath -like (Join-Path -Path $usersFolder -ChildPath 'NtfsProbe*') } | ForEach-Object -Process { $_.LocalPath }) + |
|||
@(Get-ChildItem -LiteralPath $usersFolder -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue | ForEach-Object -Process { $_.FullName }) | Sort-Object -Unique) |
|||
$logGroup = ([System.Security.Principal.SecurityIdentifier] 'S-1-5-32-559').Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', '' |
|||
$probeMembers = @(& net.exe localgroup $logGroup 2>&1 | ForEach-Object -Process { "$_".Trim() } | Where-Object -FilterScript { $_ -match '^S-1-5-21-[\d-]+$' -or $_ -match 'NtfsProbe' }) |
|||
|
|||
[pscustomobject]@{ |
|||
Share = [bool] (Get-SmbShare -Name 'NTFSSecurityLive' -ErrorAction SilentlyContinue) |
|||
ShareRoot = Test-Path -LiteralPath 'C:\NTFSSecurityLive' |
|||
Payload = Test-Path -LiteralPath 'C:\NTFSSecurityLab' |
|||
LocalGroup = [bool] (Get-LocalGroup -Name 'NtfsLiveLocal' -ErrorAction SilentlyContinue) |
|||
Groups = $groups -join '; ' |
|||
Profiles = @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid }).Count |
|||
# What the suite runs and the probes of the kit leave behind: scheduled tasks, items in the stage folders, the folders of the |
|||
# account probe, and standard users |
|||
Tasks = @(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.TaskName -like 'NtfsMatrix*' }).Count |
|||
Stages = @('C:\NtfsMatrixLocal', 'C:\NtfsMatrixProbe' | Where-Object -FilterScript { Test-Path -LiteralPath $_ } | ForEach-Object -Process { Get-ChildItem -LiteralPath $_ -Force -ErrorAction SilentlyContinue }).Count + |
|||
@('C:\NtfsProbeRecreation', 'C:\NtfsProbeModules' | Where-Object -FilterScript { Test-Path -LiteralPath $_ }).Count |
|||
Users = @(Get-LocalUser -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.Name -like 'NtfsProbe*' }).Count |
|||
ProbeProfiles = $probePaths.Count |
|||
ProbeMembers = $probeMembers.Count |
|||
} |
|||
} |
|||
|
|||
foreach ($name in $Machine) { |
|||
if ($Mode -eq 'Repair') { |
|||
$repairScript = { |
|||
param ($Sid) |
|||
$messages = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
foreach ($groupSid in 'S-1-5-32-544', 'S-1-5-32-579', 'S-1-5-32-580') { |
|||
$groupName = ([System.Security.Principal.SecurityIdentifier] $groupSid).Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', '' |
|||
$named = @(& net.exe localgroup $groupName 2>&1 | ForEach-Object -Process { "$_".Trim() } | Where-Object -FilterScript { $_ -match 'NtfsLive' }) |
|||
foreach ($member in @($Sid) + $named) { $null = & net.exe localgroup $groupName $member /delete 2>&1 } |
|||
} |
|||
|
|||
if (Get-SmbShare -Name 'NTFSSecurityLive' -ErrorAction SilentlyContinue) { Remove-SmbShare -Name 'NTFSSecurityLive' -Force } |
|||
$null = & net.exe localgroup 'NtfsLiveLocal' /delete 2>&1 |
|||
foreach ($path in 'C:\NTFSSecurityLive', 'C:\NTFSSecurityLab') { |
|||
$command = '$errors = @(); Remove-Item -LiteralPath ''__PATH__'' -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $path) |
|||
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) |
|||
$attempt = 0 |
|||
while ((Test-Path -LiteralPath $path) -and $attempt -lt 6) { |
|||
$attempt++ |
|||
if ($attempt -gt 1) { Start-Sleep -Seconds 5 } |
|||
$null = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 |
|||
} |
|||
|
|||
$messages.Add(('{0}: present after {1} attempt(s): {2}' -f $path, $attempt, (Test-Path -LiteralPath $path))) |
|||
} |
|||
|
|||
# What the suite runner and the probes of the kit left: the items in their stage folders, the folders of the account probe, |
|||
# their scheduled tasks, and the standard users that the probe of the authorization managers creates (with their profiles) |
|||
foreach ($stage in 'C:\NtfsMatrixLocal', 'C:\NtfsMatrixProbe') { |
|||
if (Test-Path -LiteralPath $stage) { Get-ChildItem -LiteralPath $stage -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
|
|||
foreach ($folder in 'C:\NtfsProbeRecreation', 'C:\NtfsProbeModules') { |
|||
if (Test-Path -LiteralPath $folder) { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue } |
|||
} |
|||
|
|||
Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.TaskName -like 'NtfsMatrix*' } | ForEach-Object -Process { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue } |
|||
foreach ($user in @(Get-LocalUser -ErrorAction SilentlyContinue | Where-Object -FilterScript { $_.Name -like 'NtfsProbe*' })) { Remove-LocalUser -SID $user.SID -ErrorAction SilentlyContinue } |
|||
|
|||
# The entries of the probe in Performance Log Users go by SID or name through the cmdlet: net.exe doesn't take the SID of an account that its name cache still resolves. |
|||
$logGroup = ([System.Security.Principal.SecurityIdentifier] 'S-1-5-32-559').Translate([System.Security.Principal.NTAccount]).Value -replace '^.*\\', '' |
|||
foreach ($member in @(& net.exe localgroup $logGroup 2>&1 | ForEach-Object -Process { "$_".Trim() } | Where-Object -FilterScript { $_ -match '^S-1-5-21-[\d-]+$' -or $_ -match 'NtfsProbe' })) { |
|||
Remove-LocalGroupMember -SID 'S-1-5-32-559' -Member $member -ErrorAction SilentlyContinue |
|||
} |
|||
|
|||
# A profile that the last task of a probe user used stays loaded for a few seconds, so the removal is repeated. What stays is |
|||
# reported by the check that follows, found by its folder and not by its user, who is gone by now. |
|||
$usersFolder = Join-Path -Path $env:SystemDrive -ChildPath 'Users' |
|||
$attempt = 0 |
|||
do { |
|||
$attempt++ |
|||
@(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.LocalPath -like (Join-Path -Path $usersFolder -ChildPath 'NtfsProbe*') }) | Remove-CimInstance -ErrorAction SilentlyContinue |
|||
Get-ChildItem -LiteralPath $usersFolder -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue |
|||
$left = @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.LocalPath -like (Join-Path -Path $usersFolder -ChildPath 'NtfsProbe*') }).Count + |
|||
@(Get-ChildItem -LiteralPath $usersFolder -Filter 'NtfsProbe*' -Force -ErrorAction SilentlyContinue).Count |
|||
if ($left -gt 0 -and $attempt -lt 10) { Start-Sleep -Seconds 3 } |
|||
} while ($left -gt 0 -and $attempt -lt 10) |
|||
|
|||
$messages.Add(('stage items, probe folders, probe users, their entries in the log group, and scheduled tasks of the kit removed; profile items left: {0} after {1} attempt(s)' -f $left, $attempt)) |
|||
|
|||
$messages |
|||
} |
|||
|
|||
foreach ($message in @(Invoke-LabCommand -ComputerName $name -ActivityName "Repair $name" -ScriptBlock $repairScript -ArgumentList (, $sids) @labCommand)) { |
|||
'{0,-9} repair: {1}' -f $name, $message |
|||
} |
|||
} |
|||
|
|||
$state = Invoke-LabCommand -ComputerName $name -ActivityName "Check $name" -ScriptBlock $machineScript -ArgumentList (, $sids) @labCommand |
|||
'{0,-9} share={1} C:\NTFSSecurityLive={2} C:\NTFSSecurityLab={3} NtfsLiveLocal={4} profiles={5}' -f $name, $state.Share, $state.ShareRoot, $state.Payload, $state.LocalGroup, $state.Profiles |
|||
' {0}' -f $state.Groups |
|||
' residue: scheduled tasks={0} stage items={1} probe users={2} probe profiles={3} probe group members={4}' -f $state.Tasks, $state.Stages, $state.Users, $state.ProbeProfiles, $state.ProbeMembers |
|||
} |
|||
} |
|||
|
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] matrix-cleanup-{1}-DONE' -f [DateTime]::UtcNow, $Mode |
|||
} *>&1 | Out-File -FilePath $OutFile -Encoding utf8 -Width 400 |
|||
@ -0,0 +1,79 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[string] $LabName = 'NtfsSecurityOsMatrixLab', |
|||
[string[]] $DomainController = @('OSDC1'), |
|||
[string[]] $Member = @('OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E'), |
|||
[Parameter(Mandatory)] [string] $OutFile |
|||
) |
|||
|
|||
# Readiness and identity of the machines of an AutomatedLab lab for the live tests of NTFSSecurity, in Windows PowerShell 5.1 on the |
|||
# Hyper-V host. It proves what the tests need, not that a VM runs: authenticated WinRM through AutomatedLab, the operating system |
|||
# build, the domain, the clock against the host, LDAP and a Kerberos ticket (a domain controller), or the secure channel, the domain |
|||
# controller locator and a service ticket for a peer (a member), the PowerShell 7 and Pester payloads, and the ports of SMB, RPC, and |
|||
# WinRM from the host. Nothing is changed in the lab. Passwords are never read or printed. |
|||
& { |
|||
$ErrorActionPreference = 'Stop' |
|||
# -File passes an array as one string, so a list may arrive as 'A,B'. |
|||
$DomainController = @($DomainController | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] START matrix-readiness lab={1}' -f [DateTime]::UtcNow, $LabName |
|||
Import-Lab -Name $LabName -NoValidation -NoDisplay |
|||
$labCommand = @{ NoDisplay = $true; PassThru = $true; ErrorAction = 'Stop' } |
|||
$everyMachine = @($DomainController) + @($Member) |
|||
$peerByMember = @{} |
|||
foreach ($name in $Member) { $peerByMember[$name] = @($Member | Where-Object -FilterScript { $_ -ne $name })[0] } |
|||
foreach ($name in $everyMachine) { |
|||
$address = (Get-LabVM -ComputerName $name).IpV4Address |
|||
$wsman = try { $null = Test-WSMan -ComputerName $address -ErrorAction Stop; 'ok' } catch { "failed: $($_.Exception.Message)" } |
|||
$ports = foreach ($port in 135, 445, 5985) { |
|||
$client = New-Object -TypeName 'System.Net.Sockets.TcpClient' |
|||
try { $open = $client.ConnectAsync($address, $port).Wait(3000) } catch { $open = $false } finally { $client.Dispose() } |
|||
'{0}={1}' -f $port, $open |
|||
} |
|||
|
|||
$hostUtc = [DateTime]::UtcNow |
|||
$state = Invoke-LabCommand -ComputerName $name -ActivityName "Readiness of $name" -ScriptBlock { |
|||
param ([bool] $IsDomainController, [string] $Peer) |
|||
$os = Get-CimInstance -ClassName Win32_OperatingSystem |
|||
$computer = Get-CimInstance -ClassName Win32_ComputerSystem |
|||
$version = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' |
|||
$dotNet = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -ErrorAction SilentlyContinue).Release |
|||
$pwshPath = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' |
|||
$result = [ordered]@{ |
|||
Os = '{0} {1}.{2}' -f $os.Caption, $os.Version, $version.UBR |
|||
ProductType = $os.ProductType |
|||
Edition = $version.EditionID |
|||
Domain = $computer.Domain |
|||
Utc = [DateTime]::UtcNow |
|||
DotNet = $dotNet |
|||
WindowsPowerShell = $PSVersionTable.PSVersion.ToString() |
|||
PowerShell7 = $(if (Test-Path -LiteralPath $pwshPath) { (Get-Item -LiteralPath $pwshPath).VersionInfo.ProductVersion } else { 'missing' }) |
|||
PesterDesktop = $(@(Get-Module -Name Pester -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1 | ForEach-Object -Process { $_.Version.ToString() }) -join '') |
|||
PesterCore = $(if (Test-Path -LiteralPath (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\Modules\Pester\5.7.1\Pester.psd1')) { '5.7.1' } else { 'missing' }) |
|||
Ldap = '' |
|||
Channel = '' |
|||
Kerberos = '' |
|||
} |
|||
if ($IsDomainController) { |
|||
$rootDse = [adsi]'LDAP://RootDSE' |
|||
$result.Ldap = 'RootDSE {0}, synchronized {1}' -f $rootDse.dnsHostName.Value, $rootDse.isSynchronized.Value |
|||
$result.Kerberos = (& klist.exe get "krbtgt/$($computer.Domain)" 2>&1 | Select-String -Pattern 'Error|Server: krbtgt' | Select-Object -First 1).Line |
|||
} |
|||
else { |
|||
$result.Ldap = (& nltest.exe "/dsgetdc:$($computer.Domain)" 2>&1 | Select-String -Pattern '^\s*DC: |ERROR' | Select-Object -First 1).Line |
|||
$result.Channel = 'secure channel {0}' -f (Test-ComputerSecureChannel) |
|||
$result.Kerberos = (& klist.exe get "host/$Peer.$($computer.Domain)" 2>&1 | Select-String -Pattern 'Error|Server: host' | Select-Object -First 1).Line |
|||
} |
|||
|
|||
[pscustomobject] $result |
|||
} -ArgumentList ($name -in $DomainController), $peerByMember[$name] @labCommand |
|||
$skew = [Math]::Round(($state.Utc - $hostUtc).TotalSeconds, 1) |
|||
'{0,-9} wsman={1} ports({2}) os={3} type={4} edition={5} domain={6} skew={7}s' -f $name, $wsman, ($ports -join ' '), $state.Os, $state.ProductType, $state.Edition, $state.Domain, $skew |
|||
' .NET release={0}; Windows PowerShell {1}; PowerShell 7 {2}; Pester Desktop {3}, Core {4}' -f $state.DotNet, $state.WindowsPowerShell, $state.PowerShell7, $state.PesterDesktop, $state.PesterCore |
|||
' ldap: {0}' -f ("$($state.Ldap)".Trim()) |
|||
if ($state.Channel) { ' {0}' -f $state.Channel } |
|||
' kerberos: {0}' -f ("$($state.Kerberos)".Trim()) |
|||
} |
|||
|
|||
'[{0:yyyy-MM-dd HH:mm:ss}Z] matrix-readiness-DONE' -f [DateTime]::UtcNow |
|||
} *>&1 | Out-File -FilePath $OutFile -Encoding utf8 -Width 400 |
|||
@ -0,0 +1,111 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z]+)?$')] [string] $Version, |
|||
[Parameter(Mandatory)] [string] $OutputPath, |
|||
[string] $Repository = 'raandree/NTFSSecurity' |
|||
) |
|||
|
|||
# Read-only identity check of a published NTFSSecurity version (acceptance of a published candidate): the tag, its commit on master, the CI run of the |
|||
# tag, the GitHub release asset, and the PowerShell Gallery package. It downloads the nupkg and the zip into OutputPath, checks the |
|||
# SHA-512 that the Gallery publishes (ordinal, case-sensitive base64), extracts both with System.IO.Compression, and compares the |
|||
# module files byte for byte. It writes Identity.json and prints a table; it changes nothing on GitHub or in the Gallery, and |
|||
# it never imports the module. Exit code 1 for any mismatch. |
|||
$ErrorActionPreference = 'Stop' |
|||
$ProgressPreference = 'SilentlyContinue' |
|||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 |
|||
Add-Type -AssemblyName System.IO.Compression.FileSystem |
|||
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null |
|||
$headers = @{ 'User-Agent' = 'ntfssecurity-published-identity-check'; Accept = 'application/vnd.github+json' } |
|||
$api = "https://api.github.com/repos/$Repository" |
|||
$problems = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
$result = [ordered]@{ Version = $Version; CheckedUtc = [DateTime]::UtcNow.ToString('o') } |
|||
|
|||
# 1. The tag and its commit |
|||
$ref = Invoke-RestMethod -Uri "$api/git/ref/tags/$Version" -Headers $headers |
|||
$sha = $ref.object.sha |
|||
if ($ref.object.type -eq 'tag') { $sha = (Invoke-RestMethod -Uri "$api/git/tags/$sha" -Headers $headers).object.sha } |
|||
$result.TagCommit = $sha |
|||
$compare = Invoke-RestMethod -Uri "$api/compare/master...$sha" -Headers $headers |
|||
$result.CommitOnMaster = ($compare.status -in 'identical', 'behind') |
|||
$result.CompareStatus = $compare.status |
|||
if (-not $result.CommitOnMaster) { $problems.Add("The commit $sha of the tag isn't on master (compare status: $($compare.status)).") } |
|||
|
|||
# 2. The CI run of the tag: the tag push has the tag as its branch name |
|||
$runs = @((Invoke-RestMethod -Uri "$api/actions/runs?head_sha=$sha&per_page=30" -Headers $headers).workflow_runs | Where-Object -FilterScript { $_.event -eq 'push' -and $_.head_branch -eq $Version }) |
|||
if ($runs.Count -eq 0) { $problems.Add("No CI run of the tag push for $Version.") } |
|||
$jobs = @() |
|||
foreach ($run in ($runs | Sort-Object -Property run_number)) { |
|||
$jobs += @((Invoke-RestMethod -Uri "$api/actions/runs/$($run.id)/jobs?per_page=50" -Headers $headers).jobs | ForEach-Object -Process { |
|||
[pscustomobject]@{ Run = $run.id; Attempt = $run.run_attempt; Job = $_.name; Status = $_.status; Conclusion = $_.conclusion } |
|||
}) |
|||
} |
|||
$result.CiJobs = $jobs |
|||
$latestRelease = @($jobs | Where-Object -FilterScript { $_.Job -match 'Release' } | Sort-Object -Property Attempt | Select-Object -Last 1) |
|||
if ($latestRelease.Count -eq 0 -or $latestRelease[0].Conclusion -ne 'success') { $problems.Add('The latest Release job of the tag did not succeed.') } |
|||
|
|||
# 3. The GitHub release and its zip |
|||
$release = Invoke-RestMethod -Uri "$api/releases/tags/$Version" -Headers $headers |
|||
$asset = @($release.assets | Where-Object -FilterScript { $_.name -eq 'NTFSSecurity.zip' }) | Select-Object -First 1 |
|||
if (-not $asset) { throw "The release $Version has no NTFSSecurity.zip." } |
|||
$zipPath = Join-Path -Path $OutputPath -ChildPath "NTFSSecurity-$Version.zip" |
|||
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zipPath -UseBasicParsing |
|||
$zipSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $zipPath).Hash |
|||
$result.Release = [ordered]@{ Prerelease = $release.prerelease; Published = $release.published_at; AssetSize = $asset.size; AssetDigest = $asset.digest; ZipSha256 = $zipSha256 } |
|||
if ($asset.digest -and $asset.digest -like 'sha256:*' -and ($asset.digest.Substring(7) -ne $zipSha256.ToLowerInvariant())) { $problems.Add('The SHA-256 of the downloaded zip differs from the digest of the release asset.') } |
|||
|
|||
# 4. The PowerShell Gallery package; the published hash is base64 of SHA-512 |
|||
$entry = Invoke-RestMethod -Uri ("https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='{0}')" -f $Version) |
|||
$published = $entry.entry.properties.PackageHash.'#text' |
|||
if (-not $published) { $published = [string] $entry.entry.properties.PackageHash } |
|||
$algorithm = $entry.entry.properties.PackageHashAlgorithm |
|||
if (-not $published -or $algorithm -ne 'SHA512') { throw "The Gallery has no SHA512 hash for NTFSSecurity $Version (algorithm '$algorithm')." } |
|||
$nupkgPath = Join-Path -Path $OutputPath -ChildPath "NTFSSecurity.$Version.nupkg" |
|||
Invoke-WebRequest -Uri "https://www.powershellgallery.com/api/v2/package/NTFSSecurity/$Version" -OutFile $nupkgPath -UseBasicParsing |
|||
$sha512 = [System.Security.Cryptography.SHA512]::Create() |
|||
$stream = [System.IO.File]::OpenRead($nupkgPath) |
|||
try { $actual = [Convert]::ToBase64String($sha512.ComputeHash($stream)) } finally { $stream.Dispose(); $sha512.Dispose() } |
|||
$hashMatches = [string]::Equals($actual, $published, [StringComparison]::Ordinal) |
|||
$result.Gallery = [ordered]@{ Published = $entry.entry.properties.Published.'#text'; IsPrerelease = $entry.entry.properties.IsPrerelease.'#text'; PackageHashAlgorithm = $algorithm; PackageHash = $published; DownloadedSha512 = $actual; HashMatches = $hashMatches; NupkgSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $nupkgPath).Hash } |
|||
if (-not $hashMatches) { $problems.Add('The downloaded nupkg does not have the SHA-512 that the Gallery publishes.') } |
|||
|
|||
# 5. The module files of both packages |
|||
$nupkgFolder = Join-Path -Path $OutputPath -ChildPath "nupkg-$Version" |
|||
$zipFolder = Join-Path -Path $OutputPath -ChildPath "zip-$Version" |
|||
foreach ($folder in $nupkgFolder, $zipFolder) { if (Test-Path -LiteralPath $folder) { Remove-Item -LiteralPath $folder -Recurse -Force } } |
|||
[System.IO.Compression.ZipFile]::ExtractToDirectory($nupkgPath, $nupkgFolder) |
|||
[System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $zipFolder) |
|||
function Get-ModuleRoot { param ([string] $Folder) (Get-ChildItem -LiteralPath $Folder -Filter 'NTFSSecurity.psd1' -Recurse -File | Select-Object -First 1).DirectoryName } |
|||
$nupkgRoot = Get-ModuleRoot -Folder $nupkgFolder |
|||
$zipRoot = Get-ModuleRoot -Folder $zipFolder |
|||
$files = foreach ($file in Get-ChildItem -LiteralPath $zipRoot -Recurse -File) { |
|||
$relative = $file.FullName.Substring($zipRoot.Length).TrimStart('\') |
|||
$other = Join-Path -Path $nupkgRoot -ChildPath $relative |
|||
$zipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash |
|||
$nupkgHash = if (Test-Path -LiteralPath $other) { (Get-FileHash -Algorithm SHA256 -LiteralPath $other).Hash } else { '' } |
|||
[pscustomobject]@{ File = $relative; ZipSha256 = $zipHash; NupkgSha256 = $nupkgHash; Equal = ($zipHash -eq $nupkgHash) } |
|||
} |
|||
|
|||
$files | Export-Csv -LiteralPath (Join-Path -Path $OutputPath -ChildPath "ModuleFiles-$Version.csv") -NoTypeInformation -Encoding utf8 |
|||
$result.ModuleFiles = @($files).Count |
|||
$result.ModuleFilesEqual = (@($files | Where-Object -FilterScript { -not $_.Equal }).Count -eq 0) |
|||
$result.ModuleDllSha256 = ($files | Where-Object -FilterScript { $_.File -eq 'NTFSSecurity.dll' }).ZipSha256 |
|||
if (-not $result.ModuleFilesEqual) { $problems.Add('The module files of the nupkg and of the zip differ.') } |
|||
|
|||
# 6. The identity that the manifest claims |
|||
$manifest = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $zipRoot -ChildPath 'NTFSSecurity.psd1') |
|||
$label = $manifest.PrivateData.PSData.Prerelease |
|||
$claimed = if ($label) { '{0}-{1}' -f $manifest.ModuleVersion, $label } else { [string] $manifest.ModuleVersion } |
|||
$result.ManifestVersion = $claimed |
|||
if ($claimed -ne $Version) { $problems.Add("The manifest says $claimed, not $Version.") } |
|||
|
|||
$result.Problems = @($problems) |
|||
$result.Verified = ($problems.Count -eq 0) |
|||
$result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path -Path $OutputPath -ChildPath "Identity-$Version.json") -Encoding utf8 |
|||
'Version {0}: tag commit {1}; on master: {2} ({3})' -f $Version, $sha, $result.CommitOnMaster, $compare.status |
|||
$jobs | Format-Table -AutoSize | Out-String -Width 200 |
|||
'GitHub zip SHA-256 {0}' -f $zipSha256 |
|||
'Gallery SHA-512 matches: {0}; nupkg SHA-256 {1}' -f $hashMatches, $result.Gallery.NupkgSha256 |
|||
'Module files: {0}; equal in nupkg and zip: {1}; NTFSSecurity.dll SHA-256 {2}' -f $result.ModuleFiles, $result.ModuleFilesEqual, $result.ModuleDllSha256 |
|||
'Manifest identity: {0}' -f $claimed |
|||
if ($problems.Count -gt 0) { $problems | ForEach-Object -Process { 'PROBLEM: ' + $_ }; 'PUBLISHED_IDENTITY_NOT_VERIFIED'; exit 1 } |
|||
'PUBLISHED_IDENTITY_VERIFIED' |
|||
@ -0,0 +1,172 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $Timeline, |
|||
[ValidateRange(1, 60)] [double] $FromMinutes = 3, |
|||
[ValidateRange(1, 60)] [double] $ToMinutes = 16, |
|||
[ValidateRange(0.01, 5)] [double] $StepMinutes = 0.25, |
|||
[ValidateRange(1, 60)] [double] $Lifetime, |
|||
[ValidateRange(0, 100000)] [int] $Permutations = 0, |
|||
[switch] $AsIfSameSubject, |
|||
[switch] $ShowMismatches |
|||
) |
|||
|
|||
# Replays the Admin roles of a timeline (Export-CellTimeline.ps1) against a model of the failures that the effective-access tests of the Admin role showed in |
|||
# the cells of the operating-system matrix (Decision 24). The model is a description of the observations, not an explanation of Windows: |
|||
# |
|||
# A remote authorization manager (the one of the client for the default -ServerName, the one of the file server for its own name) computes the groups of an |
|||
# account at the first request for the account name and answers from that result for L minutes, also when the account was deleted and created again under |
|||
# the same name in the meantime, with a new SID and new group memberships. The answer then has no access through the groups (0x100000, Synchronize only). |
|||
# |
|||
# For each L from -FromMinutes to -ToMinutes, the script walks the Admin roles in time order, keeps one entry per computer and account name, and predicts |
|||
# whether the first (file server) and the second (client) test pass: a test passes when no entry that is younger than L minutes and was made for another |
|||
# account instance exists. It reports how many of the observed outcomes each L predicts. A fit says that the position of a cell in the sequence is enough to |
|||
# explain the failures, whichever module was under test; it doesn't say how Windows does it, or that the lifetime is a constant. A run whose account name is |
|||
# new always passes, which is what the controller relies on since 1dec389. It reads files only; Windows PowerShell 5.1 or PowerShell 7. |
|||
# |
|||
# -Lifetime L lists every run with the observed and the predicted outcome of both tests for that one L instead of searching for the best L. -AsIfSameSubject |
|||
# gives every run the same account name: for the cells of a controller that gives each fixture a new name (rc7l and later), the listing then shows where a |
|||
# controller that reuses the name would have met a stale entry. -Permutations N asks how often a random assignment of the observed outcomes to the runs |
|||
# (the same number of failures, a fixed random seed) reaches the best agreement of the real outcomes for some L: if the position of a cell decides the |
|||
# outcome, it should almost never. |
|||
# |
|||
# The lifetimes are those of a grid with the step -StepMinutes, so a range is known to within one step (use 0.05 to see the ranges of the record). The model |
|||
# doesn't know that a computer restarted. If the state lives in the memory of the computer, a restart would clear it; for the real account names of the series |
|||
# of Decision 24, no restart of the client or of a file server changes a prediction (the entry that a restart would have cleared had expired, or the run that |
|||
# read it had the same account). For -AsIfSameSubject it matters once: the client restarted between ab6 and ab7. |
|||
$ErrorActionPreference = 'Stop' |
|||
$random = New-Object -TypeName 'System.Random' -ArgumentList 20261010 |
|||
$rows = @(Import-Csv -LiteralPath $Timeline | ForEach-Object -Process { |
|||
[pscustomobject]@{ |
|||
Time = [DateTime]::ParseExact($_.AdminRoleStarted, 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) |
|||
Run = $_.Run |
|||
Candidate = $_.Candidate |
|||
Server = $_.FileServer |
|||
Edition = $_.Edition |
|||
Subject = if ($AsIfSameSubject) { 'one name' } else { $_.Subject } |
|||
Sid = $_.SubjectRid |
|||
Test1 = $_.T1ServerNameFileServer -like 'pass*' |
|||
Test2 = $_.T2DefaultServerName -like 'pass*' |
|||
} |
|||
} | Sort-Object -Property Time) |
|||
|
|||
function Test-Model { |
|||
param ([double] $Minutes, [ValidateSet('Test1', 'Test2')] [string] $Test) |
|||
|
|||
$entries = @{} |
|||
$mismatch = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
$predictions = New-Object -TypeName 'System.Collections.Generic.List[bool]' |
|||
$agree = 0 |
|||
foreach ($row in $rows) { |
|||
$scope = if ($Test -eq 'Test2') { 'client|' + $row.Subject } else { $row.Server + '|' + $row.Subject } |
|||
$entry = $entries[$scope] |
|||
if ($entry -and ($row.Time - $entry.Created).TotalMinutes -lt $Minutes) { |
|||
$predicted = $entry.Sid -eq $row.Sid |
|||
} |
|||
else { |
|||
$entries[$scope] = @{ Created = $row.Time; Sid = $row.Sid } |
|||
$predicted = $true |
|||
} |
|||
|
|||
$predictions.Add($predicted) |
|||
$observed = $row.$Test |
|||
if ($predicted -eq $observed) { |
|||
$agree++ |
|||
} |
|||
else { |
|||
$mismatch.Add(('{0:HH:mm} {1} {2} {3} [{4}]: observed {5}, model {6}' -f $row.Time, $row.Run, $row.Server, $row.Edition, $row.Candidate, |
|||
$(if ($observed) { 'pass' } else { 'FAIL' }), $(if ($predicted) { 'pass' } else { 'FAIL' }))) |
|||
} |
|||
} |
|||
|
|||
[pscustomobject]@{ Minutes = $Minutes; Agree = $agree; Mismatch = $mismatch; Predictions = $predictions } |
|||
} |
|||
|
|||
if ($PSBoundParameters.ContainsKey('Lifetime')) { |
|||
$first = Test-Model -Minutes $Lifetime -Test Test1 |
|||
$second = Test-Model -Minutes $Lifetime -Test Test2 |
|||
$word = { param ($Passed) if ($Passed) { 'pass' } else { 'FAIL' } } |
|||
for ($index = 0; $index -lt $rows.Count; $index++) { |
|||
$row = $rows[$index] |
|||
[pscustomobject]@{ |
|||
Time = $row.Time.ToString('MM-dd HH:mm:ss') |
|||
Run = $row.Run |
|||
Server = $row.Server |
|||
Edition = $row.Edition |
|||
Candidate = $row.Candidate |
|||
Subject = $row.Subject |
|||
Test1 = & $word $row.Test1 |
|||
Test1Model = & $word $first.Predictions[$index] |
|||
Test2 = & $word $row.Test2 |
|||
Test2Model = & $word $second.Predictions[$index] |
|||
} |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
function Get-Range { |
|||
# The lifetimes of a result list as ranges of the grid: 'a to b', or 'a to b and c to d' when the list has a gap. |
|||
param ([object[]] $Result) |
|||
|
|||
$segments = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
$start = $null |
|||
$last = $null |
|||
foreach ($item in $Result) { |
|||
if ($null -eq $start) { |
|||
$start = $item.Minutes |
|||
} |
|||
elseif ($item.Minutes - $last -gt $StepMinutes * 1.5) { |
|||
$segments.Add(('{0:N2} to {1:N2}' -f $start, $last)) |
|||
$start = $item.Minutes |
|||
} |
|||
|
|||
$last = $item.Minutes |
|||
} |
|||
|
|||
if ($null -ne $start) { $segments.Add(('{0:N2} to {1:N2}' -f $start, $last)) } |
|||
$segments -join ' and ' |
|||
} |
|||
|
|||
# Every lifetime is computed from its index, because adding the step again and again drifts and would lose the last grid point of a range. |
|||
$grid = @(for ($step = 0; ; $step++) { |
|||
$value = [Math]::Round($FromMinutes + $step * $StepMinutes, 6) |
|||
if ($value -gt $ToMinutes) { break } |
|||
$value |
|||
}) |
|||
$resultsByTest = @{} |
|||
foreach ($test in 'Test1', 'Test2') { |
|||
$results = @(foreach ($minutes in $grid) { Test-Model -Minutes $minutes -Test $test }) |
|||
$resultsByTest[$test] = $results |
|||
$best = ($results | Measure-Object -Property Agree -Maximum).Maximum |
|||
$bestResults = @($results | Where-Object -FilterScript { $_.Agree -eq $best }) |
|||
$failures = @($rows | Where-Object -FilterScript { -not $_.$test }).Count |
|||
'{0} ({1}): the model predicts {2} of {3} outcomes for L from {4} minutes; {5} runs failed' -f $test, |
|||
$(if ($test -eq 'Test1') { 'the name of the file server' } else { 'the default server name, the client' }), $best, $rows.Count, (Get-Range -Result $bestResults), $failures |
|||
if ($ShowMismatches) { foreach ($line in $bestResults[0].Mismatch) { ' mismatch: ' + $line } } |
|||
if ($Permutations -gt 0) { |
|||
$observed = [bool[]] @($rows | ForEach-Object -Process { $_.$test }) |
|||
$reached = 0 |
|||
$highest = 0 |
|||
for ($shuffle = 0; $shuffle -lt $Permutations; $shuffle++) { |
|||
$shuffled = [bool[]] @($observed | Sort-Object -Property { $random.Next() }) |
|||
$agreement = 0 |
|||
foreach ($result in $results) { |
|||
$agree = 0 |
|||
for ($index = 0; $index -lt $shuffled.Count; $index++) { if ($result.Predictions[$index] -eq $shuffled[$index]) { $agree++ } } |
|||
if ($agree -gt $agreement) { $agreement = $agree } |
|||
} |
|||
|
|||
if ($agreement -ge $best) { $reached++ } |
|||
if ($agreement -gt $highest) { $highest = $agreement } |
|||
} |
|||
|
|||
' {0} of {1} random assignments of the outcomes to the runs reach {2} of {3} for some L; the best of them reaches {4}' -f $reached, $Permutations, $best, $rows.Count, $highest |
|||
} |
|||
} |
|||
|
|||
# One lifetime for both tests: the range of L at which the model predicts the most outcomes of the two tests together. |
|||
$together = @(for ($index = 0; $index -lt $grid.Count; $index++) { |
|||
[pscustomobject]@{ Minutes = $grid[$index]; Agree = $resultsByTest['Test1'][$index].Agree + $resultsByTest['Test2'][$index].Agree } |
|||
}) |
|||
$bestTogether = ($together | Measure-Object -Property Agree -Maximum).Maximum |
|||
'Both tests with one L: the model predicts {0} of {1} outcomes for L from {2} minutes' -f $bestTogether, (2 * $rows.Count), (Get-Range -Result @($together | Where-Object -FilterScript { $_.Agree -eq $bestTogether })) |
|||
@ -0,0 +1,68 @@ |
|||
[CmdletBinding()] |
|||
param ( |
|||
[Parameter(Mandatory)] [string] $ResultsFolder, |
|||
[Parameter(Mandatory)] [string] $OutputPrefix, |
|||
[string[]] $Edition = @('Desktop', 'Core'), |
|||
[ValidateSet('Candidate', 'Baseline')] [string] $Expect = 'Candidate' |
|||
) |
|||
|
|||
# Validates one controller result folder: every edition and role has exactly one result, and for a candidate no test failed |
|||
# and every exit code is 0. It writes the counts per role, every test with its result (from the result files of the roles, |
|||
# not from the counts), and the failures with their full names and messages. A Desktop ConvertFrom-Json wraps an array in |
|||
# one object, so each JSON array is enumerated explicitly. -File passes an array as one string. |
|||
$ErrorActionPreference = 'Stop' |
|||
$Edition = @($Edition | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) |
|||
$summary = @(Get-Content -LiteralPath (Join-Path -Path $ResultsFolder -ChildPath 'Summary.json') -Raw | ConvertFrom-Json | ForEach-Object -Process { $_ }) |
|||
$roles = 'Delegate', 'ServerAdmin', 'Admin', 'Server' |
|||
$expected = @(foreach ($name in $Edition) { foreach ($role in $roles) { '{0}:{1}' -f $name, $role } }) |
|||
$actual = @($summary | ForEach-Object -Process { '{0}:{1}' -f $_.Edition, $_.Role }) |
|||
$problems = New-Object -TypeName 'System.Collections.Generic.List[string]' |
|||
foreach ($identity in $expected) { |
|||
$count = @($actual | Where-Object -FilterScript { $_ -eq $identity }).Count |
|||
if ($count -ne 1) { $problems.Add("$identity has $count results instead of 1") } |
|||
} |
|||
|
|||
if ($summary.Count -ne $expected.Count) { $problems.Add("The summary has $($summary.Count) results instead of $($expected.Count)") } |
|||
$counts = foreach ($entry in $summary) { |
|||
[pscustomobject]@{ |
|||
Version = $entry.Version; Edition = $entry.Edition; Role = $entry.Role; Account = $entry.Account; ExitCode = $entry.ExitCode |
|||
Passed = $entry.Passed; Failed = $entry.Failed; Skipped = $entry.Skipped |
|||
} |
|||
} |
|||
|
|||
$tests = New-Object -TypeName 'System.Collections.Generic.List[object]' |
|||
foreach ($file in Get-ChildItem -LiteralPath $ResultsFolder -Filter '*.result.json') { |
|||
$baseName = $file.Name -replace '\.result\.json$', '' |
|||
$role = ($baseName -split '-')[-1] |
|||
$resultEdition = if ($baseName -match '-(Desktop|Core)-') { $Matches[1] } else { '' } |
|||
foreach ($case in @(Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json | ForEach-Object -Process { $_ })) { |
|||
$tests.Add([pscustomobject]@{ Edition = $resultEdition; Role = $role; Test = $case.Name; Result = $case.Result; Message = (($case.Message -split '\r?\n')[0]) }) |
|||
} |
|||
} |
|||
|
|||
$failures = @($tests | Where-Object -FilterScript { $_.Result -eq 'Failed' }) |
|||
if ($Expect -eq 'Candidate') { |
|||
foreach ($row in $counts) { |
|||
if ($row.ExitCode -ne 0 -or $row.Failed -ne 0 -or $row.Passed -eq 0) { $problems.Add("$($row.Edition) $($row.Role): exit code $($row.ExitCode), $($row.Passed) passed, $($row.Failed) failed") } |
|||
} |
|||
|
|||
if ($failures.Count -gt 0) { $problems.Add("$($failures.Count) failed tests in the result files") } |
|||
} |
|||
|
|||
$counts | Export-Csv -LiteralPath ($OutputPrefix + '-counts.csv') -NoTypeInformation -Encoding utf8 |
|||
$tests | Export-Csv -LiteralPath ($OutputPrefix + '-tests.csv') -NoTypeInformation -Encoding utf8 |
|||
$failures | Export-Csv -LiteralPath ($OutputPrefix + '-failures.csv') -NoTypeInformation -Encoding utf8 |
|||
foreach ($editionName in $Edition) { |
|||
$selected = @($counts | Where-Object -FilterScript { $_.Edition -eq $editionName }) |
|||
'{0}: passed={1}, failed={2}, skipped={3}' -f $editionName, ($selected.Passed | Measure-Object -Sum).Sum, ($selected.Failed | Measure-Object -Sum).Sum, ($selected.Skipped | Measure-Object -Sum).Sum |
|||
} |
|||
|
|||
$counts | Format-Table -AutoSize | Out-String -Width 200 |
|||
'tests in the result files: {0}; failed: {1}; skipped: {2}' -f $tests.Count, $failures.Count, @($tests | Where-Object -FilterScript { $_.Result -eq 'Skipped' }).Count |
|||
if ($problems.Count -gt 0) { |
|||
$problems | ForEach-Object -Process { 'PROBLEM: ' + $_ } |
|||
'LIVE_RESULT_NOT_ACCEPTED' |
|||
exit 1 |
|||
} |
|||
|
|||
'LIVE_RESULT_VERIFIED ({0})' -f $Expect |
|||
@ -0,0 +1,143 @@ |
|||
# Test NTFSSecurity on a file server that isn't Windows |
|||
|
|||
This page is for people who reported [#34][issue-34] on a NetApp, EMC, IBM, or |
|||
other file server and who offered to test a fix. It takes about 20 minutes. |
|||
Two people may share the work: a storage administrator, who prepares and |
|||
removes a test folder, and a user without administrator rights on the file |
|||
server, who runs the module. The commands are the ones that the |
|||
[live tests](README.md) run on Windows file servers (case 1). |
|||
|
|||
## Keep it safe |
|||
|
|||
- Use only a new folder that you create for this test. Never run these |
|||
commands on real data, on the root of a share, or on a home folder: they |
|||
change permissions. |
|||
- The test removes nothing outside that folder. When you finish, the |
|||
storage administrator deletes the folder. |
|||
- Don't send passwords, API keys, file contents, or complete security |
|||
descriptors. Replace the names of servers, domains, and accounts with |
|||
placeholders, such as `FILER`, `DOMAIN`, and `testuser`. Well-known SIDs, |
|||
such as `S-1-5-32-544`, can stay. |
|||
|
|||
## What you need |
|||
|
|||
- The exact package to test. The maintainer names it in the issue; this page |
|||
says `5.0.0-rc7` as an example. Install it in a new PowerShell session and |
|||
don't load another version of NTFSSecurity in the same session: |
|||
|
|||
```powershell |
|||
Install-Module -Name NTFSSecurity -RequiredVersion 5.0.0-rc7 -AllowPrerelease -Scope CurrentUser |
|||
Import-Module -Name NTFSSecurity |
|||
(Get-Module -Name NTFSSecurity).Version |
|||
(Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'NTFSSecurity.dll')).Hash |
|||
``` |
|||
|
|||
- A domain group that has Full Control on the test folder, and a user who is |
|||
a member of that group but not an administrator (or root) of the file |
|||
server. This is the setup in which the error 1307 happened. |
|||
- Windows PowerShell 5.1 or PowerShell 7. Say which one you used. |
|||
|
|||
## 1. Prepare the test folder (storage administrator) |
|||
|
|||
Create the folder and one subfolder for each command. The user who runs the |
|||
module in step 2 must not own these folders: in the failing setup the folder |
|||
is owned by `BUILTIN\Administrators`, or by the owner that your file server |
|||
shows for administrators, and the user may not assign that owner. Replace the |
|||
first two lines. |
|||
|
|||
```powershell |
|||
$root = '\\FILER\share\ntfssecurity-test' |
|||
$group = 'DOMAIN\ntfssecurity-test-group' |
|||
|
|||
$names = 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance', 'SetInheritance', 'SetSecurityDescriptor' |
|||
New-Item -ItemType Directory -Path $root | Out-Null |
|||
icacls $root /grant "${group}:(OI)(CI)F" | Out-Null |
|||
foreach ($name in $names) { New-Item -ItemType Directory -Path (Join-Path $root $name) | Out-Null } |
|||
icacls "$root\RemoveAccess" /grant 'Everyone:(OI)(CI)RX' | Out-Null |
|||
icacls "$root\ClearAccess" /grant 'Everyone:(OI)(CI)RX' | Out-Null |
|||
icacls "$root\EnableInheritance" /inheritance:d | Out-Null |
|||
|
|||
(Get-Acl -LiteralPath $root).Owner |
|||
``` |
|||
|
|||
The last line shows the owner. If it is the account that runs the module in |
|||
step 2, the test can't show the error: let another administrator create the |
|||
folders. If `icacls` fails for you at this step, stop and tell us. |
|||
|
|||
## 2. Run the commands (the user without administrator rights) |
|||
|
|||
Run this in the new session in which you imported NTFSSecurity. It only |
|||
changes the seven subfolders. Each command writes an error, if there is one, |
|||
instead of stopping. |
|||
|
|||
```powershell |
|||
$root = '\\FILER\share\ntfssecurity-test' |
|||
$everyone = 'Everyone' |
|||
$ErrorActionPreference = 'Continue' |
|||
|
|||
function Show-State ([string] $Name) { |
|||
$path = Join-Path $root $Name |
|||
'--- {0}: owner {1}' -f $Name, ((Get-Acl -LiteralPath $path).GetOwner([System.Security.Principal.SecurityIdentifier]).Value) |
|||
icacls $path |
|||
} |
|||
|
|||
foreach ($name in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance', 'SetInheritance', 'SetSecurityDescriptor') { Show-State $name } # before |
|||
|
|||
Add-NTFSAccess -Path "$root\AddAccess" -Account $everyone -AccessRights ReadData |
|||
Remove-NTFSAccess -Path "$root\RemoveAccess" -Account $everyone -AccessRights ReadAndExecute -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None |
|||
Clear-NTFSAccess -Path "$root\ClearAccess" |
|||
Disable-NTFSAccessInheritance -Path "$root\DisableInheritance" |
|||
Enable-NTFSAccessInheritance -Path "$root\EnableInheritance" |
|||
Set-NTFSInheritance -Path "$root\SetInheritance" -AccessInheritanceEnabled $false |
|||
$descriptor = Get-NTFSSecurityDescriptor -Path "$root\SetSecurityDescriptor" |
|||
Add-NTFSAccess -SecurityDescriptor $descriptor -Account $everyone -AccessRights ReadData |
|||
Set-NTFSSecurityDescriptor -SecurityDescriptor $descriptor |
|||
|
|||
foreach ($name in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance', 'SetInheritance', 'SetSecurityDescriptor') { Show-State $name } # after |
|||
``` |
|||
|
|||
What a good result looks like, for each of the seven folders: |
|||
|
|||
- The command wrote no error. |
|||
- The owner is the same before and after. |
|||
- Only the intended entry changed: `AddAccess` gained an entry for Everyone, |
|||
`RemoveAccess` and `ClearAccess` lost theirs, the inheritance flags of |
|||
`DisableInheritance`, `EnableInheritance`, and `SetInheritance` changed, |
|||
and `SetSecurityDescriptor` gained an entry for Everyone. |
|||
|
|||
## 3. Tell us what happened |
|||
|
|||
Post a comment in [#34][issue-34] with: |
|||
|
|||
1. The package version and the SHA-256 of `NTFSSecurity.dll` from the |
|||
first step, and the PowerShell edition and Windows version of the |
|||
computer that ran the commands. |
|||
2. The file server product and version, such as `ONTAP 9.x`, `PowerScale |
|||
OneFS x.y`, or `IBM ESS x.y`, whether the path goes through DFS, and the |
|||
SMB version if you know it. |
|||
3. For each of the seven commands: worked or failed. For a failure, the |
|||
first line of the error and its `FullyQualifiedErrorId`, such as |
|||
`AddAceError,NTFSSecurity.AddAccess`. |
|||
4. The owner before and after for each folder, and the `icacls` output |
|||
before and after, with the names replaced. |
|||
5. Anything that looked different from what you expected, even if the |
|||
commands worked. |
|||
|
|||
A result that says only "works for me" can't tell us which command ran on |
|||
which setup. The list above is what we need to treat the result as a test. |
|||
|
|||
## 4. Optional: confirm that your setup reproduces the error |
|||
|
|||
To see that the setup is the one in which #34 happened, repeat the second |
|||
step with `4.2.6` in a new session. Reporters saw error 1307 from |
|||
`Add-NTFSAccess` in 4.2.6, and in our Windows lab 5.0.0-rc2 failed |
|||
`Add-NTFSAccess`, `Clear-NTFSAccess`, and `Set-NTFSSecurityDescriptor` the |
|||
same way. Create a new test folder first, because the first run changed some |
|||
of the seven folders. Don't run the two versions in the same session. |
|||
|
|||
## 5. Remove the test folder (storage administrator) |
|||
|
|||
Delete the folder `ntfssecurity-test` with its subfolders. Check its path |
|||
first, so that you delete only the test folder. |
|||
|
|||
[issue-34]: https://github.com/raandree/NTFSSecurity/issues/34 |
|||
Loading…
Reference in new issue