From bafb5a52034bb111fb4e382412067cdb8e2520f2 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 09:36:03 +0000 Subject: [PATCH 01/20] docs(memory-bank): record post-release triage of open work Replace the stale release-pending status: v0.3.0-preview0001 shipped on 2026-09-07. Record the restored ai/performance-refactor branch, its review findings rechecked against main, the dependabot failure cause, issue #4, the uncreated v0.2.0 changelog pull request, and FIND-002. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 200 ++++++++++++++-------------------- .memory-bank/progress.md | 40 +++---- 2 files changed, 104 insertions(+), 136 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 91fed2a..93ae0f1 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -1,126 +1,92 @@ --- status: current -last-verified: 2026-09-07 +last-verified: 2026-10-06 owner: software-engineer -source: current task evidence +source: repository, git reflog, and public GitHub API evidence --- # Active context ## Current task -Reacceptance of the corrected `f5731f1` candidate is complete on -`ai/test-gap-audit`. All requested lab, installed-package, DSC, and fresh local -coverage gates passed. The module bytes remain identical to the independently -approved FIND-001 correction. The next step is the normal versioned release -workflow, only after explicit user authorization. No merge, tag, publication, -push, or git remote mutation is authorized or was performed. - -## Current acceptance evidence - -- Four full lab passes: built Desktop/Core and installed Desktop/Core each - passed 95 tests, zero failed or skipped, with eight ready cleanup entries. -- Isolated Desktop DSC engine: five passed, zero skipped, including both actual - engine invocations; its temporary machine-module installation was removed. -- Fresh local Core 7.6.5: 1,806 passed, zero failed, two environment skips, - 91.15 percent asserted coverage, completed at 18:35:18 UTC. -- Fresh local Desktop 5.1.26100.33296: 1,761 passed, zero failed, two environment - skips, 90.41 percent asserted coverage, completed at 18:52:19 UTC. -- Both local ten-task workflows imported the exact fresh lab coverage hash; - the 80 percent threshold was unchanged. Their two warnings each were from - intentionally mocked evidence-copy failures. The mounted-volume and - unavailable SACL-read privilege skips remain disclosed. -- Original installed module: all four original file hashes and directory ACL - restored. Both controllers passed signed/sealed Kerberos LDAP after testing; - zero targets remained and ten marked baseline objects were ready. -- All 38 guest evidence files matched host copies before the two run-owned - remote staging directories were removed. No test console, member module - staging, or temporary HTTP.sys binding remained. All thirteen VMs and - checkpoints `wac-pre-f5731f1-b1fe0b1f` remain; no lab replacement was needed. -- All host, guest, and local terminal markers are zero. The processes exited - and event-based monitoring finished. Do not restart the completed run. - -Private host evidence is under administrator `%TEMP%` in -`wac-reaccept-f5731f1-b1fe0b1f50b042dd8a8b69223ed64375`. The -[reacceptance record](../docs/lab-reacceptance-2026-09-07.md) is the shareable -summary; raw logs and installation inventories remain private. The host -AutomatedLab wrapper rejected `ScriptFileName`, so the private driver used -validated persistent PSSessions and the unchanged console entry point. - -Fresh lab coverage SHA-256 is -`D4A71389F9539CF05D4BBA5FFA8F318407D7A26E2A116B2E0EBC8D050EBC49AE`. -The configured coverage path contains this accepted document; the prior file -was preserved separately. No old lab coverage was reused for the new candidate. - -## Corrected candidate - -FIND-001 is resolved on `ai/test-gap-audit`. The source change replaces only -the redundant post-verification CNG descriptor read with the already verified -`$storedBytes`; non-enumerating output, critical-binding checks, verification, -and rollback remain unchanged. A fresh independent review approved the final -source and regression with no findings and no Blocker or Major. - -The newline-only FIND-002 remains deferred and non-blocking. Prior packages and -lab evidence remain historical proof for their original hashes; the current -reacceptance establishes the corrected candidate's validation. - -## FIND-001 verification - -- Red: PowerShell 7 ran the new real-key regression against `fd07051`; helper - read three raised the injected failure after an unmocked read confirmed the - requested DACL was stored. Test cleanup had already verified key deletion. -- Green: the focused regression passed once in Core 7.6.5 and once in Desktop - 5.1.26100.33296 with zero skips. It asserts exactly two helper reads, exact - returned bytes, a non-equivalent candidate, independent stored-DACL - equivalence, and cleanup. -- The full 52-test CNG mutation file passed with zero failures or skips in each - local edition. The focused live CNG mutation passed on `F1AFile1`; its - descriptor returned byte-for-byte to SHA-256 - `473155193E7061B6357A560792023B2519B91B7349EB7701F13569541E933676`, - and its HTTP.sys binding and staging directories were removed. -- Full local Core passed 1,806 tests with two environment skips and 83.00 - percent asserted coverage. Full local Desktop passed 1,761 tests with the - same skips and 80.81 percent asserted coverage. Both explicitly report - `Domain-lab evidence merged: no`. -- PSScriptAnalyzer 1.25.0 found nothing in the changed source or test. The built - module SHA-256 is - `A8E433469F06D54F7B71796A2DA1F6589F8705C3141641BD2EDEE589271D94D7`. -- The 22-task pack passed without errors or warnings. Package SHA-256 is - `D93B2644B31A38B37F9FAC08DBD1D28C85AF8AD452D81E1428E0A3054530588C`, - and its root module matches the tested module byte-for-byte. -- Private evidence is under - `%TEMP%\wac-find001-6cf68f013c3948d0919d0f774a10f6b4`. The prior - `%TEMP%\wac-acceptance-5991673-884f827956f442e0974735805d7d0d0c` - directory remains unchanged. - -## Prior candidate acceptance - -The [prior acceptance record](../docs/lab-acceptance-2026-09-07.md) retains the -earlier candidate's hashes, fixture cleanup failure and repair, recovery, and -completed gates. Its private artifacts remain under -`%TEMP%\wac-acceptance-5991673-884f827956f442e0974735805d7d0d0c`. -Keep the historical failure separate from the successful rerun. Neither its -module hash nor its older coverage substitutes for the current reacceptance. - -## Handoff and limits - -[Reacceptance record](../docs/lab-reacceptance-2026-09-07.md) records the current -four-pass lab evidence, restoration, and final cross-edition coverage gates. -[Audit record](../docs/test-gap-audit-2026-09-06.md) contains the confirmed -findings, previous audit-host validation, and specific residual risks. The -[lab checklist](../tests/Lab/acceptance-checklist.md) covers a fresh payload, -both editions, an installed-package pass, evidence collection, and rollback. -Do not claim atomic LDAP writes, exhaustive native fault injection, or live -validation from unit tests. The final redundant CNG post-write read is a -resolved control-flow reliability defect (FIND-001); its full domain-lab, -installed-package, and current local coverage gates are complete. A properly -versioned release workflow still needs authorization. Focused reviews are -complete, but their outcomes -are not unconditional release approval. The original review ledger remains in -administrator TEMP under `wac-security-review-ce8512135436429ba24194450bba4877`. - -## Previous verified milestone - -Wiki publication is closed: `830a909`, hosted run `34055979655`, and -`0.2.0-preview0002` passed the standard Ubuntu publication workflow. Keep the -standard task and runner split; no local publisher override is needed. +Post-release triage of open work on 2026-10-06. `main` is `40e364a`, in sync +with `origin/main`. No code was changed and no git remote was mutated. The +only repository-state change was restoring the deleted local branch +`ai/performance-refactor` at `5f06c03` so its commits survive reflog expiry. + +## Release state + +- `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07: Build run + `34158650074` passed, and the GitHub release and PowerShell Gallery package + were published at 20:32 UTC. The earlier "release awaits authorization" + note was stale. +- `v0.2.0` is the current stable release (2026-09-06). +- The [reacceptance record](../docs/lab-reacceptance-2026-09-07.md) and the + [security review](../docs/security-review-2026-09-07.md) remain the evidence + for the shipped candidate. + +## Open work, in recommended order + +1. **Orphaned performance refactor.** `ai/performance-refactor` holds five + commits on `5991673` (2026-09-10): `a3e3152` plus review and documentation + commits. It was never pushed, and its ref was deleted before 2026-10-05. + Source and test files merge cleanly onto `main`; the Memory Bank files and + `docs/README.md` conflict. Its CNG comparison change must be revalidated + over `f5731f1`. Live-lab and installed-package acceptance never ran. + Decide whether to rebase and finish it or drop it deliberately. +2. **Repository-wide review findings (2026-09-10).** They were recorded only + on that branch. Rechecked by reading `main` on 2026-10-06, not by running + anything: + - Blocker (lab harness): + `tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1` recursively + deletes `-RemoteRepositoryPath` (only `ValidateNotNullOrEmpty`) on the + domain controller and replaces any same-version module under + `%ProgramFiles%\WindowsPowerShell\Modules\WindowsAccessControl`. There is + no ownership marker or backup; a High-impact confirmation is the only + guard. Still present. + - Major: `Test-WindowsTaskSchedulerSystemAce` returns `$false` when the + current DACL has no SYSTEM ACE, so a write that restores SYSTEM is + refused. Still present; no test pins it as intended behavior. + - Major: `Set-WindowsSmbShareSecurityDescriptor` restores the description + captured at target resolution, overwriting a concurrent edit. Still + present. + - Major: the opt-in ModuleFast path in the vendored `Resolve-Dependency.ps1` + downloads and runs `bit.ly/modulefast` unverified. Disabled by default. + - Major (CNG final read) is FIND-001, resolved on `main` by `f5731f1`. + - Minor: `Resolve-WindowsADObjectTarget` always requests + `nTSecurityDescriptor`, and `Get-WindowsADObjectRecord` indexes it + without an absence check. `Get-ADObjectCallerEffectiveAccess` does not + need it; the impact on callers without read-control access needs live + evidence. + - Follow-ups: `README.md` line 315 still calls private keys read-only; the + batch dispatcher `CommandName`/`ObjectFamily` guard has no test. +3. **Dependabot pull requests #1-#3** bump `actions/checkout` to 7.0.1, + `actions/upload-artifact` to 7.0.1, and `actions/download-artifact` to + 8.0.1. Their red test jobs are inherited: all three branch from `4806726` + (2026-08-15, 42 commits behind `main`), whose own push build failed the + same four NTFS path tests. `0502254` fixed those tests the next morning. + The API's base `4c204ff` is the later branch tip, and its tests passed. + All three merge cleanly onto `main`, and no test pins action versions. + The v4 pins raise the Node.js 20 deprecation warning. Land the artifact + pair together after reading the major-version release notes. +4. **Issue #4** (2026-09-07, unanswered) asks whether this module replaces + NTFSSecurity. `docs/README.md` and `source/WikiSource/Home.md` promise an + NTFSSecurity migration map, but `docs/migration-from-ntfspermission.md` + covers only the unpublished NTFSPermission rename. Source material: + `docs/research.md#detailed-ntfssecurity-comparison`. +5. **Changelog.** The v0.2.0 run reported "Send changelog pull request" as + successful, yet the repository has no closed pull request. Branch + `updateChangelogAfterv0.2.0` (`38cdabd`) inserts `## [0.2.0]` directly + below `[Unreleased]`, which would now mislabel seven post-0.2.0 entries; + do not merge it as-is. Find the cause before the next stable release. +6. **FIND-002**: two test files still lack a final newline. +7. **Housekeeping**: remote `ai/test-gap-audit` is merged. The thirteen lab + VMs, their checkpoints, and the administrator `%TEMP%` evidence are not + visible from this session's account; confirm before keeping or removing + them. + +## Limits + +Remote deletions, pull request comments, issue replies, pushes, and releases +need explicit user authorization. The review findings above were confirmed by +reading code, not by fault injection or live runs. diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 7626cdc..a6efd8a 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-09-07 +last-verified: 2026-10-06 owner: software-engineer source: repository evidence --- @@ -9,27 +9,29 @@ source: repository evidence ## Current status -FIND-001 is resolved on `ai/test-gap-audit`. The CNG setter now returns the -bytes already read and verified after persistence rather than performing a -third provider read. The real-key regression is red then green, both editions -pass the 52-test CNG suite, and independent review approved with no findings. -The tested package passed 22 build tasks without errors or warnings. - -Fresh reacceptance of `f5731f1` has passed all four built/installed lab profiles: -95 tests each, zero failures or skips, and eight ready cleanup entries. The -five Desktop DSC-engine tests also passed. Independent checks verified original -installation bytes/ACLs and fixture cleanup; all 38 guest evidence files were -retained before staging removal. All thirteen VMs and checkpoints remain. -Fresh local Core completed at 18:35:18 UTC with 1,806 passed, zero failed, two -environment skips, and 91.15 percent asserted coverage using the exact new lab -coverage hash. Desktop completed at 18:52:19 UTC with 1,761 passed, zero failed, -two environment skips, and 90.41 percent asserted coverage. Both imported the -current lab coverage byte-for-byte, all terminal markers are zero, and no -validation process remains. Requested candidate acceptance is complete; -FIND-002 is deferred. A versioned release still requires explicit authorization. +`v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted +FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage +found unmerged and untracked work: the restored `ai/performance-refactor` +branch and its repository-wide review findings, of which the lab-harness +Blocker and the Task Scheduler and SMB Majors remain on `main`. Also open are +three dependabot pull requests with inherited failures, unanswered issue #4 +with a missing NTFSSecurity migration map, an uncreated v0.2.0 changelog pull +request, and the FIND-002 newline nit. `activeContext.md` holds the evidence +and the recommended order. ## Recent milestones +- 2026-10-06: Triaged open work. Verified from git, the public GitHub API, + and the PowerShell Gallery that `v0.3.0-preview0001` shipped on + 2026-09-07, so the pending-authorization note was stale. Found the + 2026-09-10 performance refactor and repository-wide review only on a + deleted, never-pushed local branch; restored `ai/performance-refactor` at + `5f06c03` before reflog expiry. Rechecked its findings against `main`: + the lab-runner deletion, Task Scheduler SYSTEM refusal, SMB description + overwrite, and AD descriptor overfetch remain; its CNG Major is FIND-001. + Dependabot failures are inherited from their parent `4806726`, fixed on + `main` by `0502254`. No code change and no remote mutation. + - 2026-09-07: Completed fresh reacceptance of `f5731f1` without replacing the lab. All four built/installed passes have 95 passed tests, zero failures or skips, and ready cleanup. Five DSC-engine tests and both fresh local gates From a48ec3d798a7496a8c7d57882bc06223999939a0 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 09:56:33 +0000 Subject: [PATCH 02/20] docs(memory-bank): record the post-release handoff sequence Eight sequential handoff prompts outside the repository now drive the triaged work. They commit to the local integration branch ai/post-release-fixes; only the final prompt touches the remote, with approval per action. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 14 ++++++++++++++ .memory-bank/progress.md | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 93ae0f1..570b690 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -14,6 +14,20 @@ with `origin/main`. No code was changed and no git remote was mutated. The only repository-state change was restoring the deleted local branch `ai/performance-refactor` at `5f06c03` so its commits survive reflog expiry. +## Handoff sequence + +Eight sequential handoff prompts now drive the open work. They live outside +the repository in the user's desktop folder +`WindowsAccessControl-handoffs-2026-10-06`, with a README, a ledger, and a +`reports` folder; trust that ledger and git history for progress. All prompts +commit to the local integration branch `ai/post-release-fixes`, created from +`ai/post-release-triage`. Execution order, which supersedes the triage order +below: 01 lab-runner ownership guard, 02 Task Scheduler, SMB, and AD +defects, 03 performance-refactor port, 04 NTFSSecurity guide and issue #4 +draft, 05 Actions bumps, 06 changelog section, silent changelog pull request +step, and FIND-002, 07 live lab acceptance, 08 push, pull request, release, +and cleanup. Only 08 touches the remote, with approval per action. + ## Release state - `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07: Build run diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index a6efd8a..3885d4a 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -21,6 +21,11 @@ and the recommended order. ## Recent milestones +- 2026-10-06: Wrote eight sequential handoff prompts for the triaged work, + outside the repository, with a ledger and per-prompt reports. They commit + to local `ai/post-release-fixes`; only the final prompt touches the + remote, with approval per action. + - 2026-10-06: Triaged open work. Verified from git, the public GitHub API, and the PowerShell Gallery that `v0.3.0-preview0001` shipped on 2026-09-07, so the pending-authorization note was stale. Found the From 96d66719d079847ded35cd98c379ebe587df6a40 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 12:59:40 +0000 Subject: [PATCH 03/20] fix(lab): refuse to delete directories the runner does not own The domain-lab acceptance runner recursively deleted the payload root, its package child, and any same-version WindowsAccessControl installation on the management domain controller without checking that it had created them. The runner now marks each directory it may later replace and replaces one only when it is absent, or marked with no junction or symbolic link inside. The domain controller rejects a payload root that is not a dedicated local directory before anything changes, in every mode. Each remote step confirms its directory, because AutomatedLab reports a remote throw without stopping the calling script. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 34 +- .memory-bank/debugging-insights.md | 26 +- .memory-bank/progress.md | 76 ++-- .memory-bank/systemPatterns.md | 6 +- CHANGELOG.md | 8 + specs/0005-verification-and-traceability.md | 23 ++ ...voke-WindowsAccessControlLabAcceptance.ps1 | 194 +++++++-- tests/Lab/README.md | 47 +++ ...indowsAccessControl.LabRunnerOwnership.ps1 | 349 +++++++++++++++++ tests/Lab/acceptance-checklist.md | 18 +- ...indowsAccessControlLabAcceptance.Tests.ps1 | 369 ++++++++++++++++++ ...AccessControl.LabRunnerOwnership.Tests.ps1 | 360 +++++++++++++++++ 12 files changed, 1410 insertions(+), 100 deletions(-) create mode 100644 tests/Lab/WindowsAccessControl.LabRunnerOwnership.ps1 create mode 100644 tests/Unit/Lab/WindowsAccessControl.LabRunnerOwnership.Tests.ps1 diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 570b690..f83da76 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,10 +9,18 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Post-release triage of open work on 2026-10-06. `main` is `40e364a`, in sync -with `origin/main`. No code was changed and no git remote was mutated. The -only repository-state change was restoring the deleted local branch -`ai/performance-refactor` at `5f06c03` so its commits survive reflog expiry. +Handoff 01 of the 2026-10-06 sequence is complete on the local branch +`ai/post-release-fixes`, created from `ai/post-release-triage`: the +domain-lab acceptance runner no longer deletes directories it did not create. +It marks what it creates on the management domain controller, replaces only +absent or marked directories without a junction or symbolic link inside, +validates the payload root there in every mode, and stops when a remote step +does not confirm its directory. No live lab run took place; handoff 07 owns +it. Next is handoff 02. No git remote was mutated. + +The 2026-10-06 triage below remains the record of the open work. `main` is +`40e364a`, in sync with `origin/main`; the triage restored the deleted local +branch `ai/performance-refactor` at `5f06c03`. ## Handoff sequence @@ -51,13 +59,19 @@ and cleanup. Only 08 touches the remote, with approval per action. 2. **Repository-wide review findings (2026-09-10).** They were recorded only on that branch. Rechecked by reading `main` on 2026-10-06, not by running anything: - - Blocker (lab harness): + - Blocker (lab harness), fixed by handoff 01 on `ai/post-release-fixes`: `tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1` recursively - deletes `-RemoteRepositoryPath` (only `ValidateNotNullOrEmpty`) on the - domain controller and replaces any same-version module under - `%ProgramFiles%\WindowsPowerShell\Modules\WindowsAccessControl`. There is - no ownership marker or backup; a High-impact confirmation is the only - guard. Still present. + deleted `-RemoteRepositoryPath` (only `ValidateNotNullOrEmpty`) on the + domain controller and replaced any same-version module under + `%ProgramFiles%\WindowsPowerShell\Modules\WindowsAccessControl`. It now + refuses unmarked directories. The existing `C:\WacRepo` and the `0.0.1` + installation on `F1ADC1` predate the marker, so 07 must decide what to do + with them before its first run. + - Follow-up (review of handoff 01, not scheduled): the SMB, certificate + private-key, Task Scheduler, and foreign-principal suites recursively + delete `C:\WindowsAccessControlLab\ModuleUnderTest` on the member server + without an ownership check; it is a fixed child of the harness's marked + member root. - Major: `Test-WindowsTaskSchedulerSystemAce` returns `$false` when the current DACL has no SYSTEM ACE, so a write that restores SYSTEM is refused. Still present; no test pins it as intended behavior. diff --git a/.memory-bank/debugging-insights.md b/.memory-bank/debugging-insights.md index 3396ef6..f1a05ce 100644 --- a/.memory-bank/debugging-insights.md +++ b/.memory-bank/debugging-insights.md @@ -1,12 +1,36 @@ --- status: current -last-verified: 2026-09-07 +last-verified: 2026-10-06 owner: software-engineer source: implementation and test evidence --- # Debugging insights +## AutomatedLab does not stop on a remote throw + +`Invoke-LabCommand` builds its own parameters for `Invoke-LWCommand`, which +calls `Invoke-Command` without `-ErrorAction`. A terminating error in the +remote script block therefore reaches the caller as a non-terminating error +unless the global `$ErrorActionPreference` is `Stop`, and a script that sets +`Stop` only in its own scope carries on. A step that returned no output is +retried (`InvokeLabCommandRetries`, default 3, 10 seconds apart) and then +returns nothing. Read in AutomatedLab 5.61.0 on 2026-10-06, not observed in a +live run. The acceptance runner has every directory step return its directory +and stops when that result is missing, so an install refusal cannot degrade +into a silent build-output pass. + +## `$script:` in a Pester mock body names the running script's scope + +While a test runs `& .\Invoke-WindowsAccessControlLabAcceptance.ps1`, a mock +body or `-ParameterFilter` that the script triggers resolves `$script:` to the +script's scope, not the test file's. `$script:protectedPath` read as `$null`, +the stand-in for the remote protected directories was never installed, and two +refusal tests passed on `C:\Users` instead of the test's own directory. Pass +such values through plain variables set in `BeforeEach` or `It`, which Pester 5 +runs in one scope, and assert the exact expected reason, not just the kind of +refusal. + ## Deleted fixture identities must leave the cleanup list The first acceptance of `5991673` passed all 95 test bodies but failed the diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 3885d4a..0726b06 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -11,16 +11,28 @@ source: repository evidence `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage -found unmerged and untracked work: the restored `ai/performance-refactor` -branch and its repository-wide review findings, of which the lab-harness -Blocker and the Task Scheduler and SMB Majors remain on `main`. Also open are +found unmerged and untracked work and split it into eight handoffs on local +`ai/post-release-fixes`. Handoff 01 fixed the lab-runner Blocker there; the +Task Scheduler and SMB Majors, the restored `ai/performance-refactor` branch, three dependabot pull requests with inherited failures, unanswered issue #4 with a missing NTFSSecurity migration map, an uncreated v0.2.0 changelog pull -request, and the FIND-002 newline nit. `activeContext.md` holds the evidence -and the recommended order. +request, and the FIND-002 newline nit remain. `activeContext.md` holds the +evidence and the order. ## Recent milestones +- 2026-10-06: Handoff 01 made the domain-lab acceptance runner refuse to + delete what it did not create, test-first on `ai/post-release-fixes`. It + marks the payload root, `package`, and installed module version directory + it creates; replaces only absent or marked directories with no junction or + symbolic link inside; validates the payload root on the management + controller in every mode; and stops when a remote step does not confirm + its directory, because AutomatedLab does not stop on a remote throw. 79 + lab-runner tests pass in Core 7.6.6 and Desktop 5.1; the full gate passes + 1,873 tests with two environmental skips at 83.0 percent asserted coverage. + One independent review approved with Minor findings; seven fixed, three + recorded. No live lab run; handoff 07 owns it. + - 2026-10-06: Wrote eight sequential handoff prompts for the triaged work, outside the repository, with a ledger and per-prompt reports. They commit to local `ai/post-release-fixes`; only the final prompt touches the @@ -150,52 +162,8 @@ and the recommended order. tests, and the full Core gate passed 1,766 tests with two environmental skips and 81.93% asserted coverage. -- 2026-09-05: Closed the validated cycle locally in `6f7ba15`. The Desktop - coverage merge completed at 23:01 UTC with 90.37% asserted coverage and - 90.34% whole-module coverage; all ten tasks passed without warnings. - Monitoring was stopped at 23:02 UTC. Both package editions and the - instrumented lab build had already passed all 93 tests and cleanup checks. - A closing self-review then found that the evidence note had introduced the - local Windows account name into a version-controlled file, which `main` did - not contain; `5d291c5` replaced it with `%TEMP%` while keeping every marker - identifier. Record transient host paths without a user profile. - No push, release, or tag was performed. - -- 2026-09-05: The user accepted the security and quality review and requested - development-cycle close-out. Documented reliable privilege-name completion, - the private-key rule table with SID fallback and unchanged object - properties, private lab diagnostics, and complete generated release notes. - The final specification regression passed 20 tests. The release-note task - passed without altering the validated manifest, module, or package. - Both controllers and the member server passed post-acceptance service - checks. Temporary baseline worktrees, dependency junctions, and the scratch - analysis script were removed; no old raw logs remain in the lab payload. - -- 2026-09-05: Closed the remaining local audit gaps with failing-then-passing - guards: every export has an evidence row and a real test link, all 52 - requirement identifiers occur in the suites that prove them, public output - types and DSC catalogs are checked, and the documented lab order is compared - with its owning function. Added the private-key rule view with orphan SID - fallback. Corrected stale selector, path, concurrency, and coverage prose - and retained raw lab console output for independent progress inspection. - Consolidated duplicate changelog categories without losing any of the 754 - non-heading content lines; the parser had omitted content across duplicates. - The final focused gates pass 20 specification and five runner tests. - Earlier audit claims about missing contributor guidance and uncited ADRs - were partly false: the guidance and several Markdown links already existed. - A rule type is not a view count, a requirement reference is not a behavioral - proof, and not every direct command test is under Unit/Public. Vendored - Sampler TODOs remain upstream under the no-local-bootstrap-edit rule. - -- 2026-09-05: Audited 105 public commands, 20 DSC resources, and 39 roadmap - identifiers. Corrected seven false specification statements and pinned - OI-31 with red/green proof. Eleven specification tests, changelog parsing, - and changed-test static analysis passed. Subsequent milestones record - completion of the remaining view and traceability work. The detailed audit - history remains in `git show f5731f1:.memory-bank/progress.md`. - -- 2026-09-03: Closed OI-31: duplicate module compilation created incompatible - class identities. Ten reproductions per mode and 1,742 passing tests with - two skips proved the fix; coverage was 81.92 percent. Keep imports unforced, - isolate load/unload tests, and run docs/tests in separate processes. The QA - AST guard preserves the rule; detailed cache analysis remains in history. +- 2026-09-03 to 2026-09-05: OI-31, the 105-command audit, the audit-gap + closure, and the `6f7ba15` close-out are in + `git show a48ec3d:.memory-bank/progress.md`. Lessons kept: record transient + host paths without a user profile; keep imports unforced and run docs and + tests in separate processes. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index 0dd79c3..056544a 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-09-07 +last-verified: 2026-10-06 owner: software-engineer source: repository evidence --- @@ -14,8 +14,8 @@ persistence, and typed output. Descriptor editing is detached. QA checks catalogs; requirement references alone do not prove behavior. Containment compares real DN components, and restore must retain the recorded GUID through every target-resolution stage. Exact native ACE removal compares -the full ACE, not only SID, mask, flags, and qualifier. Test cleanup needs an -explicit ownership record before deleting a staged installation. Fixtures +the full ACE, not only SID, mask, flags, and qualifier. Test cleanup and the lab +runner need an ownership record before deleting what they staged. Fixtures release obsolete cleanup entries after successful deletion; passing test bodies do not override a failed Pester container or cleanup ledger. Acceptance requires a nonempty edition set, explicit exit status, fresh per-run artifacts, diff --git a/CHANGELOG.md b/CHANGELOG.md index 3413c5b..d92f244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,6 +576,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Stop the domain-lab acceptance runner from deleting directories it did not + create: it marks the payload root, its `package` staging directory, and + the module version directory it installs, refuses an existing one that is + unmarked or contains a junction or symbolic link before deleting anything, + rejects a payload root that is not a dedicated local directory on the + management domain controller, and stops when a remote step does not + confirm its directory + ([lab guide](tests/Lab/README.md#payload-and-module-ownership)) - Return the already-verified CNG private-key descriptor bytes after a successful DACL write, avoiding a redundant provider read that could report failure after permissions had already changed diff --git a/specs/0005-verification-and-traceability.md b/specs/0005-verification-and-traceability.md index 714730a..e5aa346 100644 --- a/specs/0005-verification-and-traceability.md +++ b/specs/0005-verification-and-traceability.md @@ -324,6 +324,29 @@ sequential and bounded-parallel NTFS owner reads over disposable targets. It emits elapsed time and throughput plus optional JSON evidence without a flaky hard timing assertion. +### Domain-lab payload ownership + +`tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1` deletes only what it +created. Before it changes anything on the management domain controller, that +machine checks `-RemoteRepositoryPath` and rejects a path that is not absolute +on a local fixed drive, is a drive root, passes through a junction or symbolic +link, or lies under the Windows directory, Program Files, ProgramData, or the +user profile root. + +The runner writes an ownership marker into each directory it may later replace +there: the payload root, its `package` staging directory, and the installed +module version directory. It replaces one of them only when the directory is +absent, or carries that marker and contains no junction or symbolic link that a +recursive deletion could follow. Otherwise it stops before deleting anything +and names the directory and the resolution. A remote step that does not confirm +the directory it prepared also stops the run, because AutomatedLab can report a +remote failure without ending the calling script. The runner refuses rather +than backing up and restoring a directory it does not own. +[Ownership tests](../tests/Unit/Lab/WindowsAccessControl.LabRunnerOwnership.Tests.ps1) +prove the path and marker decisions without a lab, and +[runner tests](../tests/Unit/Lab/Invoke-WindowsAccessControlLabAcceptance.Tests.ps1) +run the remote steps against disposable local directories. + ## Code coverage measurement The 80 percent threshold is asserted over the commands the running test profile diff --git a/tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1 b/tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1 index 71de62e..1af16dc 100644 --- a/tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1 +++ b/tests/Lab/Invoke-WindowsAccessControlLabAcceptance.ps1 @@ -29,7 +29,14 @@ .PARAMETER RemoteRepositoryPath The directory on the management domain controller that receives the - minimal repository tree. + minimal repository tree. That machine rejects a path that is not + absolute on a local fixed drive, is a drive root, passes through a + junction or symbolic link, or lies under the Windows directory, Program + Files, ProgramData, or the user profile root. The runner replaces the + directory only when it is absent, or carries the ownership marker the + runner wrote when it created it and contains no junction or symbolic + link; tests\Lab\README.md describes the marker and how to resolve a + refusal. .PARAMETER PowerShellEdition The PowerShell editions the acceptance runs in, one complete pass each. @@ -64,7 +71,9 @@ build wrote to `output\module`. `Installed` expands the packaged module into the machine module path of the management domain controller and points every suite at that installed copy, which is what proves the - package and not only the build output. + package and not only the build output. The runner marks the version + directory it installs and refuses to replace an installation of the + same version that it did not create. .PARAMETER PackagePath The NuGet package an `Installed` run expands. It defaults to the newest @@ -151,6 +160,56 @@ param( $ErrorActionPreference = 'Stop' +# Every remote step that creates or deletes a directory on the management domain +# controller dot-sources this text, so that machine makes each ownership and +# path decision about its own directories. +$ownershipGuard = Get-Content ` + -LiteralPath (Join-Path $PSScriptRoot 'WindowsAccessControl.LabRunnerOwnership.ps1') ` + -Raw ` + -ErrorAction Stop + +function Assert-LabRunnerStepResult { + <# + .SYNOPSIS + Returns the one directory a remote step reports, or stops the run. + + .DESCRIPTION + AutomatedLab can surface a remote failure as a non-terminating error + and return no output, so a step that does not report its directory + stops the run instead of letting the next step act on a directory + that was never checked or prepared. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [object[]]$Result, + + [Parameter(Mandatory)] + [string]$ActivityName, + + [Parameter(Mandatory)] + [string]$ComputerName, + + [Parameter()] + [string]$ExpectedPath + ) + + $values = @($Result | Where-Object { $null -ne $_ }) + $reportedPath = if ($values.Count -eq 1) { [string]$values[0] } else { '' } + if ([string]::IsNullOrWhiteSpace($reportedPath) -or + ($PSBoundParameters.ContainsKey('ExpectedPath') -and $reportedPath -ne $ExpectedPath)) { + throw ( + "'$ActivityName' on '$ComputerName' did not confirm its directory, so the run stops " + + 'before anything else changes. Any remote error reported above names the cause.' + ) + } + + $reportedPath +} + Import-Module -Name AutomatedLab -ErrorAction Stop $repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).ProviderPath @@ -221,62 +280,126 @@ if (-not $PSCmdlet.ShouldProcess( Import-Lab -Name $LabName -NoValidation +# The payload root is checked on the machine that uses it, in every mode, +# before anything there changes. +$validationActivity = 'Validate the acceptance payload directory' +$validation = Invoke-LabCommand ` + -ComputerName $ManagementDomainController ` + -ActivityName $validationActivity ` + -ScriptBlock { + param($OwnershipGuard, $Path) + + $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($OwnershipGuard)) + Assert-WindowsAccessControlLabRunnerPayloadPath -Path $Path + } ` + -ArgumentList $ownershipGuard, $RemoteRepositoryPath ` + -PassThru ` + -NoDisplay +$payloadRoot = Assert-LabRunnerStepResult ` + -Result $validation ` + -ActivityName $validationActivity ` + -ComputerName $ManagementDomainController + if (-not $SkipPayloadDeployment) { - Invoke-LabCommand ` + $resetActivity = 'Reset the acceptance payload directory' + $payloadReset = Invoke-LabCommand ` -ComputerName $ManagementDomainController ` - -ActivityName 'Reset the acceptance payload directory' ` + -ActivityName $resetActivity ` -ScriptBlock { - param($Path) + param($OwnershipGuard, $Path) - Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue - $null = New-Item -Path (Join-Path $Path 'output') -ItemType Directory -Force + $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($OwnershipGuard)) + $root = Assert-WindowsAccessControlLabRunnerPayloadPath -Path $Path + Reset-WindowsAccessControlLabRunnerDirectory ` + -Path $root ` + -Resolution ( + 'Inspect it, then remove or rename it yourself if it is disposable, or pass ' + + '-RemoteRepositoryPath with a directory that does not exist yet.' + ) + $null = New-Item -Path (Join-Path $root 'output') -ItemType Directory -Force + $root } ` - -ArgumentList $RemoteRepositoryPath ` + -ArgumentList $ownershipGuard, $payloadRoot ` + -PassThru ` -NoDisplay + $null = Assert-LabRunnerStepResult ` + -Result $payloadReset ` + -ActivityName $resetActivity ` + -ComputerName $ManagementDomainController ` + -ExpectedPath $payloadRoot foreach ($item in 'source', 'tests') { Copy-LabFileItem ` -Path (Join-Path $repositoryRoot $item) ` -ComputerName $ManagementDomainController ` - -DestinationFolderPath $RemoteRepositoryPath ` + -DestinationFolderPath $payloadRoot ` -Recurse } Copy-LabFileItem ` -Path (Join-Path $repositoryRoot 'output\module') ` -ComputerName $ManagementDomainController ` - -DestinationFolderPath (Join-Path $RemoteRepositoryPath 'output') ` + -DestinationFolderPath (Join-Path $payloadRoot 'output') ` -Recurse } $installedModuleRoot = '' if ($ModuleSource -eq 'Installed') { - $packageDestination = Join-Path $RemoteRepositoryPath 'package' - Invoke-LabCommand ` + $packageDestination = Join-Path $payloadRoot 'package' + $packageActivity = 'Reset the package staging directory' + $packageReset = Invoke-LabCommand ` -ComputerName $ManagementDomainController ` - -ActivityName 'Reset the package staging directory' ` + -ActivityName $packageActivity ` -ScriptBlock { - param($Path) + param($OwnershipGuard, $Path) - Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue - $null = New-Item -Path $Path -ItemType Directory -Force + $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($OwnershipGuard)) + Reset-WindowsAccessControlLabRunnerDirectory ` + -Path $Path ` + -Resolution ( + 'Inspect it, then remove or rename it yourself if it is disposable, or deploy ' + + 'a fresh payload without -SkipPayloadDeployment.' + ) + $Path } ` - -ArgumentList $packageDestination ` + -ArgumentList $ownershipGuard, $packageDestination ` + -PassThru ` -NoDisplay + $null = Assert-LabRunnerStepResult ` + -Result $packageReset ` + -ActivityName $packageActivity ` + -ComputerName $ManagementDomainController ` + -ExpectedPath $packageDestination Copy-LabFileItem ` -Path $resolvedPackagePath ` -ComputerName $ManagementDomainController ` -DestinationFolderPath $packageDestination - $installedModuleRoot = Invoke-LabCommand ` + $installActivity = 'Install the packaged module into the machine module path' + $installation = Invoke-LabCommand ` -ComputerName $ManagementDomainController ` - -ActivityName 'Install the packaged module into the machine module path' ` + -ActivityName $installActivity ` -ScriptBlock { - param($PackageDirectory) + param($OwnershipGuard, $PackageDirectory) $ErrorActionPreference = 'Stop' + . ([scriptblock]::Create($OwnershipGuard)) Add-Type -AssemblyName System.IO.Compression.FileSystem + # Everything this step deletes lies inside the package directory, + # so that directory must still be the one this run created, with no + # junction or symbolic link that a recursive deletion could follow. + $packageOwnership = Get-WindowsAccessControlLabRunnerDirectoryState -Path $PackageDirectory + if ($packageOwnership.State -ne 'Owned') { + throw ( + "Refusing to stage the package in '$PackageDirectory' on " + + "'$env:COMPUTERNAME': $($packageOwnership.Reason)" + ) + } + $package = Get-ChildItem -Path (Join-Path $PackageDirectory '*.nupkg') | Sort-Object -Property LastWriteTimeUtc -Descending | Select-Object -First 1 @@ -301,22 +424,35 @@ if ($ModuleSource -eq 'Installed') { if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw 'The package does not contain a WindowsAccessControl manifest.' } - $version = (Import-PowerShellDataFile -LiteralPath $manifestPath).ModuleVersion + $manifestVersion = (Import-PowerShellDataFile -LiteralPath $manifestPath).ModuleVersion + $version = $null + if (-not [version]::TryParse([string]$manifestVersion, [ref]$version)) { + throw "The package manifest declares '$manifestVersion', which is not a module version." + } + # Only the version directory this runner created and marked may be + # replaced; an installation it did not create is refused. $installRoot = Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules\WindowsAccessControl' - $target = Join-Path $installRoot $version - Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue - $null = New-Item -Path $target -ItemType Directory -Force + $target = Join-Path $installRoot $version.ToString() + Reset-WindowsAccessControlLabRunnerDirectory ` + -Path $target ` + -Resolution ( + 'Inspect it, then uninstall or move it yourself if it is disposable, or install ' + + 'a package with a different module version.' + ) Copy-Item -Path (Join-Path $staging '*') -Destination $target -Recurse -Force $null = Test-ModuleManifest -Path (Join-Path $target 'WindowsAccessControl.psd1') $target } ` - -ArgumentList $packageDestination ` + -ArgumentList $ownershipGuard, $packageDestination ` -PassThru ` -NoDisplay - $installedModuleRoot = [string]$installedModuleRoot + $installedModuleRoot = Assert-LabRunnerStepResult ` + -Result $installation ` + -ActivityName $installActivity ` + -ComputerName $ManagementDomainController Write-Information ( "The packaged module is installed at '$installedModuleRoot'." ) -InformationAction Continue @@ -329,10 +465,10 @@ foreach ($edition in $editions) { $editionKey = $edition.ToLowerInvariant() $armCoverage = $edition -eq $coverageEditionName $remoteEvidencePath = Join-Path ` - $RemoteRepositoryPath ` + $payloadRoot ` ('lab-evidence-{0}-{1}.json' -f $acceptanceRunId, $editionKey) $remoteCoveragePath = if ($armCoverage) { - Join-Path $RemoteRepositoryPath ('lab-coverage-{0}.xml' -f $acceptanceRunId) + Join-Path $payloadRoot ('lab-coverage-{0}.xml' -f $acceptanceRunId) } else { '' @@ -402,7 +538,7 @@ foreach ($edition in $editions) { ConsoleLogPath = $consoleLogPath } } ` - -ArgumentList $RemoteRepositoryPath, $DomainDistinguishedName, $MemberServer, $remoteEvidencePath, $remoteCoveragePath, $edition, $installedModuleRoot ` + -ArgumentList $payloadRoot, $DomainDistinguishedName, $MemberServer, $remoteEvidencePath, $remoteCoveragePath, $edition, $installedModuleRoot ` -PassThru ` -NoDisplay diff --git a/tests/Lab/README.md b/tests/Lab/README.md index dc90f6d..d0a8c3f 100644 --- a/tests/Lab/README.md +++ b/tests/Lab/README.md @@ -85,6 +85,7 @@ baseline rather than a single-domain one. | [Invoke-WindowsAccessControlLabAcceptance.ps1](Invoke-WindowsAccessControlLabAcceptance.ps1) | Copies the current build and the suites into the management domain controller, runs one unattended profile per PowerShell edition there, and carries the redacted evidence and the coverage document back to the host. | | [Start-WindowsAccessControlDomainLabAcceptance.ps1](Start-WindowsAccessControlDomainLabAcceptance.ps1) | Starts the profile in a child console process inside the lab, because a session runspace allows far fewer nested script frames than a console host. | | [Resolve-WindowsAccessControlLabModuleRoot.ps1](Resolve-WindowsAccessControlLabModuleRoot.ps1) | Returns the module directory every suite imports. It is the build output unless `WAC_LAB_MODULE_ROOT` names an installed module, and it fails rather than falling back when that variable is wrong. | +| [WindowsAccessControl.LabRunnerOwnership.ps1](WindowsAccessControl.LabRunnerOwnership.ps1) | The runner's ownership and path decisions. Every remote step that creates or deletes a directory on the management domain controller makes them there; see [Payload and module ownership](#payload-and-module-ownership). | | [WindowsAccessControl.DomainLab.psm1](WindowsAccessControl.DomainLab.psm1) | Test-only harness: fixture plan, setup, status, teardown, coverage arming, and the unattended acceptance profile. | | `*.Live.Tests.ps1` | The eight acceptance suites. | | `coverage/` | The JaCoCo document the acceptance carries back. The build merges it into the reported coverage when it measures the current build; see decisions 0025 and 0027. | @@ -199,6 +200,52 @@ Coverage instruments the built module, so an installed-package run refuses to arm it. Measuring a module no suite loads would report a green run over an unmeasured module rather than report the gap. +## Payload and module ownership + +The runner deletes only directories it created. It writes the marker file +`.windows-access-control-lab-runner` into each directory it may later replace +on the management domain controller: the payload root named by +`-RemoteRepositoryPath`, its `package` staging directory, and the installed +module version directory under +`%ProgramFiles%\WindowsPowerShell\Modules\WindowsAccessControl`. A later run +replaces one of them only when it is absent, or still carries that marker and +contains no junction or symbolic link at any depth, because Windows PowerShell +5.1 deletes through a junction during a recursive removal. + +The management domain controller makes these checks itself, before anything +there changes and also with `-SkipPayloadDeployment`. The payload root must be +an absolute path on a local fixed drive, and it must not be a drive root, pass +through a junction or symbolic link, or lie under the Windows directory, +Program Files, ProgramData, or the user profile root. + +When a directory exists without the marker, or contains a junction or symbolic +link, the run stops before it deletes anything. The error names the directory, +and the link when there is one, and the way out: + +| Refused directory | Resolution | +| --- | --- | +| Payload root | Inspect it, then remove or rename it yourself if it is disposable, or pass `-RemoteRepositoryPath` with a directory that does not exist yet. | +| `package` under a reused payload root | Inspect it, then remove or rename it yourself if it is disposable, or deploy a fresh payload without `-SkipPayloadDeployment`. | +| Installed module of the package's version | Inspect it, then uninstall or move it yourself if it is disposable, or install a package with a different module version. | + +For a junction or symbolic link, remove the link itself rather than what it +points to, and run again. + +Directories left by runs before the marker existed carry no marker. The +`C:\WacRepo` payload on `F1ADC1` is one of them, so the first run refuses it. +The installed-package pass likewise refuses an unmarked installation of the +package's version, such as the `0.0.1` installation the +[2026-09-07 reacceptance](../../docs/lab-reacceptance-2026-09-07.md) found and +restored on `F1ADC1`; a local build without GitVersion also has version +`0.0.1`. Decide before the run whether to keep such a directory and choose a +new payload path or package version, or to remove it after confirming that +nothing else uses it. The runner has no backup and restore; it refuses +instead. + +AutomatedLab can report a failed remote step without ending the calling +script, so the runner also stops when a step does not confirm the directory it +checked or prepared. + ## Known gaps - No read-only domain controller. AutomatedLab exposes no read-only domain diff --git a/tests/Lab/WindowsAccessControl.LabRunnerOwnership.ps1 b/tests/Lab/WindowsAccessControl.LabRunnerOwnership.ps1 new file mode 100644 index 0000000..021f325 --- /dev/null +++ b/tests/Lab/WindowsAccessControl.LabRunnerOwnership.ps1 @@ -0,0 +1,349 @@ +<# + .SYNOPSIS + Ownership and path decisions of the domain-lab acceptance runner. + + .DESCRIPTION + Invoke-WindowsAccessControlLabAcceptance.ps1 sends the text of this file + into every remote step that creates or deletes a directory on the + management domain controller, so each decision is made by the machine + that holds the directory. The runner marks every directory it creates + and replaces a directory only when it is absent or carries that marker. + + The file only defines functions, so dot-sourcing it changes nothing. +#> + +function Get-WindowsAccessControlLabRunnerMarker { + <# + .SYNOPSIS + Returns the file name and text of the runner's ownership marker. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + [pscustomobject]@{ + FileName = '.windows-access-control-lab-runner' + Text = 'Created by the WindowsAccessControl domain-lab acceptance runner.' + } +} + +function Get-WindowsAccessControlLabRunnerProtectedPath { + <# + .SYNOPSIS + Returns the directories a payload root must not lie under on this + machine. + + .DESCRIPTION + Reads the Windows directory, Program Files, ProgramData, and the + user profile root of the machine that runs the function. A + category that resolves to no directory stops the check rather than + weakening it. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $profileList = Get-ItemProperty ` + -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' ` + -Name 'ProfilesDirectory' ` + -ErrorAction SilentlyContinue + $userProfile = [Environment]::GetFolderPath('UserProfile') + + $categories = [ordered]@{ + 'Windows directory' = @( + [Environment]::GetFolderPath('Windows') + $env:SystemRoot + $env:windir + ) + 'Program Files directory' = @( + [Environment]::GetFolderPath('ProgramFiles') + [Environment]::GetFolderPath('ProgramFilesX86') + $env:ProgramFiles + ${env:ProgramFiles(x86)} + $env:ProgramW6432 + ) + 'ProgramData directory' = @( + [Environment]::GetFolderPath('CommonApplicationData') + $env:ProgramData + ) + 'user profile root' = @( + if ($profileList) { + $profileList.ProfilesDirectory + } + if (-not [string]::IsNullOrWhiteSpace($userProfile)) { + [IO.Path]::GetDirectoryName($userProfile) + } + ) + } + + $protectedPaths = foreach ($category in $categories.GetEnumerator()) { + $paths = @($category.Value | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($paths.Count -eq 0) { + throw "Cannot determine the $($category.Key) of '$env:COMPUTERNAME', so no payload root can be checked there." + } + + foreach ($path in $paths) { + [IO.Path]::GetFullPath($path).TrimEnd('\') + } + } + + $protectedPaths | Sort-Object -Unique +} + +function Assert-WindowsAccessControlLabRunnerPayloadPath { + <# + .SYNOPSIS + Rejects a payload root this machine must not use and returns the + normalized path. + + .DESCRIPTION + A payload root must be an absolute path on a local fixed drive. It + must not be a drive root, pass through a junction or symbolic link, + or lie under a protected directory of the machine that runs the + function. Normalization resolves relative segments, separators, + and 8.3 short names before any comparison. + + .PARAMETER Path + The payload root to check. + + .PARAMETER ProtectedPath + The directories the payload root must not lie under. It defaults to + the protected directories of the machine that runs the function; + an empty list is refused rather than treated as nothing to protect. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string[]]$ProtectedPath + ) + + if (-not $PSBoundParameters.ContainsKey('ProtectedPath')) { + $ProtectedPath = @(Get-WindowsAccessControlLabRunnerProtectedPath) + } + + $subject = "The payload root '$Path' on '$env:COMPUTERNAME'" + $example = "Pass a dedicated directory such as 'C:\WacRepo'." + if ($Path -notmatch '^[A-Za-z]:[\\/]') { + throw "$subject is not an absolute local path. $example" + } + + # Wildcard and bracket characters would turn the -Path parameters further + # down into patterns, and a second colon would name a data stream. + $reservedCharacters = [char[]]([char[]]'*?"<>|[]' + [IO.Path]::GetInvalidPathChars()) + if ($Path.IndexOfAny($reservedCharacters) -ge 0 -or $Path.IndexOf([char]':', 2) -ge 0) { + throw "$subject contains a character that a payload root must not use. $example" + } + + try { + $fullPath = [IO.Path]::GetFullPath($Path) + } + catch { + $fullPath = $null + } + if ($null -eq $fullPath -or $fullPath -notmatch '^[A-Za-z]:\\') { + throw "$subject is not an absolute local path. $example" + } + + $fullPath = $fullPath.TrimEnd('\') + if ($fullPath.Length -le 2) { + throw "$subject is a drive root. $example" + } + + if ([IO.DriveInfo]::new($fullPath.Substring(0, 1)).DriveType -ne [IO.DriveType]::Fixed) { + throw "$subject is not on a local fixed drive. $example" + } + + foreach ($protected in $ProtectedPath) { + if ([string]::IsNullOrWhiteSpace($protected)) { + throw "$subject cannot be checked, because the -ProtectedPath list contains a blank entry." + } + + $protectedFullPath = [IO.Path]::GetFullPath($protected).TrimEnd('\') + if ($fullPath.Equals($protectedFullPath, [StringComparison]::OrdinalIgnoreCase) -or + $fullPath.StartsWith($protectedFullPath + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw ( + "$subject lies under the protected directory '$protectedFullPath'. Pass a dedicated " + + 'directory outside the Windows directory, Program Files, ProgramData, and the user ' + + "profiles, such as 'C:\WacRepo'." + ) + } + } + + # A junction or symbolic link would let the path reach a directory the + # comparison above never saw. + $ancestor = $fullPath + while ($ancestor.Length -gt 3) { + if (Test-Path -LiteralPath $ancestor) { + $item = Get-Item -LiteralPath $ancestor -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "$subject passes through the junction or symbolic link '$ancestor'. $example" + } + } + $ancestor = [IO.Path]::GetDirectoryName($ancestor) + } + + $fullPath +} + +function Get-WindowsAccessControlLabRunnerDirectoryState { + <# + .SYNOPSIS + Reports whether a directory is absent, owned by the runner, or not + owned by it. + + .DESCRIPTION + A directory is owned only when it is a real directory, not a + junction or symbolic link, holds the runner's marker file with the + runner's marker text, and contains no junction or symbolic link at + any depth. Windows PowerShell 5.1 deletes through a junction during + a recursive removal, so a tree with one is never treated as owned; + the walk lists each link without entering it. + + .PARAMETER Path + The directory to inspect. + + .OUTPUTS + An object with Path, State (Absent, Owned, or Unowned), and Reason. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Path + ) + + $marker = Get-WindowsAccessControlLabRunnerMarker + $state = 'Unowned' + if (-not (Test-Path -LiteralPath $Path)) { + $state = 'Absent' + $reason = 'it does not exist.' + } + else { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + $markerPath = Join-Path -Path $Path -ChildPath $marker.FileName + if ($item -isnot [IO.DirectoryInfo]) { + $reason = 'it is not a directory.' + } + elseif ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + $reason = 'it is a junction or symbolic link, not a directory the runner created.' + } + elseif (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { + $reason = "it carries no '$($marker.FileName)' ownership marker, so the runner did not create it." + } + elseif ([IO.File]::ReadAllText($markerPath).TrimEnd() -cne $marker.Text) { + $reason = "its '$($marker.FileName)' marker does not hold the runner's ownership text." + } + else { + $link = $null + $pendingDirectories = [Collections.Generic.Stack[IO.DirectoryInfo]]::new() + $pendingDirectories.Push($item) + while ($null -eq $link -and $pendingDirectories.Count -gt 0) { + foreach ($entry in $pendingDirectories.Pop().GetFileSystemInfos()) { + if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { + $link = $entry.FullName + break + } + if ($entry -is [IO.DirectoryInfo]) { + $pendingDirectories.Push($entry) + } + } + } + + if ($link) { + $reason = ( + "it contains the junction or symbolic link '$link', which a recursive " + + 'deletion could follow out of the directory.' + ) + } + else { + $state = 'Owned' + $reason = "it carries the runner's ownership marker." + } + } + } + + [pscustomobject]@{ + Path = $Path + State = $state + Reason = $reason + } +} + +function Reset-WindowsAccessControlLabRunnerDirectory { + <# + .SYNOPSIS + Replaces a runner-owned or absent directory with an empty, marked one. + + .DESCRIPTION + Refuses a directory the runner does not own before deleting + anything, and names the directory and the resolution. An owned + directory is deleted and created again; an absent one is created. + The marker is written as soon as the directory exists, so a later + run can prove that the runner created it. + + .PARAMETER Path + The directory to replace. + + .PARAMETER Resolution + What the operator can do when the directory is refused. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Resolution + ) + + $ownership = Get-WindowsAccessControlLabRunnerDirectoryState -Path $Path + if ($ownership.State -eq 'Unowned') { + throw "Refusing to replace '$Path' on '$env:COMPUTERNAME': $($ownership.Reason) $Resolution" + } + + if (-not $PSCmdlet.ShouldProcess($Path, 'Replace the runner-owned directory')) { + return + } + + $marker = Get-WindowsAccessControlLabRunnerMarker + if ($ownership.State -eq 'Owned') { + # The marker goes last, so a deletion that fails part way still leaves + # a directory the next run can prove it owns. + Get-ChildItem -LiteralPath $Path -Force | + Where-Object { $_.Name -ne $marker.FileName } | + Remove-Item -Recurse -Force -ErrorAction Stop + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } + + $null = New-Item -Path $Path -ItemType Directory -ErrorAction Stop + try { + [IO.File]::WriteAllText( + (Join-Path -Path $Path -ChildPath $marker.FileName), + $marker.Text, + [Text.UTF8Encoding]::new($false) + ) + } + catch { + $markerError = $_ + try { + # This call created the directory a moment ago, so the runner owns it. + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } + catch { + throw [AggregateException]::new( + "Marking '$Path' and removing it again both failed.", + [Exception[]]@($markerError.Exception, $_.Exception) + ) + } + throw $markerError + } +} diff --git a/tests/Lab/acceptance-checklist.md b/tests/Lab/acceptance-checklist.md index deb785f..570fbdb 100644 --- a/tests/Lab/acceptance-checklist.md +++ b/tests/Lab/acceptance-checklist.md @@ -29,6 +29,15 @@ test-fixture repair found during that run. A later candidate needs fresh proof. was changed. Use a prepared lab machine, and do not skip those tests. - Do not use `-SkipPayloadDeployment` for this first run: the old lab payload does not contain the new tests or fixes. +- Decide first what happens to directories that predate the runner's + ownership marker, because the runner refuses to replace them. The existing + `C:\WacRepo` payload on `F1ADC1` has no marker, and neither has the `0.0.1` + installation that the 2026-09-07 reacceptance restored there; a local build + without GitVersion has that same version. Either keep such a directory and + add a new, absent `RemoteRepositoryPath` to `$parameters` or install a + package with another version, or inspect it and remove it yourself. The + runner has no backup and restore. See + [payload and module ownership](README.md#payload-and-module-ownership). Inspect the candidate locally: @@ -133,7 +142,9 @@ For the installed pass, use a new run ID and the same detached wrapper. Add `ModuleSource = 'Installed'` and an explicit absolute `PackagePath` inside `$parameters`, and change `CoverageEdition` to `None`. Do not reuse the first run's evidence name. The runner deliberately refuses installed-package coverage -because it instruments the built module, not the installed copy. +because it instruments the built module, not the installed copy. It marks the +module version directory it installs and refuses to replace an unmarked +installation of the same version. The new live cases are: @@ -157,8 +168,9 @@ service; check that the partner answers LDAP after the suite. - Retain exact failure messages and raw logs in administrator TEMP. Raw logs are not sanitized and must not be published or put in the lab payload tree. - Stop on a failed suite, missing evidence, unknown exit status, unexpected - target, cleanup error, or unavailable prerequisite. Do not weaken a test, - force a write, or skip a suite to complete the run. + target, cleanup error, unavailable prerequisite, or ownership refusal. Do + not weaken a test, force a write, or skip a suite to complete the run, and + do not delete a refused directory before inspecting it. - Review residual risks in the audit record before treating the candidate as release-ready. In particular, request independent security review for the containment, immutable-identity, and exact-ACE changes. diff --git a/tests/Unit/Lab/Invoke-WindowsAccessControlLabAcceptance.Tests.ps1 b/tests/Unit/Lab/Invoke-WindowsAccessControlLabAcceptance.Tests.ps1 index 47c932e..b49d110 100644 --- a/tests/Unit/Lab/Invoke-WindowsAccessControlLabAcceptance.Tests.ps1 +++ b/tests/Unit/Lab/Invoke-WindowsAccessControlLabAcceptance.Tests.ps1 @@ -156,6 +156,9 @@ Describe 'Domain lab host evidence collection' -Tag 'Unit', 'WindowsOnly' { Mock Invoke-LabCommand { [pscustomobject]@{ ExitCode = 0; Output = @(); ConsoleLogPath = 'test-console.log' } } + Mock Invoke-LabCommand { [string]$ArgumentList[-1] } -ParameterFilter { + $ActivityName -eq 'Validate the acceptance payload directory' + } Mock New-LabPSSession { New-MockObject -Type ([System.Management.Automation.Runspaces.PSSession]) } @@ -232,3 +235,369 @@ Describe 'Domain lab host evidence collection' -Tag 'Unit', 'WindowsOnly' { $firstCoverage.RemotePath | Should -Not -BeExactly $secondCoverage.RemotePath } } + +Describe 'Domain lab runner directory ownership' -Tag 'Unit', 'WindowsOnly' { + BeforeAll { + $script:acceptanceScript = Join-Path $PSScriptRoot '..\..\Lab\Invoke-WindowsAccessControlLabAcceptance.ps1' + . (Join-Path $PSScriptRoot '..\..\Lab\WindowsAccessControl.LabRunnerOwnership.ps1') + $script:markerFileName = '.windows-access-control-lab-runner' + + function Import-Lab { + [CmdletBinding()] + param([string]$Name, [switch]$NoValidation) + throw "Unexpected real lab import '$Name' (NoValidation=$NoValidation)." + } + + function Invoke-LabCommand { + [CmdletBinding()] + param( + [string]$ComputerName, + [string]$ActivityName, + [scriptblock]$ScriptBlock, + [object[]]$ArgumentList, + [switch]$PassThru, + [switch]$NoDisplay + ) + throw ( + "Unexpected real lab command '$ActivityName' on '$ComputerName' " + + "(arguments=$($ArgumentList.Count), callback=$($null -ne $ScriptBlock), " + + "PassThru=$PassThru, NoDisplay=$NoDisplay)." + ) + } + + function Copy-LabFileItem { + [CmdletBinding()] + param([string]$Path, [string]$ComputerName, [string]$DestinationFolderPath, [switch]$Recurse) + throw "Unexpected real lab copy of '$Path' to '$DestinationFolderPath' on '$ComputerName' (Recurse=$Recurse)." + } + + function New-LabPSSession { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This test stub never creates a session.' + )] + [CmdletBinding()] + param([string]$ComputerName) + throw "Unexpected real lab session for '$ComputerName'." + } + + function New-TestRunnerMarker { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This test helper writes only below TestDrive.' + )] + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Directory) + + $marker = Get-WindowsAccessControlLabRunnerMarker + [IO.File]::WriteAllText( + (Join-Path $Directory $marker.FileName), + $marker.Text, + [Text.UTF8Encoding]::new($false) + ) + } + + $script:remoteStepFilter = { + $ActivityName -in @( + 'Validate the acceptance payload directory' + 'Reset the acceptance payload directory' + 'Reset the package staging directory' + 'Install the packaged module into the machine module path' + ) + } + + # Runs one remote step the way remoting does: from the text of its script + # block alone, in a fresh runspace that holds none of the test's + # functions. A non-null protected list stands in for the remote + # machine's own protected directories, so these cases never evaluate + # the real list; WindowsAccessControl.LabRunnerOwnership.Tests.ps1 + # proves that list against this machine. + function Invoke-TestRemoteStep { + param( + [scriptblock]$ScriptBlock, + [object[]]$ArgumentList, + [AllowNull()][string[]]$ProtectedPath + ) + + $shell = [powershell]::Create() + try { + $null = $shell.AddScript({ + param($StepText, $StepArguments, $TestProtectedPath) + + if ($null -ne $TestProtectedPath) { + $protectedPathStub = { $TestProtectedPath }.GetNewClosure() + Set-Item -Path 'function:global:Get-WacTestProtectedPath' -Value $protectedPathStub + Set-Alias -Name Get-WindowsAccessControlLabRunnerProtectedPath -Value Get-WacTestProtectedPath -Scope Global + } + & ([scriptblock]::Create($StepText)) @StepArguments + }.ToString()) + $null = $shell.AddArgument($ScriptBlock.ToString()) + $null = $shell.AddArgument($ArgumentList) + $null = $shell.AddArgument($ProtectedPath) + $shell.Invoke() + } + finally { + $shell.Dispose() + } + } + + $packageSource = Join-Path $TestDrive 'PackageSource' + $null = New-Item -Path (Join-Path $packageSource '_rels') -ItemType Directory -Force + New-ModuleManifest -Path (Join-Path $packageSource 'WindowsAccessControl.psd1') -ModuleVersion '91.0.0' + Set-Content -LiteralPath (Join-Path $packageSource '_rels\.rels') -Value '' + Set-Content -LiteralPath (Join-Path $packageSource '[Content_Types].xml') -Value '' + Set-Content -LiteralPath (Join-Path $packageSource 'WindowsAccessControl.nuspec') -Value '' + $script:packagePath = Join-Path $TestDrive 'WindowsAccessControl.91.0.0.nupkg' + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($packageSource, $script:packagePath) + } + + BeforeEach { + $script:remoteRoot = [IO.Path]::GetFullPath((Join-Path $TestDrive ([guid]::NewGuid().ToString('N')))) + $script:payloadRoot = Join-Path $script:remoteRoot 'WacRepo' + $script:packageDirectory = Join-Path $script:payloadRoot 'package' + $script:programFiles = Join-Path $script:remoteRoot 'ProgramFiles' + $script:installTarget = Join-Path $script:programFiles 'WindowsPowerShell\Modules\WindowsAccessControl\91.0.0' + $null = New-Item -Path $script:programFiles -ItemType Directory -Force + + # Every remote step of these tests writes below TestDrive, never below + # the real Program Files. Mock bodies run inside the runner script, + # where $script: names the runner's scope, so the values they read are + # plain variables of this scope. + $script:originalProgramFiles = $env:ProgramFiles + $env:ProgramFiles = $script:programFiles + $testProtectedPath = @( + [Environment]::GetFolderPath('Windows') + $script:originalProgramFiles + [Environment]::GetFolderPath('CommonApplicationData') + ) + + $script:runnerParameters = @{ + RemoteRepositoryPath = $script:payloadRoot + PowerShellEdition = 'Core' + CoverageEdition = 'None' + EvidencePath = Join-Path $script:remoteRoot 'evidence.json' + CoverageEvidencePath = Join-Path $script:remoteRoot 'coverage.xml' + Confirm = $false + WarningAction = 'SilentlyContinue' + InformationAction = 'SilentlyContinue' + ErrorAction = 'Stop' + } + + Mock Import-Module { } + Mock Import-Lab { } + Mock Copy-LabFileItem { + if ($Path -like '*.nupkg') { + [IO.File]::Copy($Path, (Join-Path $DestinationFolderPath (Split-Path -Path $Path -Leaf))) + } + } + Mock Invoke-LabCommand { + Invoke-TestRemoteStep -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ProtectedPath $testProtectedPath + } -ParameterFilter $script:remoteStepFilter + Mock Invoke-LabCommand { + [pscustomobject]@{ ExitCode = 0; Output = @(); ConsoleLogPath = 'test-console.log' } + } -ParameterFilter { $ActivityName -like 'WindowsAccessControl domain-lab acceptance*' } + Mock New-LabPSSession { + New-MockObject -Type ([System.Management.Automation.Runspaces.PSSession]) + } + Mock Remove-PSSession { } + Mock Copy-Item { + [pscustomobject]@{ + Format = 'WindowsAccessControl.DomainLabAcceptance' + SchemaVersion = 1 + Result = 'Passed' + } | ConvertTo-Json | Set-Content -LiteralPath $Destination + } -ParameterFilter { $null -ne $FromSession } + } + + AfterEach { + $env:ProgramFiles = $script:originalProgramFiles + } + + It 'Should refuse to delete an unmarked payload root and stop before copying the payload' { + $null = New-Item -Path $script:payloadRoot -ItemType Directory -Force + $sentinel = Join-Path $script:payloadRoot 'operator-data.txt' + Set-Content -LiteralPath $sentinel -Value 'not created by the runner' -NoNewline + + { & $script:acceptanceScript @script:runnerParameters } | + Should -Throw "*Refusing to replace '$($script:payloadRoot)'*-RemoteRepositoryPath*" + + Get-Content -LiteralPath $sentinel -Raw | Should -BeExactly 'not created by the runner' + Should -Invoke Copy-LabFileItem -Exactly -Times 0 + Should -Invoke Invoke-LabCommand -Exactly -Times 0 -ParameterFilter { + $ActivityName -like 'WindowsAccessControl domain-lab acceptance*' + } + } + + It 'Should create and mark an absent payload root' { + $null = & $script:acceptanceScript @script:runnerParameters + + Join-Path $script:payloadRoot $script:markerFileName | Should -Exist + (Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:payloadRoot).State | Should -BeExactly 'Owned' + Join-Path $script:payloadRoot 'output' | Should -Exist + Should -Invoke Copy-LabFileItem -Exactly -Times 3 + } + + It 'Should replace a marked payload root with a fresh marked payload root' { + $null = New-Item -Path (Join-Path $script:payloadRoot 'tests') -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path $script:payloadRoot 'tests\stale.ps1') -Value '# stale' + New-TestRunnerMarker -Directory $script:payloadRoot + + $null = & $script:acceptanceScript @script:runnerParameters + + Join-Path $script:payloadRoot 'tests\stale.ps1' | Should -Not -Exist + Join-Path $script:payloadRoot $script:markerFileName | Should -Exist + } + + It 'Should reject a payload root under a protected directory of the remote machine before any reset' { + $testProtectedDirectory = Join-Path $script:remoteRoot 'Protected' + Mock Invoke-LabCommand { + Invoke-TestRemoteStep -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ProtectedPath @($testProtectedDirectory) + } -ParameterFilter $script:remoteStepFilter + $script:runnerParameters.RemoteRepositoryPath = Join-Path $testProtectedDirectory 'WacRepo' + + { & $script:acceptanceScript @script:runnerParameters } | + Should -Throw "*lies under the protected directory '$testProtectedDirectory'*" + + Join-Path $testProtectedDirectory 'WacRepo' | Should -Not -Exist + Should -Invoke Invoke-LabCommand -Exactly -Times 0 -ParameterFilter { $ActivityName -like 'Reset*' } + Should -Invoke Copy-LabFileItem -Exactly -Times 0 + } + + It 'Should reject a protected payload root even when it reuses the deployed payload' { + $testProtectedDirectory = Join-Path $script:remoteRoot 'Protected' + Mock Invoke-LabCommand { + Invoke-TestRemoteStep -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ProtectedPath @($testProtectedDirectory) + } -ParameterFilter $script:remoteStepFilter + $script:runnerParameters.RemoteRepositoryPath = Join-Path $testProtectedDirectory 'WacRepo' + $script:runnerParameters.SkipPayloadDeployment = $true + + { & $script:acceptanceScript @script:runnerParameters } | + Should -Throw "*lies under the protected directory '$testProtectedDirectory'*" + + Should -Invoke Invoke-LabCommand -Exactly -Times 0 -ParameterFilter { + $ActivityName -like 'WindowsAccessControl domain-lab acceptance*' + } + } + + It 'Should refuse an unmarked package staging directory when it reuses the deployed payload' { + $null = New-Item -Path $script:packageDirectory -ItemType Directory -Force + $previousPackage = Join-Path $script:packageDirectory 'WindowsAccessControl.0.0.1.nupkg' + Set-Content -LiteralPath $previousPackage -Value 'previous package' -NoNewline + $script:runnerParameters.SkipPayloadDeployment = $true + $script:runnerParameters.ModuleSource = 'Installed' + $script:runnerParameters.PackagePath = $script:packagePath + + { & $script:acceptanceScript @script:runnerParameters } | + Should -Throw "*Refusing to replace '$($script:packageDirectory)'*" + + Get-Content -LiteralPath $previousPackage -Raw | Should -BeExactly 'previous package' + Should -Invoke Copy-LabFileItem -Exactly -Times 0 + Should -Invoke Invoke-LabCommand -Exactly -Times 0 -ParameterFilter { + $ActivityName -eq 'Install the packaged module into the machine module path' + } + } + + It 'Should refuse to replace an unmarked installation of the same module version' { + $null = New-Item -Path $script:installTarget -ItemType Directory -Force + $existingModule = Join-Path $script:installTarget 'WindowsAccessControl.psm1' + Set-Content -LiteralPath $existingModule -Value '# installed by someone else' -NoNewline + $script:runnerParameters.ModuleSource = 'Installed' + $script:runnerParameters.PackagePath = $script:packagePath + + { & $script:acceptanceScript @script:runnerParameters } | + Should -Throw "*Refusing to replace '$($script:installTarget)'*different module version*" + + Get-Content -LiteralPath $existingModule -Raw | Should -BeExactly '# installed by someone else' + Join-Path $script:installTarget $script:markerFileName | Should -Not -Exist + Should -Invoke Invoke-LabCommand -Exactly -Times 0 -ParameterFilter { + $ActivityName -like 'WindowsAccessControl domain-lab acceptance*' + } + } + + It 'Should mark the module version directory it installs and leave out the package artifacts' { + $script:runnerParameters.ModuleSource = 'Installed' + $script:runnerParameters.PackagePath = $script:packagePath + + $null = & $script:acceptanceScript @script:runnerParameters + + @(Get-ChildItem -LiteralPath $script:installTarget -Force | ForEach-Object Name | Sort-Object) | + Should -Be @($script:markerFileName, 'WindowsAccessControl.psd1') + (Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:installTarget).State | + Should -BeExactly 'Owned' + Should -Invoke Invoke-LabCommand -Exactly -Times 1 -ParameterFilter { + $ActivityName -like 'WindowsAccessControl domain-lab acceptance*' -and + $ArgumentList[-1] -eq $script:installTarget + } + } + + It 'Should replace a same-version installation that carries the runner marker' { + $null = New-Item -Path $script:installTarget -ItemType Directory -Force + New-TestRunnerMarker -Directory $script:installTarget + Set-Content -LiteralPath (Join-Path $script:installTarget 'stale.psm1') -Value '# stale' + $script:runnerParameters.ModuleSource = 'Installed' + $script:runnerParameters.PackagePath = $script:packagePath + + $null = & $script:acceptanceScript @script:runnerParameters + + Join-Path $script:installTarget 'stale.psm1' | Should -Not -Exist + Join-Path $script:installTarget 'WindowsAccessControl.psd1' | Should -Exist + Join-Path $script:installTarget $script:markerFileName | Should -Exist + } + + It "Should stop when '' does not confirm its directory" -ForEach @( + @{ Activity = 'Validate the acceptance payload directory' } + @{ Activity = 'Reset the acceptance payload directory' } + @{ Activity = 'Reset the package staging directory' } + @{ Activity = 'Install the packaged module into the machine module path' } + ) { + # AutomatedLab can report a remote failure as a non-terminating error + # and return no output, so a silent step stands in for that failure. + $testSilentActivity = $Activity + Mock Invoke-LabCommand { } -ParameterFilter { $ActivityName -eq $testSilentActivity } + $script:runnerParameters.ModuleSource = 'Installed' + $script:runnerParameters.PackagePath = $script:packagePath + + { & $script:acceptanceScript @script:runnerParameters } | + Should -Throw "*'$Activity'*did not confirm*" + + Should -Invoke Invoke-LabCommand -Exactly -Times 0 -ParameterFilter { + $ActivityName -like 'WindowsAccessControl domain-lab acceptance*' + } + } + + It 'Should delete directories recursively only inside the owned package staging tree' { + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $script:acceptanceScript, [ref]$tokens, [ref]$errors + ) + $errors | Should -BeNullOrEmpty + + $recursiveRemovals = @($ast.FindAll({ + param($node) + + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -in @('Remove-Item', 'ri', 'rm', 'rmdir', 'rd', 'del', 'erase') -and + @($node.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.CommandParameterAst] -and + 'recurse'.StartsWith($_.ParameterName.ToLowerInvariant()) + }).Count -gt 0 + }, $true)) + + $recursiveRemovals.Count | Should -BeGreaterThan 0 + foreach ($removal in $recursiveRemovals) { + $elements = @($removal.CommandElements) + $pathIndex = [array]::FindIndex($elements, [Predicate[object]] { + param($element) + $element -is [System.Management.Automation.Language.CommandParameterAst] -and + $element.ParameterName -eq 'LiteralPath' + }) + $pathIndex | Should -BeGreaterThan 0 -Because "'$($removal.Extent.Text)' must name its target literally" + $elements[$pathIndex + 1].Extent.Text | + Should -Match '^(?:\$staging|\(Join-Path \$staging \$artifact\))$' -Because ( + "'$($removal.Extent.Text)' must delete only inside the owned package staging tree" + ) + } + } +} diff --git a/tests/Unit/Lab/WindowsAccessControl.LabRunnerOwnership.Tests.ps1 b/tests/Unit/Lab/WindowsAccessControl.LabRunnerOwnership.Tests.ps1 new file mode 100644 index 0000000..0dd9b64 --- /dev/null +++ b/tests/Unit/Lab/WindowsAccessControl.LabRunnerOwnership.Tests.ps1 @@ -0,0 +1,360 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\..\Lab\WindowsAccessControl.LabRunnerOwnership.ps1') + + function New-TestRunnerMarker { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This test helper writes only below TestDrive.' + )] + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Directory) + + $marker = Get-WindowsAccessControlLabRunnerMarker + [IO.File]::WriteAllText( + (Join-Path $Directory $marker.FileName), + $marker.Text, + [Text.UTF8Encoding]::new($false) + ) + } +} + +Describe 'Lab runner payload root rules' -Tag 'Unit', 'WindowsOnly' { + BeforeAll { + # Cases that test another rule still pass a real protected list, because + # an empty list is itself refused. + $script:protectedRoot = Join-Path $TestDrive 'Protected' + $null = New-Item -Path $script:protectedRoot -ItemType Directory -Force + } + + It 'Should reject a payload root that is not an absolute local path: ' -ForEach @( + @{ Case = 'empty'; Path = '' } + @{ Case = 'relative'; Path = 'WacRepo' } + @{ Case = 'drive-relative'; Path = 'C:WacRepo' } + @{ Case = 'rooted without a drive'; Path = '\WacRepo' } + @{ Case = 'UNC'; Path = '\\server\share\WacRepo' } + @{ Case = 'device'; Path = '\\?\C:\WacRepo' } + @{ Case = 'provider-qualified'; Path = 'Microsoft.PowerShell.Core\FileSystem::C:\WacRepo' } + @{ Case = 'registry'; Path = 'HKLM:\SOFTWARE\WacRepo' } + ) { + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path $Path -ProtectedPath @($script:protectedRoot) } | + Should -Throw '*is not an absolute local path*' + } + + It 'Should reject a payload root that contains ' -ForEach @( + @{ Case = 'a wildcard'; Path = 'C:\Wac*' } + @{ Case = 'a question mark'; Path = 'C:\Wac?Repo' } + @{ Case = 'an opening bracket'; Path = 'C:\Wac[1' } + @{ Case = 'a bracket pair'; Path = 'C:\Wac[1]' } + @{ Case = 'a pipe'; Path = 'C:\Wac|Repo' } + @{ Case = 'an alternate data stream'; Path = 'C:\WacRepo:stream' } + ) { + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path $Path -ProtectedPath @($script:protectedRoot) } | + Should -Throw '*contains a character that a payload root must not use*' + } + + It 'Should reject the drive root ' -ForEach @( + @{ Path = 'C:\' } + @{ Path = 'C:/' } + @{ Path = 'C:\WacRepo\..' } + ) { + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path $Path -ProtectedPath @($script:protectedRoot) } | + Should -Throw '*is a drive root*' + } + + It 'Should reject a payload root on a drive letter that is not a local fixed drive' { + # A unit test cannot attach a network or removable drive, so an + # unassigned letter stands in for every drive type other than Fixed. + $usedLetters = @([IO.DriveInfo]::GetDrives() | ForEach-Object { $_.Name.Substring(0, 1).ToUpperInvariant() }) + $unusedLetter = [char[]]([int][char]'D'..[int][char]'Z') | + Where-Object { [string]$_ -notin $usedLetters } | + Select-Object -Last 1 + $unusedLetter | Should -Not -BeNullOrEmpty + + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path "$($unusedLetter):\WacRepo" -ProtectedPath @($script:protectedRoot) } | + Should -Throw '*is not on a local fixed drive*' + } + + It 'Should refuse a protected-directory list rather than skip the check' -ForEach @( + @{ Case = 'that is empty'; ProtectedList = @() } + @{ Case = 'with a blank entry'; ProtectedList = @(' ') } + ) { + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path 'C:\WacRepo' -ProtectedPath $ProtectedList } | + Should -Throw "*ProtectedPath*" + } + + It 'Should reject of a protected directory' -ForEach @( + @{ Case = 'the directory itself'; Child = '' } + @{ Case = 'a child'; Child = 'WacRepo' } + @{ Case = 'a differently cased descendant'; Child = 'WACREPO\Deeper' } + @{ Case = 'a path that climbs back into it'; Child = 'WacRepo\..\..\Protected\WacRepo' } + ) { + $candidate = if ($Child) { Join-Path $script:protectedRoot $Child } else { $script:protectedRoot } + + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path $candidate -ProtectedPath @($script:protectedRoot) } | + Should -Throw "*lies under the protected directory*" + } + + It 'Should accept a sibling whose name only starts with a protected directory name' { + $sibling = "$($script:protectedRoot)Sibling\WacRepo" + + $result = Assert-WindowsAccessControlLabRunnerPayloadPath -Path $sibling -ProtectedPath @("$($script:protectedRoot)\") + + $result | Should -BeExactly ([IO.Path]::GetFullPath($sibling)) + } + + It 'Should return the normalized payload root for an absent directory' { + $candidate = Join-Path $TestDrive 'Accepted\Nested\..\WacRepo\' + + $result = Assert-WindowsAccessControlLabRunnerPayloadPath -Path ($candidate -replace '\\', '/') -ProtectedPath @($script:protectedRoot) + + $result | Should -BeExactly ([IO.Path]::GetFullPath((Join-Path $TestDrive 'Accepted\WacRepo'))) + } + + It 'Should reject a payload root that is, or lies under, a junction' { + $target = Join-Path $TestDrive 'JunctionTarget' + $link = Join-Path $TestDrive 'JunctionLink' + $null = New-Item -Path $target -ItemType Directory -Force + $null = New-Item -Path $link -ItemType Junction -Target $target + try { + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path $link -ProtectedPath @($script:protectedRoot) } | + Should -Throw '*junction or symbolic link*' + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path (Join-Path $link 'WacRepo') -ProtectedPath @($script:protectedRoot) } | + Should -Throw '*junction or symbolic link*' + } + finally { + [IO.Directory]::Delete($link) + } + } + + It 'Should name the machine that evaluated the payload root' { + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path 'WacRepo' -ProtectedPath @($script:protectedRoot) } | + Should -Throw "*on '$env:COMPUTERNAME'*" + } +} + +Describe 'Lab runner protected directories of the evaluating machine' -Tag 'Unit', 'WindowsOnly' { + It 'Should protect the of this machine' -ForEach @( + @{ Name = 'Windows directory'; Directory = [Environment]::GetFolderPath('Windows') } + @{ Name = 'Program Files directory'; Directory = [Environment]::GetFolderPath('ProgramFiles') } + @{ Name = 'ProgramData directory'; Directory = [Environment]::GetFolderPath('CommonApplicationData') } + @{ + Name = 'user profile root' + Directory = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -Name ProfilesDirectory).ProfilesDirectory + } + ) { + $protected = @(Get-WindowsAccessControlLabRunnerProtectedPath) + + $protected | Should -Contain ([IO.Path]::GetFullPath($Directory).TrimEnd('\')) + } + + It 'Should reject a payload root under without an explicit protected list' -ForEach @( + @{ Name = 'the Windows directory'; Directory = $env:SystemRoot } + @{ Name = 'Program Files'; Directory = $env:ProgramFiles } + @{ Name = 'Program Files (x86)'; Directory = ${env:ProgramFiles(x86)} } + @{ Name = 'ProgramData'; Directory = $env:ProgramData } + @{ Name = 'the current user profile'; Directory = $env:USERPROFILE } + ) { + $candidate = Join-Path $Directory 'WindowsAccessControlLabRunnerUnitTest' + + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path $candidate } | + Should -Throw '*lies under the protected directory*' + } + + It 'Should reject Program Files by its 8.3 short name where the volume records one' { + $fileSystem = New-Object -ComObject Scripting.FileSystemObject + try { + $shortProgramFiles = $fileSystem.GetFolder($env:ProgramFiles).ShortPath + } + finally { + $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($fileSystem) + } + $shortProgramFiles | Should -Not -BeNullOrEmpty + if ($shortProgramFiles -eq $env:ProgramFiles) { + # Without a recorded short name there is no alias to test, and the + # long-name case is already covered above. + Set-ItResult -Skipped -Because 'the system volume records no 8.3 name for Program Files' + return + } + + { Assert-WindowsAccessControlLabRunnerPayloadPath -Path (Join-Path $shortProgramFiles 'WacRepo') } | + Should -Throw '*lies under the protected directory*' + } +} + +Describe 'Lab runner directory ownership' -Tag 'Unit', 'WindowsOnly' { + BeforeEach { + $script:directory = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + } + + It 'Should name a fixed marker file that the runner documentation can refer to' { + $marker = Get-WindowsAccessControlLabRunnerMarker + + $marker.FileName | Should -BeExactly '.windows-access-control-lab-runner' + $marker.Text | Should -Not -BeNullOrEmpty + } + + It 'Should report an absent directory as absent' { + (Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory).State | + Should -BeExactly 'Absent' + } + + It 'Should report a marked directory as owned' { + $null = New-Item -Path $script:directory -ItemType Directory + New-TestRunnerMarker -Directory $script:directory + + (Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory).State | + Should -BeExactly 'Owned' + } + + It 'Should report as unowned' -ForEach @( + @{ Case = 'a directory without a marker'; MarkerText = $null; Reason = '*carries no*marker*' } + @{ Case = 'a directory whose marker holds other text'; MarkerText = 'Managed by someone else.'; Reason = '*does not hold the runner*' } + ) { + $null = New-Item -Path $script:directory -ItemType Directory + if ($MarkerText) { + $markerFile = Join-Path $script:directory (Get-WindowsAccessControlLabRunnerMarker).FileName + Set-Content -LiteralPath $markerFile -Value $MarkerText -NoNewline + } + + $ownership = Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory + + $ownership.State | Should -BeExactly 'Unowned' + $ownership.Reason | Should -BeLike $Reason + } + + It 'Should report a file at the path as unowned' { + Set-Content -LiteralPath $script:directory -Value 'not a directory' -NoNewline + + $ownership = Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory + + $ownership.State | Should -BeExactly 'Unowned' + $ownership.Reason | Should -BeLike '*not a directory*' + } + + It 'Should report a junction to a marked directory as unowned' { + $target = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $target -ItemType Directory + New-TestRunnerMarker -Directory $target + $null = New-Item -Path $script:directory -ItemType Junction -Target $target + try { + $ownership = Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory + + $ownership.State | Should -BeExactly 'Unowned' + $ownership.Reason | Should -BeLike '*junction or symbolic link*' + } + finally { + [IO.Directory]::Delete($script:directory) + } + } + + It 'Should report a marked directory that contains a junction as unowned' { + $outside = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $outside -ItemType Directory + $null = New-Item -Path (Join-Path $script:directory 'tests\nested') -ItemType Directory -Force + New-TestRunnerMarker -Directory $script:directory + $link = Join-Path $script:directory 'tests\nested\outside' + $null = New-Item -Path $link -ItemType Junction -Target $outside + try { + $ownership = Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory + + $ownership.State | Should -BeExactly 'Unowned' + $ownership.Reason | Should -BeLike "*contains the junction or symbolic link '$link'*" + } + finally { + [IO.Directory]::Delete($link) + } + } +} + +Describe 'Lab runner directory reset' -Tag 'Unit', 'WindowsOnly' { + BeforeEach { + $script:directory = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $script:resolution = 'Inspect it, then remove it yourself.' + } + + It 'Should refuse an unmarked directory before deleting anything and name the path and the resolution' { + $null = New-Item -Path $script:directory -ItemType Directory + $sentinel = Join-Path $script:directory 'operator-data.txt' + Set-Content -LiteralPath $sentinel -Value 'not created by the runner' -NoNewline + + { Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution } | + Should -Throw "*Refusing to replace '$($script:directory)'*carries no*marker*$($script:resolution)*" + + Get-Content -LiteralPath $sentinel -Raw | Should -BeExactly 'not created by the runner' + Join-Path $script:directory (Get-WindowsAccessControlLabRunnerMarker).FileName | Should -Not -Exist + } + + It 'Should refuse a file at the path and keep it' { + Set-Content -LiteralPath $script:directory -Value 'operator file' -NoNewline + + { Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution } | + Should -Throw '*not a directory*' + + Get-Content -LiteralPath $script:directory -Raw | Should -BeExactly 'operator file' + } + + It 'Should refuse a marked directory that contains a junction and keep what the junction reaches' { + $outside = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path $outside -ItemType Directory + $outsideFile = Join-Path $outside 'not-runner-owned.txt' + Set-Content -LiteralPath $outsideFile -Value 'outside the runner tree' -NoNewline + $null = New-Item -Path $script:directory -ItemType Directory + New-TestRunnerMarker -Directory $script:directory + $link = Join-Path $script:directory 'outside' + $null = New-Item -Path $link -ItemType Junction -Target $outside + try { + { Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution } | + Should -Throw "*Refusing to replace '$($script:directory)'*contains the junction or symbolic link*" + + Get-Content -LiteralPath $outsideFile -Raw | Should -BeExactly 'outside the runner tree' + $link | Should -Exist + } + finally { + [IO.Directory]::Delete($link) + } + } + + It 'Should create an absent directory and write the marker on creation' { + Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution + + $marker = Get-WindowsAccessControlLabRunnerMarker + $markerFile = Join-Path $script:directory $marker.FileName + $markerFile | Should -Exist + [IO.File]::ReadAllText($markerFile) | Should -BeExactly $marker.Text + @(Get-ChildItem -LiteralPath $script:directory -Force).Name | Should -Be @($marker.FileName) + (Get-WindowsAccessControlLabRunnerDirectoryState -Path $script:directory).State | Should -BeExactly 'Owned' + } + + It 'Should replace a marked directory with an empty marked directory' { + $null = New-Item -Path (Join-Path $script:directory 'stale\nested') -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path $script:directory 'stale\nested\old.txt') -Value 'old payload' + New-TestRunnerMarker -Directory $script:directory + + Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution + + @(Get-ChildItem -LiteralPath $script:directory -Force).Name | + Should -Be @((Get-WindowsAccessControlLabRunnerMarker).FileName) + } + + It 'Should remove the directory it created when the marker cannot be written' { + Mock Get-WindowsAccessControlLabRunnerMarker { + [pscustomobject]@{ FileName = 'invalid|marker'; Text = 'unused' } + } + + { Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution } | + Should -Throw + + $script:directory | Should -Not -Exist + } + + It 'Should change nothing under WhatIf' { + $null = New-Item -Path $script:directory -ItemType Directory + New-TestRunnerMarker -Directory $script:directory + $kept = Join-Path $script:directory 'kept.txt' + Set-Content -LiteralPath $kept -Value 'kept' -NoNewline + + Reset-WindowsAccessControlLabRunnerDirectory -Path $script:directory -Resolution $script:resolution -WhatIf + + $kept | Should -Exist + } +} From 553182478960c03ac147b137dddaa39d8c53b532 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 14:15:26 +0000 Subject: [PATCH 04/20] fix(taskscheduler): allow repairing a DACL without a Local System ACE Test-WindowsTaskSchedulerSystemAce refused every candidate when the current DACL held no literal Local System ACE, so a write that restored SYSTEM was rejected. Specification 0010 requires only that each current Local System ACE stays byte-identical and that no Local System deny ACE is added. History shows no intent behind the extra refusal: it arrived with 442162a without a decision record, spec text, or test. A current descriptor whose DACL is missing or null is still refused, because neither preservation nor an exact rollback can be verified against it; the setter now says so in its own message. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + docs/usage/task-scheduler.md | 7 + docs/usage/troubleshooting.md | 2 +- ...WindowsTaskSchedulerSecurityDescriptor.ps1 | 5 + .../Test-WindowsTaskSchedulerSystemAce.ps1 | 9 +- specs/0010-task-scheduler-dacl-management.md | 21 ++- ...indowsTaskSchedulerAccessControl.Tests.ps1 | 122 ++++++++++++++++++ 7 files changed, 161 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92f244..1716f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,6 +576,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Accept a Task Scheduler DACL write that restores Local System to a folder or + task whose DACL has no Local System ACE; such repairs were refused even + though every current Local System ACE was preserved. A current DACL that + is missing or null is still refused, now with its own message + ([specification](specs/0010-task-scheduler-dacl-management.md#persistence-and-safety)) - Stop the domain-lab acceptance runner from deleting directories it did not create: it marks the payload root, its `package` staging directory, and the module version directory it installs, refuses an existing one that is diff --git a/docs/usage/task-scheduler.md b/docs/usage/task-scheduler.md index 250e3b9..2eab063 100644 --- a/docs/usage/task-scheduler.md +++ b/docs/usage/task-scheduler.md @@ -16,11 +16,18 @@ Every mutator requires an explicit `AllowedRootPath`, so a configuration states its own containment boundary. On top of that, a write is rejected when it: - targets the scheduler root or the `\Microsoft` tree; +- finds the current DACL missing or null, so Local System preservation cannot + be verified; - fails to retain the current literal Local System ACEs; - adds an explicit Local System deny ACE; - newly denies any identity in the Task Scheduler service token; or - contains an object or compound ACE that the store would silently re-revision. +A target whose DACL has no Local System ACE accepts any candidate without a +Local System deny ACE, so restoring Local System repairs it. The module does +not insist on that repair: include a Local System allow ACE in the candidate, +or Task Scheduler can lose access to the target. + Task Scheduler may reorder ACEs and add its own derived auto-inherited flag after a write. The module verifies the native ACE set and the caller-controlled protection state after persistence rather than demanding byte equality. diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index 9c31126..13b1da2 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -21,7 +21,7 @@ tables below map a symptom to the rule that produced it. | A share command rejects the share | Administrative, drive, IPC, print, clustered, and continuously available shares are out of scope | | A directory write is refused as out of bounds | The target is outside `AllowedBaseDistinguishedName` | | A directory edit disappeared | Two controllers converged on one survivor; pin one `Server` for both writes | -| A task write is refused | The target is the scheduler root or `\Microsoft`, or the candidate removes or denies Local System | +| A task write is refused | The target is the scheduler root or `\Microsoft`, the candidate removes or denies Local System, or the current DACL is missing or null | | A private-key write is refused | Run `Test-CertificatePrivateKeyCriticalBinding` to see which binding blocks it | | The DSC LCM cannot find the resource | Install the module in a machine-wide module path visible to the SYSTEM process | | A remote target is rejected | Enter a remote session and run the command locally on the destination computer | diff --git a/source/Private/Set-WindowsTaskSchedulerSecurityDescriptor.ps1 b/source/Private/Set-WindowsTaskSchedulerSecurityDescriptor.ps1 index 99f7fb1..c9d256a 100644 --- a/source/Private/Set-WindowsTaskSchedulerSecurityDescriptor.ps1 +++ b/source/Private/Set-WindowsTaskSchedulerSecurityDescriptor.ps1 @@ -40,6 +40,11 @@ function Set-WindowsTaskSchedulerSecurityDescriptor { $currentSddl = [string]$NativeTarget.GetSecurityDescriptor(4) $current = [Security.AccessControl.RawSecurityDescriptor]::new($currentSddl) + if (-not $current.DiscretionaryAcl) { + throw [InvalidOperationException]::new( + 'The current Task Scheduler DACL is missing or null, so its Local System ACEs and an exact rollback cannot be verified.' + ) + } if ($expected -and -not (Test-WindowsTaskSchedulerDaclEquivalent ` -Left $current ` -Right $expected)) { diff --git a/source/Private/Test-WindowsTaskSchedulerSystemAce.ps1 b/source/Private/Test-WindowsTaskSchedulerSystemAce.ps1 index 8acb30c..8e232e1 100644 --- a/source/Private/Test-WindowsTaskSchedulerSystemAce.ps1 +++ b/source/Private/Test-WindowsTaskSchedulerSystemAce.ps1 @@ -10,6 +10,12 @@ function Test-WindowsTaskSchedulerSystemAce { ) $systemSid = 'S-1-5-18' + # Without a current DACL neither Local System preservation nor an exact + # rollback can be verified, so a missing or null DACL fails closed. A DACL + # that merely lacks a Local System ACE has nothing to preserve. + if (-not $CurrentDescriptor.DiscretionaryAcl) { + return $false + } $getAceIdentity = { param($Ace) @@ -37,9 +43,6 @@ function Test-WindowsTaskSchedulerSystemAce { ), [StringComparer]::Ordinal ) - if ($currentSystemAces.Count -eq 0) { - return $false - } foreach ($ace in $CandidateDescriptor.DiscretionaryAcl) { $knownAce = $ace -as [Security.AccessControl.KnownAce] $qualifiedAce = $ace -as [Security.AccessControl.QualifiedAce] diff --git a/specs/0010-task-scheduler-dacl-management.md b/specs/0010-task-scheduler-dacl-management.md index 4b66a4a..4ba5ee2 100644 --- a/specs/0010-task-scheduler-dacl-management.md +++ b/specs/0010-task-scheduler-dacl-management.md @@ -80,7 +80,14 @@ Task Scheduler. The adapter enables no privilege and sends no outbound request. - `ShouldProcess` gates every write with high confirmation impact. - The target is resolved again inside the operation-scoped COM boundary. - Every current literal Local System ACE must remain byte-identical, and the - candidate must not add an explicit Local System deny ACE. + candidate must not add an explicit Local System deny ACE. A current DACL + without a Local System ACE has nothing to preserve, so any candidate without + a Local System deny ACE is accepted, including one that restores Local + System. Such a candidate need not grant Local System; if it also drops every + other allow ACE that covers the service token, Task Scheduler can lose access + to the target. A current descriptor whose DACL is missing or null is + refused, because neither Local System preservation nor an exact rollback can + be verified against it. - A write is rejected when the candidate newly denies any identity in the Task Scheduler service token and the denied mask intersects the read, write, or run access the service requires. The evaluated set is the live LocalSystem @@ -125,11 +132,13 @@ Task Scheduler. The adapter enables no privilege and sends no outbound request. ## Verification Unit tests cover path normalization, containment, system-tree rejection, COM -cleanup after failure, setter flags, Local System preservation, service-token -deny rejection, object-ACE rejection, stale-descriptor rejection, the two -rights-model mask tables, `AppliesTo` flag conversion, inheritance-sensitive -duplicate detection, inherited-rule rejection, canonical-identity revalidation, -and canonical DACL comparison. Public contract tests cover all ten exports. +cleanup after failure, setter flags, Local System preservation, repair of a +DACL without Local System, refusal of a missing or null current DACL, +service-token deny rejection, object-ACE rejection, stale-descriptor rejection, +the two rights-model mask tables, `AppliesTo` flag conversion, +inheritance-sensitive duplicate detection, inherited-rule rejection, +canonical-identity revalidation, and canonical DACL comparison. Public contract +tests cover all ten exports. Disposable live acceptance creates one disabled inert task inside the marked folder, proves typed and deduplicated reads, `WhatIf`, unsafe-boundary rejection, diff --git a/tests/Unit/Private/WindowsTaskSchedulerAccessControl.Tests.ps1 b/tests/Unit/Private/WindowsTaskSchedulerAccessControl.Tests.ps1 index 4a194a9..8eebe96 100644 --- a/tests/Unit/Private/WindowsTaskSchedulerAccessControl.Tests.ps1 +++ b/tests/Unit/Private/WindowsTaskSchedulerAccessControl.Tests.ps1 @@ -186,6 +186,128 @@ Describe 'Task Scheduler access-control internals' -Tag 'Unit', 'WindowsOnly' { } } + It 'Should accept a candidate for a DACL without a Local System ACE: ' -ForEach @( + @{ Current = 'D:(A;;FA;;;BA)'; Candidate = 'D:(A;;FA;;;BA)(A;;FA;;;SY)' } + @{ Current = 'D:P'; Candidate = 'D:P(A;;FA;;;SY)' } + @{ Current = 'D:(A;;FA;;;BA)'; Candidate = 'D:(A;;FA;;;BA)(A;;FR;;;AU)' } + ) { + $accepted = & $script:module { + param($CurrentSddl, $CandidateSddl) + Test-WindowsTaskSchedulerSystemAce ` + -CurrentDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CurrentSddl)) ` + -CandidateDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CandidateSddl)) + } $Current $Candidate + + $accepted | Should -BeTrue -Because "$Current has no Local System ACE to preserve" + } + + It 'Should refuse a candidate that adds a Local System deny ACE: ' -ForEach @( + @{ Current = 'D:(A;;FA;;;BA)'; Candidate = 'D:(D;;FA;;;SY)(A;;FA;;;BA)' } + @{ Current = 'D:(A;;FA;;;SY)(A;;FA;;;BA)'; Candidate = 'D:(D;;FW;;;SY)(A;;FA;;;SY)(A;;FA;;;BA)' } + ) { + $accepted = & $script:module { + param($CurrentSddl, $CandidateSddl) + Test-WindowsTaskSchedulerSystemAce ` + -CurrentDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CurrentSddl)) ` + -CandidateDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CandidateSddl)) + } $Current $Candidate + + $accepted | Should -BeFalse -Because 'an explicit Local System deny ACE is never accepted' + } + + It 'Should refuse a candidate that drops or changes a current Local System ACE: ' -ForEach @( + @{ Current = 'D:(A;;FA;;;SY)(A;;FA;;;BA)'; Candidate = 'D:(A;;FA;;;BA)' } + @{ Current = 'D:(A;;FA;;;SY)(A;;FA;;;BA)'; Candidate = 'D:(A;;FR;;;SY)(A;;FA;;;BA)' } + @{ Current = 'D:(A;;FA;;;SY)(A;OICI;FA;;;SY)'; Candidate = 'D:(A;;FA;;;SY)' } + ) { + $accepted = & $script:module { + param($CurrentSddl, $CandidateSddl) + Test-WindowsTaskSchedulerSystemAce ` + -CurrentDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CurrentSddl)) ` + -CandidateDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CandidateSddl)) + } $Current $Candidate + + $accepted | Should -BeFalse -Because 'every current Local System ACE must remain byte-identical' + } + + It 'Should refuse a candidate when the current DACL is ' -ForEach @( + @{ Name = 'missing from an empty read'; Current = '' } + @{ Name = 'missing from the read'; Current = 'O:BA' } + @{ Name = 'null'; Current = 'D:NO_ACCESS_CONTROL' } + ) { + $accepted = & $script:module { + param($CurrentSddl) + Test-WindowsTaskSchedulerSystemAce ` + -CurrentDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new($CurrentSddl)) ` + -CandidateDescriptor ([Security.AccessControl.RawSecurityDescriptor]::new('D:(A;;FA;;;SY)(A;;FA;;;BA)')) + } $Current + + $accepted | Should -BeFalse -Because 'Local System preservation and an exact rollback cannot be verified without a current DACL' + } + + It 'Should write a candidate that restores Local System to a DACL without one' { + & $script:module { + $script:storedSddl = 'D:(A;;FA;;;BA)' + $candidate = [Security.AccessControl.RawSecurityDescriptor]::new( + 'D:(A;;FA;;;BA)(A;;FA;;;SY)' + ) + $candidateBytes = [byte[]]::new($candidate.BinaryLength) + $candidate.GetBinaryForm($candidateBytes, 0) + $script:fakeNative = [pscustomobject]@{} + $script:fakeNative | Add-Member ScriptMethod GetSecurityDescriptor { + param($Information) + $null = $Information + $script:storedSddl + } + $script:fakeNative | Add-Member ScriptMethod SetSecurityDescriptor { + param($Sddl, $Flags) + $null = $Flags + $script:storedSddl = $Sddl + } + Mock Invoke-WindowsTaskSchedulerComOperation { + param($Target, $Operation) + $null = $Target + & $Operation $script:fakeNative + } + + $null = Set-WindowsTaskSchedulerSecurityDescriptor ` + -Target ([pscustomobject]@{ ObjectType = 'TaskFolder' }) ` + -SecurityDescriptor $candidateBytes + + $script:storedSddl | Should -BeExactly $candidate.GetSddlForm('Access') + } + } + + It 'Should refuse to write over a current descriptor that has a null DACL' { + & $script:module { + $candidate = [Security.AccessControl.RawSecurityDescriptor]::new( + 'D:(A;;FA;;;SY)(A;;FA;;;BA)' + ) + $candidateBytes = [byte[]]::new($candidate.BinaryLength) + $candidate.GetBinaryForm($candidateBytes, 0) + $script:fakeNative = [pscustomobject]@{} + $script:fakeNative | Add-Member ScriptMethod GetSecurityDescriptor { + param($Information) + $null = $Information + 'D:NO_ACCESS_CONTROL' + } + $script:fakeNative | Add-Member ScriptMethod SetSecurityDescriptor { + throw 'Setter must not run.' + } + Mock Invoke-WindowsTaskSchedulerComOperation { + param($Target, $Operation) + $null = $Target + & $Operation $script:fakeNative + } + + { + Set-WindowsTaskSchedulerSecurityDescriptor ` + -Target ([pscustomobject]@{ ObjectType = 'TaskFolder' }) ` + -SecurityDescriptor $candidateBytes + } | Should -Throw -ExpectedMessage '*current Task Scheduler DACL is missing or null*' + } + } + It 'Should treat reordered identical ACEs as equivalent but reject rights changes' { $candidate = [Security.AccessControl.RawSecurityDescriptor]::new( 'D:(A;;FA;;;SY)(A;;FA;;;BA)(A;;RC;;;WD)' From 618c6d54aa56b3bc31b29235d440e3f722efbff4 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 14:18:46 +0000 Subject: [PATCH 05/20] fix(smb): keep share description edits made during a DACL write Set-WindowsSmbShareSecurityDescriptor compared the description after the native SE_LMSHARE write with the value captured when the target was resolved and wrote that snapshot back whenever they differed, so an edit made after resolution was overwritten. The setter now reads the description immediately before the native write. Afterwards it restores that value only when the write cleared it, reports the restoration in the verbose stream, and leaves any other new value in place with a warning. A description that cannot be read before the write stops the command before anything is written. A failed restoration after a failed write still raises one AggregateException. Following decision 48, no step after a committed write throws: a description that cannot be checked or restored afterwards is reported as a warning naming the earlier value, and those warnings ignore a Stop or Inquire preference, because the DACL change is already live. The setter is now exercised by unit tests, so it leaves the domain-lab- only coverage list and its coverage is asserted. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/debugging-insights.md | 10 +- CHANGELOG.md | 10 + build.yaml | 1 - docs/usage/smb-shares.md | 7 +- .../Set-WindowsSmbShareSecurityDescriptor.ps1 | 47 +++- ...re-and-active-directory-dacl-management.md | 14 + ...indowsSmbShareSecurityDescriptor.Tests.ps1 | 255 ++++++++++++++++++ 7 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/Private/Set-WindowsSmbShareSecurityDescriptor.Tests.ps1 diff --git a/.memory-bank/debugging-insights.md b/.memory-bank/debugging-insights.md index f1a05ce..5837e30 100644 --- a/.memory-bank/debugging-insights.md +++ b/.memory-bank/debugging-insights.md @@ -806,9 +806,13 @@ that did the import releases it; test with ## SMB share descriptor metadata preservation `SetNamedSecurityInfoW` with `SE_LMSHARE` can clear a share description even -when only the DACL is selected. Capture the provider description before the -native write, restore it afterward, and aggregate restoration failure with the -primary operation failure. A DACL round trip alone is insufficient evidence. +when only the DACL is selected. Read the provider description immediately +before the native write, not at target resolution, and afterwards restore it +only when the write cleared it; any other new value is a concurrent edit to +keep and report with a warning. Aggregate a restoration failure with a failed +write, but after a committed write report it as a warning, with a Stop warning +preference downgraded, so the caller never sees a live change as a failure. A +DACL round trip alone is insufficient evidence. Resolve share targets through the local SMB provider rather than accepting a syntactically plausible UNC or wildcard. Provider topology is the authority for diff --git a/CHANGELOG.md b/CHANGELOG.md index 1716f73..972cd9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,6 +576,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keep an SMB share description that someone edits while a share DACL write + is in flight: the write now reads the description immediately before the + native call instead of reusing the value captured at target resolution, + restores it only when the native write cleared it, and warns instead of + overwriting any other change. A description that cannot be read before + the write stops the command before anything is written, and one that + cannot be checked or restored after the DACL was written is reported as a + warning naming the earlier value instead of failing a change that is + already live + ([specification](specs/0009-smb-share-and-active-directory-dacl-management.md#smb-share-contract)) - Accept a Task Scheduler DACL write that restores Local System to a folder or task whose DACL has no Local System ACE; such repairs were refused even though every current Local System ACE was preserved. A current DACL that diff --git a/build.yaml b/build.yaml index 56c8feb..674da2a 100644 --- a/build.yaml +++ b/build.yaml @@ -161,7 +161,6 @@ CodeCoverage: - Public/Get-ADObjectSecurityDescriptor.ps1 - Private/Invoke-WindowsSmbShareAclRuleMutation.ps1 - Private/Invoke-WindowsSmbShareCommandBatch.ps1 - - Private/Set-WindowsSmbShareSecurityDescriptor.ps1 - Public/Add-SmbShareAccessRule.ps1 - Public/Get-SmbShareAccessRule.ps1 - Public/Get-SmbShareSecurityDescriptor.ps1 diff --git a/docs/usage/smb-shares.md b/docs/usage/smb-shares.md index e1c3025..a9f37ea 100644 --- a/docs/usage/smb-shares.md +++ b/docs/usage/smb-shares.md @@ -40,7 +40,12 @@ Set-SmbShareSecurityDescriptor -Name 'Data$' ` -WhatIf ``` -Share DACL writes preserve the share description. +Share DACL writes preserve the share description. A description the native +write clears is restored. A description someone else changes during the write +is kept and reported with a warning, unless the write then clears it, in which +case the value from before the write returns. When the description cannot be +checked or restored after the DACL was written, a warning names the earlier +value instead of failing a change that is already live. ## Share rights diff --git a/source/Private/Set-WindowsSmbShareSecurityDescriptor.ps1 b/source/Private/Set-WindowsSmbShareSecurityDescriptor.ps1 index 3eaa125..7305f76 100644 --- a/source/Private/Set-WindowsSmbShareSecurityDescriptor.ps1 +++ b/source/Private/Set-WindowsSmbShareSecurityDescriptor.ps1 @@ -16,6 +16,12 @@ function Set-WindowsSmbShareSecurityDescriptor { [byte[]]$CurrentSecurityDescriptor ) + # SE_LMSHARE writes can clear the share description. Read it as close to the + # native write as possible so the compensation never replays a stale value. + $descriptionBefore = [string]( + Get-SmbShare -Name $Target.ShareName -ErrorAction Stop + ).Description + $writeError = $null try { Set-WindowsNamedSecurityDescriptor ` @@ -29,15 +35,37 @@ function Set-WindowsSmbShareSecurityDescriptor { $writeError = $_ } + # A step after a committed DACL write must not report failure for a change + # that is already live, so its warnings ignore a Stop or Inquire preference. + $postWriteWarningAction = if ($WarningPreference -in 'Stop', 'Inquire') { + 'Continue' + } + else { + $WarningPreference + } $metadataError = $null try { - $currentShare = Get-SmbShare -Name $Target.ShareName -ErrorAction Stop - if ([string]$currentShare.Description -cne [string]$Target.Description) { - Set-SmbShare ` - -Name $Target.ShareName ` - -Description ([string]$Target.Description) ` - -Confirm:$false ` - -ErrorAction Stop + $descriptionAfter = [string]( + Get-SmbShare -Name $Target.ShareName -ErrorAction Stop + ).Description + if ($descriptionAfter -cne $descriptionBefore) { + if ($descriptionAfter.Length -eq 0) { + Set-SmbShare ` + -Name $Target.ShareName ` + -Description $descriptionBefore ` + -Confirm:$false ` + -ErrorAction Stop + Write-Verbose -Message ( + "Restored the description of SMB share '{0}' to '{1}' after the DACL write cleared it." -f + $Target.ShareName, $descriptionBefore + ) + } + else { + Write-Warning -Message ( + "The description of SMB share '{0}' changed while its DACL was written and was left as it is. The description before the write was '{1}'." -f + $Target.ShareName, $descriptionBefore + ) -WarningAction $postWriteWarningAction + } } } catch { @@ -54,6 +82,9 @@ function Set-WindowsSmbShareSecurityDescriptor { throw $writeError } if ($metadataError) { - throw $metadataError + Write-Warning -Message ( + "The DACL of SMB share '{0}' was written, but its description could not be checked or restored: {1} The description before the write was '{2}'." -f + $Target.ShareName, $metadataError.Exception.Message, $descriptionBefore + ) -WarningAction $postWriteWarningAction } } diff --git a/specs/0009-smb-share-and-active-directory-dacl-management.md b/specs/0009-smb-share-and-active-directory-dacl-management.md index 81ff3b4..19908e7 100644 --- a/specs/0009-smb-share-and-active-directory-dacl-management.md +++ b/specs/0009-smb-share-and-active-directory-dacl-management.md @@ -52,6 +52,20 @@ The implementation uses the shared binary descriptor engine with `SE_LMSHARE`. It does not flatten a share DACL through a friendly access-level projection. +A native `SE_LMSHARE` write can clear the share description even though only +the DACL is selected. The setter reads the description immediately before the +native write and afterwards restores it only when the write cleared it, +reporting the restoration in the verbose stream. A description that changed to +any other value reflects a concurrent edit: it is left in place, and a warning +names the value from before the write. The window is narrowed, not eliminated: +an edit that lands during the write and is then cleared by it is replaced with +the value from before the write. A description that cannot be read before the +write stops the command before anything is written. After a committed write, a +description that cannot be checked or restored is reported as a warning that +names the earlier value, because a step after the write must not report +failure for a change that is already live; a failed restoration after a failed +write is reported together with the write failure. + ## Active Directory contract Every AD command requires `DistinguishedName`. `Server` names the final writable diff --git a/tests/Unit/Private/Set-WindowsSmbShareSecurityDescriptor.Tests.ps1 b/tests/Unit/Private/Set-WindowsSmbShareSecurityDescriptor.Tests.ps1 new file mode 100644 index 0000000..f4f3c01 --- /dev/null +++ b/tests/Unit/Private/Set-WindowsSmbShareSecurityDescriptor.Tests.ps1 @@ -0,0 +1,255 @@ +BeforeAll { + $moduleManifest = Get-ChildItem -Path "$PSScriptRoot\..\..\..\output\module\WindowsAccessControl\*\WindowsAccessControl.psd1" | + Sort-Object -Property { [version]$_.Directory.Name } -Descending | + Select-Object -First 1 + Import-Module -Name $moduleManifest.FullName -ErrorAction Stop + + $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new('D:(A;;FA;;;BA)') + $script:descriptorBytes = [byte[]]::new($descriptor.BinaryLength) + $descriptor.GetBinaryForm($script:descriptorBytes, 0) +} + +Describe 'Set-WindowsSmbShareSecurityDescriptor description handling' -Tag 'Unit', 'WindowsOnly' { + BeforeEach { + InModuleScope WindowsAccessControl { + $script:testTarget = [pscustomobject]@{ + ShareName = 'WacLab$' + Description = 'Captured at resolution' + NativePath = 'WacLab$' + NativeObjectType = [int][WindowsSecurityObjectType]::SmbShare + } + Mock Get-SmbShare { + [pscustomobject]@{ + Name = 'WacLab$' + Description = $script:liveDescription + } + } + Mock Set-SmbShare { + $script:liveDescription = $Description + } + } + } + + It 'Should keep a description edited after the target was resolved' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Edited after resolution' + Mock Set-WindowsNamedSecurityDescriptor { } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction SilentlyContinue + + $script:liveDescription | Should -BeExactly 'Edited after resolution' + Should -Invoke Set-SmbShare -Times 0 -Exactly + $smbWarnings | Should -BeNullOrEmpty + } + } + + It 'Should keep and report a description edited during the DACL write' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = 'Edited during the write' + } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction SilentlyContinue + + $script:liveDescription | Should -BeExactly 'Edited during the write' + Should -Invoke Set-SmbShare -Times 0 -Exactly + $smbWarnings | Should -HaveCount 1 + [string]$smbWarnings[0] | + Should -BeLike '*''WacLab$''*changed while its DACL was written*''Captured at resolution''*' + } + } + + It 'Should restore the description the DACL write cleared' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Edited after resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = '' + } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction SilentlyContinue + + $script:liveDescription | Should -BeExactly 'Edited after resolution' + Should -Invoke Set-SmbShare -Times 1 -Exactly -ParameterFilter { + $Name -eq 'WacLab$' -and $Description -ceq 'Edited after resolution' + } + $smbWarnings | Should -BeNullOrEmpty + } + } + + It 'Should report a restored description in the verbose stream' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = '' + } + + $verboseRecords = @( + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -Verbose 4>&1 | + Where-Object { $_ -is [System.Management.Automation.VerboseRecord] -and [string]$_ -like '*restored*' } + ) + + $verboseRecords | Should -HaveCount 1 + [string]$verboseRecords[0] | + Should -BeLike '*restored*''WacLab$''*''Captured at resolution''*' + } + } + + It 'Should warn instead of failing when restoring the description fails after a committed write' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = '' + } + Mock Set-SmbShare { throw 'Description restoration failed.' } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction SilentlyContinue + + $smbWarnings | Should -HaveCount 1 + [string]$smbWarnings[0] | + Should -BeLike '*DACL of SMB share ''WacLab$'' was written*Description restoration failed*''Captured at resolution''*' + } + } + + It 'Should warn instead of failing when the description cannot be read after a committed write' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + $script:getSmbShareCalls = 0 + Mock Get-SmbShare { + $script:getSmbShareCalls++ + if ($script:getSmbShareCalls -gt 1) { + throw 'SMB provider unavailable after the write.' + } + [pscustomobject]@{ + Name = 'WacLab$' + Description = $script:liveDescription + } + } + Mock Set-WindowsNamedSecurityDescriptor { } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction SilentlyContinue + + Should -Invoke Set-WindowsNamedSecurityDescriptor -Times 1 -Exactly + $smbWarnings | Should -HaveCount 1 + [string]$smbWarnings[0] | + Should -BeLike '*DACL of SMB share ''WacLab$'' was written*SMB provider unavailable after the write*' + } + } + + It 'Should not let a stop warning preference fail a committed write' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = 'Edited during the write' + } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction Stop 3>$null + + $script:liveDescription | Should -BeExactly 'Edited during the write' + $smbWarnings | Should -HaveCount 1 + } + } + + It 'Should not write an unchanged description' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { } + + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes ` + -WarningVariable smbWarnings ` + -WarningAction SilentlyContinue + + Should -Invoke Set-WindowsNamedSecurityDescriptor -Times 1 -Exactly + Should -Invoke Set-SmbShare -Times 0 -Exactly + $smbWarnings | Should -BeNullOrEmpty + } + } + + It 'Should not write the DACL when the description cannot be read first' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + Mock Get-SmbShare { throw 'SMB provider unavailable.' } + Mock Set-WindowsNamedSecurityDescriptor { } + + { + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes + } | Should -Throw -ExpectedMessage '*SMB provider unavailable*' + Should -Invoke Set-WindowsNamedSecurityDescriptor -Times 0 -Exactly + Should -Invoke Set-SmbShare -Times 0 -Exactly + } + } + + It 'Should restore a cleared description and still report the failed write' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = '' + throw 'Native DACL write failed.' + } + + { + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes + } | Should -Throw -ExpectedMessage '*Native DACL write failed*' + $script:liveDescription | Should -BeExactly 'Captured at resolution' + } + } + + It 'Should aggregate a failed write and a failed description restoration' { + InModuleScope WindowsAccessControl -Parameters @{ Bytes = $script:descriptorBytes } { + $script:liveDescription = 'Captured at resolution' + Mock Set-WindowsNamedSecurityDescriptor { + $script:liveDescription = '' + throw 'Native DACL write failed.' + } + Mock Set-SmbShare { throw 'Description restoration failed.' } + + $failure = $null + try { + Set-WindowsSmbShareSecurityDescriptor ` + -Target $script:testTarget ` + -SecurityDescriptor $Bytes + } + catch { + $failure = $_ + } + + $failure | Should -Not -BeNullOrEmpty + $failure.Exception | Should -BeOfType ([AggregateException]) + $failure.Exception.InnerExceptions | Should -HaveCount 2 + $failure.Exception.InnerExceptions[0].Message | Should -BeLike '*Native DACL write failed*' + $failure.Exception.InnerExceptions[1].Message | Should -BeLike '*Description restoration failed*' + } + } +} From 8b67058b2bf2c02049de7210ced93a559ebb1d68 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 14:25:53 +0000 Subject: [PATCH 06/20] fix(ad): read caller effective access without the security descriptor Get-ADObjectCallerEffectiveAccess resolved every target, in the dispatcher's prevalidation and again in the worker, through a request for nTSecurityDescriptor that it never used. A domain controller omits that attribute for a bind without READ_CONTROL, and Get-WindowsADObjectRecord then failed with "Cannot index into a null array" instead of returning the caller's effective access. Resolve-WindowsADObjectTarget and Invoke-WindowsADCommandBatch gain an opt-out switch that only this command sets; every descriptor-dependent caller keeps requesting the descriptor, and a write target refuses the switch. When a requested descriptor is absent or has no value, Get-WindowsADObjectRecord now throws UnauthorizedAccessException naming the likely missing read-control access. Send-WindowsADSearchRequest now holds the LDAP send and its NoSuchObject translation, so unit tests can supply a directory response. The catch stays beside the native call, where $_.Exception is still the DirectoryOperationException. Get-WindowsADObjectRecord and Resolve-WindowsADObjectTarget move into asserted coverage, with unit tests for the write-target path, and the new seam is declared domain-lab-only. The domain-lab AD suite gains a restricted-caller case and a missing-object case for the next live acceptance run. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 48 ++-- .memory-bank/debugging-insights.md | 13 + .memory-bank/progress.md | 25 +- CHANGELOG.md | 7 + build.yaml | 3 +- docs/usage/active-directory.md | 5 + docs/usage/troubleshooting.md | 1 + source/Private/Get-WindowsADObjectRecord.ps1 | 24 +- .../Private/Invoke-WindowsADCommandBatch.ps1 | 6 + .../Private/Resolve-WindowsADObjectTarget.ps1 | 10 +- .../Private/Send-WindowsADSearchRequest.ps1 | 36 +++ .../Get-ADObjectCallerEffectiveAccess.ps1 | 8 +- ...re-and-active-directory-dacl-management.md | 7 + ...ctive-directory-caller-effective-access.md | 14 +- tests/Lab/ADObjectPermissions.Live.Tests.ps1 | 68 +++++ .../Private/WindowsADAccessControl.Tests.ps1 | 236 ++++++++++++++++++ ...et-ADObjectCallerEffectiveAccess.Tests.ps1 | 126 ++++++++++ 17 files changed, 588 insertions(+), 49 deletions(-) create mode 100644 source/Private/Send-WindowsADSearchRequest.ps1 diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index f83da76..205a88b 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,18 +9,24 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Handoff 01 of the 2026-10-06 sequence is complete on the local branch -`ai/post-release-fixes`, created from `ai/post-release-triage`: the -domain-lab acceptance runner no longer deletes directories it did not create. -It marks what it creates on the management domain controller, replaces only -absent or marked directories without a junction or symbolic link inside, -validates the payload root there in every mode, and stops when a remote step -does not confirm its directory. No live lab run took place; handoff 07 owns -it. Next is handoff 02. No git remote was mutated. +Handoff 02 of the 2026-10-06 sequence is complete on the local branch +`ai/post-release-fixes`: three review defects are fixed test-first, one commit +each. A Task Scheduler write may now repair a DACL that has no Local System +ACE, while a missing or null current DACL is still refused. An SMB share DACL +write reads the description immediately before the native call, keeps a +concurrent edit, and after a committed write reports description trouble as a +warning, never an error. `Get-ADObjectCallerEffectiveAccess` no longer +requests `nTSecurityDescriptor`, and a command that needs a missing descriptor +raises a typed read-control error. One independent review approved with minor +findings; the cheap ones were fixed before the commits were finalized. The two +Desktop DSC-engine tests fail locally only because WinRM is stopped. No live lab +run took place; handoff 07 runs the two new AD live cases. Next is handoff 03. +No git remote was mutated. -The 2026-10-06 triage below remains the record of the open work. `main` is -`40e364a`, in sync with `origin/main`; the triage restored the deleted local -branch `ai/performance-refactor` at `5f06c03`. +Handoff 01 made the domain-lab acceptance runner refuse to delete directories +it did not create. The 2026-10-06 triage below remains the record of the open +work. `main` is `40e364a`, in sync with `origin/main`; the triage restored the +deleted local branch `ai/performance-refactor` at `5f06c03`. ## Handoff sequence @@ -72,20 +78,18 @@ and cleanup. Only 08 touches the remote, with approval per action. delete `C:\WindowsAccessControlLab\ModuleUnderTest` on the member server without an ownership check; it is a fixed child of the harness's marked member root. - - Major: `Test-WindowsTaskSchedulerSystemAce` returns `$false` when the - current DACL has no SYSTEM ACE, so a write that restores SYSTEM is - refused. Still present; no test pins it as intended behavior. - - Major: `Set-WindowsSmbShareSecurityDescriptor` restores the description - captured at target resolution, overwriting a concurrent edit. Still - present. + - Major, fixed by handoff 02: `Test-WindowsTaskSchedulerSystemAce` + refused a write that restores SYSTEM to a DACL without a SYSTEM ACE. A + missing or null current DACL is still refused. + - Major, fixed by handoff 02: `Set-WindowsSmbShareSecurityDescriptor` + restored the description captured at target resolution, overwriting a + concurrent edit. - Major: the opt-in ModuleFast path in the vendored `Resolve-Dependency.ps1` downloads and runs `bit.ly/modulefast` unverified. Disabled by default. - Major (CNG final read) is FIND-001, resolved on `main` by `f5731f1`. - - Minor: `Resolve-WindowsADObjectTarget` always requests - `nTSecurityDescriptor`, and `Get-WindowsADObjectRecord` indexes it - without an absence check. `Get-ADObjectCallerEffectiveAccess` does not - need it; the impact on callers without read-control access needs live - evidence. + - Minor, fixed by handoff 02: `Get-ADObjectCallerEffectiveAccess` requested + `nTSecurityDescriptor` it never used, and a caller without read-control + access got a null-index error. Live evidence is owed by handoff 07. - Follow-ups: `README.md` line 315 still calls private keys read-only; the batch dispatcher `CommandName`/`ObjectFamily` guard has no test. 3. **Dependabot pull requests #1-#3** bump `actions/checkout` to 7.0.1, diff --git a/.memory-bank/debugging-insights.md b/.memory-bank/debugging-insights.md index 5837e30..df4dbf5 100644 --- a/.memory-bank/debugging-insights.md +++ b/.memory-bank/debugging-insights.md @@ -833,6 +833,19 @@ explicitly before applying the allowed-base check. Use `AuthType.Kerberos` with an explicit FQDN server, signing, sealing, disabled referrals, and bounded timeouts when the contract requires strict Kerberos. +A domain controller omits `nTSecurityDescriptor` instead of failing the search +when the bind lacks `READ_CONTROL`. Request it only where a command uses it, +and check for its absence before indexing it. + +## A .NET exception changes shape when it leaves a function + +A typed `catch [DirectoryOperationException]` still matches after the native +call moves into a helper function, but `$_.Exception` in the caller is then a +`MethodInvocationException`, so a property such as `Response.ResultCode` reads +as null and the translation silently stops working. Keep a catch that inspects +exception properties beside the native call, as `Send-WindowsADSearchRequest` +does, and probe both editions before moving one. + ## Cold-lab timeouts corrupt the shared certificate fixture A domain-lab acceptance started shortly after the Hyper-V host reboots runs diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 0726b06..f20892f 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -12,15 +12,28 @@ source: repository evidence `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage found unmerged and untracked work and split it into eight handoffs on local -`ai/post-release-fixes`. Handoff 01 fixed the lab-runner Blocker there; the -Task Scheduler and SMB Majors, the restored `ai/performance-refactor` branch, -three dependabot pull requests with inherited failures, unanswered issue #4 -with a missing NTFSSecurity migration map, an uncreated v0.2.0 changelog pull -request, and the FIND-002 newline nit remain. `activeContext.md` holds the -evidence and the order. +`ai/post-release-fixes`. Handoffs 01 and 02 fixed the lab-runner Blocker and +the Task Scheduler, SMB, and AD review defects there; the restored +`ai/performance-refactor` branch, three dependabot pull requests with +inherited failures, unanswered issue #4 with a missing NTFSSecurity migration +map, an uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit +remain. `activeContext.md` holds the evidence and the order. ## Recent milestones +- 2026-10-06: Handoff 02 fixed three review defects test-first on + `ai/post-release-fixes`, one commit each. A Task Scheduler write may + repair a DACL without a Local System ACE; a missing or null current DACL + is still refused. An SMB DACL write reads the description just before + the native call, keeps concurrent edits, and warns instead of failing + after a committed write. `Get-ADObjectCallerEffectiveAccess` no longer + requests `nTSecurityDescriptor`; an absent descriptor elsewhere raises a + typed read-control error. The Core gate passes 1,905 tests with two + environmental skips at 83.2 percent asserted coverage. The Desktop gate + fails only the two WinRM-bound DSC-engine tests; its scoped coverage, + computed from its document, is 81.03 percent. One review approved with + minor findings, most fixed. No live lab run; 07 owns two new AD cases. + - 2026-10-06: Handoff 01 made the domain-lab acceptance runner refuse to delete what it did not create, test-first on `ai/post-release-fixes`. It marks the payload root, `package`, and installed module version directory diff --git a/CHANGELOG.md b/CHANGELOG.md index 972cd9e..d401e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,6 +576,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Let `Get-ADObjectCallerEffectiveAccess` answer for a caller who cannot read + the object's security descriptor: resolving its targets no longer requests + `nTSecurityDescriptor`, which no output property uses. A command that does + need the descriptor now fails with an `UnauthorizedAccessException` naming + the likely missing read-control access instead of + `Cannot index into a null array` + ([specification](specs/0018-active-directory-caller-effective-access.md#output-contract)) - Keep an SMB share description that someone edits while a share DACL write is in flight: the write now reads the description immediately before the native call instead of reusing the value captured at target resolution, diff --git a/build.yaml b/build.yaml index 674da2a..e223e6c 100644 --- a/build.yaml +++ b/build.yaml @@ -153,11 +153,10 @@ CodeCoverage: DomainLabCodeCoverageFile: tests/Lab/coverage/JaCoCo_coverage_DomainLab.xml DomainLabOnlySourcePath: - Private/Get-WindowsADEffectiveAccessRecord.ps1 - - Private/Get-WindowsADObjectRecord.ps1 - Private/Get-WindowsADRootDse.ps1 - Private/Get-WindowsADRuleEnrichment.ps1 - Private/New-WindowsADConnection.ps1 - - Private/Resolve-WindowsADObjectTarget.ps1 + - Private/Send-WindowsADSearchRequest.ps1 - Public/Get-ADObjectSecurityDescriptor.ps1 - Private/Invoke-WindowsSmbShareAclRuleMutation.ps1 - Private/Invoke-WindowsSmbShareCommandBatch.ps1 diff --git a/docs/usage/active-directory.md b/docs/usage/active-directory.md index d021676..84d7114 100644 --- a/docs/usage/active-directory.md +++ b/docs/usage/active-directory.md @@ -184,6 +184,11 @@ results can be compared: (Get-ADObjectCallerEffectiveAccess -DistinguishedName $targetDn).WritableAttribute ``` +The command never reads the object's security descriptor, so it also answers +for a caller who lacks read-control access to that descriptor. The descriptor +and rule commands need it; for such a caller they fail with an +`UnauthorizedAccessException` that names the missing read-control access. + ### Read the four limits before you trust it - **It is scoped to the bind, and there is no `Account` parameter.** No in-box diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index 13b1da2..793cbd9 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -20,6 +20,7 @@ tables below map a symptom to the rule that produced it. | A `RequireUnchanged` write is rejected | Another writer changed the target; re-read, reapply, and re-read again before retrying | | A share command rejects the share | Administrative, drive, IPC, print, clustered, and continuously available shares are out of scope | | A directory write is refused as out of bounds | The target is outside `AllowedBaseDistinguishedName` | +| A directory command reports that the caller most likely lacks read-control access | The bind cannot read the object's security descriptor; grant Read permissions, or use `Get-ADObjectCallerEffectiveAccess`, which does not need it | | A directory edit disappeared | Two controllers converged on one survivor; pin one `Server` for both writes | | A task write is refused | The target is the scheduler root or `\Microsoft`, the candidate removes or denies Local System, or the current DACL is missing or null | | A private-key write is refused | Run `Test-CertificatePrivateKeyCriticalBinding` to see which binding blocks it | diff --git a/source/Private/Get-WindowsADObjectRecord.ps1 b/source/Private/Get-WindowsADObjectRecord.ps1 index d595abc..d3ded09 100644 --- a/source/Private/Get-WindowsADObjectRecord.ps1 +++ b/source/Private/Get-WindowsADObjectRecord.ps1 @@ -33,24 +33,20 @@ function Get-WindowsADObjectRecord { ) ) } - try { - $response = [System.DirectoryServices.Protocols.SearchResponse]( - $Connection.SendRequest($request) - ) - } - catch [System.DirectoryServices.Protocols.DirectoryOperationException] { - if ($_.Exception.Response.ResultCode -eq - [System.DirectoryServices.Protocols.ResultCode]::NoSuchObject) { - throw [System.Management.Automation.ItemNotFoundException]::new( - "Active Directory object was not found: '$DistinguishedName'." - ) - } - throw - } + $response = Send-WindowsADSearchRequest -Connection $Connection -Request $request if ($response.Entries.Count -ne 1) { throw "Active Directory object did not resolve uniquely: '$DistinguishedName'." } $entry = $response.Entries[0] + if ($IncludeSecurityDescriptor -and ( + -not $entry.Attributes.Contains('nTSecurityDescriptor') -or + $entry.Attributes['nTSecurityDescriptor'].Count -lt 1)) { + # The controller omits the attribute instead of failing the search when + # the bind lacks READ_CONTROL, so name the likely cause explicitly. + throw [System.UnauthorizedAccessException]::new( + "Active Directory returned no security descriptor for '$DistinguishedName'. The caller most likely lacks read-control access (READ_CONTROL, shown as Read permissions) on the object." + ) + } $guidBytes = [byte[]]$entry.Attributes['objectGUID'][0] [pscustomobject]@{ DistinguishedName = [string]$entry.DistinguishedName diff --git a/source/Private/Invoke-WindowsADCommandBatch.ps1 b/source/Private/Invoke-WindowsADCommandBatch.ps1 index 1c97f1e..8eaa058 100644 --- a/source/Private/Invoke-WindowsADCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsADCommandBatch.ps1 @@ -26,6 +26,9 @@ function Invoke-WindowsADCommandBatch { [Parameter()] [switch]$SerializeByCanonicalTarget, + [Parameter()] + [switch]$ExcludeSecurityDescriptor, + [Parameter()] [System.Management.Automation.ConfirmImpact]$ConfirmationImpact = [System.Management.Automation.ConfirmImpact]::None @@ -47,6 +50,9 @@ function Invoke-WindowsADCommandBatch { if ($BoundParameters.ContainsKey('ExpectedObjectGuid')) { $resolveParameters.ExpectedObjectGuid = $BoundParameters['ExpectedObjectGuid'] } + if ($ExcludeSecurityDescriptor) { + $resolveParameters.ExcludeSecurityDescriptor = $true + } $target = Resolve-WindowsADObjectTarget @resolveParameters [pscustomobject]@{ CanonicalTarget = $target.CanonicalTarget diff --git a/source/Private/Resolve-WindowsADObjectTarget.ps1 b/source/Private/Resolve-WindowsADObjectTarget.ps1 index 5176ba4..16bbd6d 100644 --- a/source/Private/Resolve-WindowsADObjectTarget.ps1 +++ b/source/Private/Resolve-WindowsADObjectTarget.ps1 @@ -21,6 +21,9 @@ function Resolve-WindowsADObjectTarget { [Parameter()] [switch]$ForWrite, + [Parameter()] + [switch]$ExcludeSecurityDescriptor, + [Parameter()] [guid]$ExpectedObjectGuid = [guid]::Empty, @@ -28,6 +31,11 @@ function Resolve-WindowsADObjectTarget { [System.DirectoryServices.Protocols.LdapConnection]$Connection ) + if ($ForWrite -and $ExcludeSecurityDescriptor) { + throw [System.ArgumentException]::new( + 'A write target needs its security descriptor, so ForWrite and ExcludeSecurityDescriptor cannot be combined.' + ) + } $serverName = Resolve-WindowsADServerName -Server $Server $ownsConnection = -not $Connection $ldapConnection = if ($ownsConnection) { @@ -54,7 +62,7 @@ function Resolve-WindowsADObjectTarget { $record = Get-WindowsADObjectRecord ` -Connection $ldapConnection ` -DistinguishedName $DistinguishedName ` - -IncludeSecurityDescriptor + -IncludeSecurityDescriptor:(-not $ExcludeSecurityDescriptor) if ($ExpectedObjectGuid -ne [guid]::Empty -and $record.ObjectGuid -ne $ExpectedObjectGuid) { throw 'The Active Directory object GUID no longer matches the path-bound target.' diff --git a/source/Private/Send-WindowsADSearchRequest.ps1 b/source/Private/Send-WindowsADSearchRequest.ps1 new file mode 100644 index 0000000..3347695 --- /dev/null +++ b/source/Private/Send-WindowsADSearchRequest.ps1 @@ -0,0 +1,36 @@ +function Send-WindowsADSearchRequest { + <# + Sends one LDAP search over a bound connection and returns its response. + A request for an object that does not exist fails with + ItemNotFoundException. Get-WindowsADObjectRecord reaches the directory + only through this function, so unit tests can supply a response without + a domain controller. + #> + [CmdletBinding()] + [OutputType([System.DirectoryServices.Protocols.SearchResponse])] + param( + [Parameter(Mandatory)] + [System.DirectoryServices.Protocols.LdapConnection]$Connection, + + [Parameter(Mandatory)] + [System.DirectoryServices.Protocols.SearchRequest]$Request + ) + + # The catch stays beside the native call: once the exception leaves a + # function, PowerShell reports it as MethodInvocationException and the + # result code below is no longer reachable through $_.Exception. + try { + [System.DirectoryServices.Protocols.SearchResponse]( + $Connection.SendRequest($Request) + ) + } + catch [System.DirectoryServices.Protocols.DirectoryOperationException] { + if ($_.Exception.Response.ResultCode -eq + [System.DirectoryServices.Protocols.ResultCode]::NoSuchObject) { + throw [System.Management.Automation.ItemNotFoundException]::new( + "Active Directory object was not found: '$($Request.DistinguishedName)'." + ) + } + throw + } +} diff --git a/source/Public/Get-ADObjectCallerEffectiveAccess.ps1 b/source/Public/Get-ADObjectCallerEffectiveAccess.ps1 index d0bea8d..c99197f 100644 --- a/source/Public/Get-ADObjectCallerEffectiveAccess.ps1 +++ b/source/Public/Get-ADObjectCallerEffectiveAccess.ps1 @@ -86,7 +86,8 @@ function Get-ADObjectCallerEffectiveAccess { -DistinguishedName $DistinguishedName ` -Credential $Credential ` -TimeoutSeconds $TimeoutSeconds ` - -ThrottleLimit $ThrottleLimit + -ThrottleLimit $ThrottleLimit ` + -ExcludeSecurityDescriptor return } $account = if ($Credential) { @@ -101,12 +102,15 @@ function Get-ADObjectCallerEffectiveAccess { -TimeoutSeconds $TimeoutSeconds try { foreach ($dnValue in $DistinguishedName) { + # No output property depends on the descriptor, and a caller + # without READ_CONTROL would not receive it. $target = Resolve-WindowsADObjectTarget ` -Server $Server ` -DistinguishedName ([string]$dnValue) ` -Credential $Credential ` -TimeoutSeconds $TimeoutSeconds ` - -Connection $connection + -Connection $connection ` + -ExcludeSecurityDescriptor $record = Get-WindowsADEffectiveAccessRecord ` -Connection $connection ` -DistinguishedName $target.DistinguishedName diff --git a/specs/0009-smb-share-and-active-directory-dacl-management.md b/specs/0009-smb-share-and-active-directory-dacl-management.md index 19908e7..eae1775 100644 --- a/specs/0009-smb-share-and-active-directory-dacl-management.md +++ b/specs/0009-smb-share-and-active-directory-dacl-management.md @@ -85,6 +85,13 @@ Output binds the current distinguished name to immutable `objectGUID` and server authority. Before every write, the adapter resolves the same DN again and rejects deletion, rename, move, or GUID mismatch. +A domain controller omits `nTSecurityDescriptor` from a search result, rather +than failing the search, when the bind lacks read-control access to the object. +A command that needs the descriptor therefore fails with +`UnauthorizedAccessException` naming the likely missing read-control access +instead of continuing without it. A command that does not need the descriptor, +such as `Get-ADObjectCallerEffectiveAccess`, does not request it. + Every AD mutation also requires `AllowedBaseDistinguishedName`. The base must resolve to an organizational unit in the default domain partition. The target must be that OU or one of its descendants. Domain root, `AdminSDHolder`, the diff --git a/specs/0018-active-directory-caller-effective-access.md b/specs/0018-active-directory-caller-effective-access.md index c950d73..8241c8d 100644 --- a/specs/0018-active-directory-caller-effective-access.md +++ b/specs/0018-active-directory-caller-effective-access.md @@ -98,7 +98,10 @@ and an absent `sDRightsEffective` as zero, which is what "none" means here. The command is read-only, supports no `ShouldProcess`, mutates nothing, and reuses the directory family's existing target resolution, so the naming-context, -partition, and unique-resolution refusals apply unchanged. +partition, and unique-resolution refusals apply unchanged. That resolution does +not request `nTSecurityDescriptor`, because no output property depends on it, so +a caller without read-control access to the object's security descriptor still +receives a result. ## Verification @@ -106,13 +109,20 @@ partition, and unique-resolution refusals apply unchanged. parameter, one result per target, the bind identity reported for a supplied credential, sorted list output, the section mask mapping including the empty case, and the context label. +- Unit tests replace the LDAP search that target resolution sends and prove + that neither the prevalidation of every target nor the per-target resolution + requests `nTSecurityDescriptor` or the security-descriptor flag control, and + that a caller whose entry carries no descriptor still receives a result. - The three-attribute base-scope request itself has no unit-testable seam, because it needs a bound `LdapConnection`. Its source file is declared domain-lab-only for coverage, and live domain-lab evidence proves it. - Live domain-lab evidence reads a real organizational unit as a domain administrator and proves a nonzero section mask, a nonempty writable-attribute list, and a nonempty creatable-child-class list against the same object - `Get-ADObjectAccessRule` reports rules for. + `Get-ADObjectAccessRule` reports rules for. It also evaluates the delegated + operator on a disposable organizational unit whose protected DACL grants it + property reads but not read-control access: the command returns a result, + while `Get-ADObjectSecurityDescriptor` fails with the read-control error. ## See also diff --git a/tests/Lab/ADObjectPermissions.Live.Tests.ps1 b/tests/Lab/ADObjectPermissions.Live.Tests.ps1 index b7c9926..9b9ddbd 100644 --- a/tests/Lab/ADObjectPermissions.Live.Tests.ps1 +++ b/tests/Lab/ADObjectPermissions.Live.Tests.ps1 @@ -216,6 +216,74 @@ Describe 'Active Directory object DACL commands' ` ($asOperator.SDRightsEffective -band 4) | Should -Be 4 } + It 'Should evaluate a caller who cannot read the object security descriptor' { + $restricted = $null + try { + $restrictedParameters = @{ + Name = 'WacNoReadControl{0}' -f [guid]::NewGuid().ToString('N') + Path = $script:targetOu + Server = $script:server + ProtectedFromAccidentalDeletion = $false + PassThru = $true + ErrorAction = 'Stop' + } + $restricted = New-ADOrganizationalUnit @restrictedParameters + # A protected DACL drops the inherited Authenticated Users read, so + # the operator may read properties but not the descriptor itself. + # The runner keeps full control so cleanup never depends on group + # membership. + $domainAdminSid = "$($script:domain.DomainSID.Value)-512" + $runnerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $restrictedSddl = 'D:P(A;;GA;;;{0})(A;;GA;;;{1})(A;;GA;;;SY)(A;;RPLCLO;;;{2})' -f + $domainAdminSid, $runnerSid, $script:operator.SID.Value + Set-ADObjectSecurityDescriptor ` + -Server $script:server ` + -DistinguishedName $restricted.DistinguishedName ` + -AllowedBaseDistinguishedName $script:targetOu ` + -Sddl $restrictedSddl ` + -ThrottleLimit 1 ` + -Confirm:$false + + $asOperator = Get-ADObjectCallerEffectiveAccess ` + -Server $script:server ` + -DistinguishedName $restricted.DistinguishedName ` + -Credential $script:credential ` + -ThrottleLimit 1 + + $asOperator.DistinguishedName | Should -BeExactly $restricted.DistinguishedName + $asOperator.Account | Should -BeExactly $script:credential.UserName + ($asOperator.SDRightsEffective -band 4) | Should -Be 0 + { + Get-ADObjectSecurityDescriptor ` + -Server $script:server ` + -DistinguishedName $restricted.DistinguishedName ` + -Credential $script:credential ` + -ThrottleLimit 1 ` + -ErrorAction Stop + } | Should -Throw -ExceptionType ([UnauthorizedAccessException]) ` + -ExpectedMessage '*READ_CONTROL*' + } + finally { + if ($restricted) { + Remove-ADOrganizationalUnit -Identity $restricted.ObjectGuid -Server $script:server ` + -Confirm:$false -ErrorAction Stop + } + } + } + + It 'Should report a missing directory object as not found' { + $missing = 'OU=WacMissing{0},{1}' -f [guid]::NewGuid().ToString('N'), $script:targetOu + + { + Get-ADObjectSecurityDescriptor ` + -Server $script:server ` + -DistinguishedName $missing ` + -ThrottleLimit 1 ` + -ErrorAction Stop + } | Should -Throw -ExceptionType ([System.Management.Automation.ItemNotFoundException]) ` + -ExpectedMessage '*was not found*' + } + It 'Should reject configuration and schema partition reads' { foreach ($distinguishedName in @( $script:configurationDn diff --git a/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 b/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 index 9c4a918..3996479 100644 --- a/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 +++ b/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 @@ -298,6 +298,242 @@ Describe 'Active Directory access-control internals' -Tag 'Unit', 'WindowsOnly' } } + It 'Should keep reading the descriptor when prevalidating descriptor-dependent targets' { + InModuleScope WindowsAccessControl { + Mock Resolve-WindowsADObjectTarget { + param($DistinguishedName) + [pscustomobject]@{ + CanonicalTarget = "ADObject:DC:$DistinguishedName" + DistinguishedName = $DistinguishedName + } + } + Mock Invoke-WindowsAccessControlBatch + + Invoke-WindowsADCommandBatch ` + -CommandName Get-ADObjectAccessRule ` + -BoundParameters @{ Server = 'dc01.example.test'; TimeoutSeconds = 10 } ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=Lab,DC=example,DC=test' ` + -TimeoutSeconds 10 ` + -ThrottleLimit 1 + + Should -Invoke Resolve-WindowsADObjectTarget -Times 1 -Exactly ` + -ParameterFilter { -not $ExcludeSecurityDescriptor } + } + } + + It 'Should request the descriptor and its DACL control for a descriptor-dependent target' { + InModuleScope WindowsAccessControl { + $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new( + 'O:BAG:BAD:(A;;RPWPCCDCLCSWRCWDWOGA;;;BA)' + ) + $script:testDescriptorBytes = [byte[]]::new($descriptor.BinaryLength) + $descriptor.GetBinaryForm($script:testDescriptorBytes, 0) + $script:testRequests = [System.Collections.Generic.List[object]]::new() + Mock Get-WindowsADRootDse { + [pscustomobject]@{ + DefaultNamingContext = 'DC=example,DC=test' + ConfigurationNamingContext = 'CN=Configuration,DC=example,DC=test' + SchemaNamingContext = 'CN=Schema,CN=Configuration,DC=example,DC=test' + RootDomainNamingContext = 'DC=example,DC=test' + } + } + Mock Send-WindowsADSearchRequest { + $script:testRequests.Add($Request) + $attributes = @{ + objectGUID = @(, [guid]::NewGuid().ToByteArray()) + objectClass = @('top', 'organizationalUnit') + } + if (@($Request.Attributes) -contains 'nTSecurityDescriptor') { + $attributes['nTSecurityDescriptor'] = @(, $script:testDescriptorBytes) + } + [pscustomobject]@{ + Entries = @( + [pscustomobject]@{ + DistinguishedName = $Request.DistinguishedName + Attributes = $attributes + } + ) + } + } + $connection = [System.DirectoryServices.Protocols.LdapConnection]::new( + [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new( + 'dc01.example.test', 389, $true, $false + ) + ) + try { + $target = Resolve-WindowsADObjectTarget ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=Lab,DC=example,DC=test' ` + -TimeoutSeconds 10 ` + -Connection $connection + } + finally { + $connection.Dispose() + } + + $script:testRequests | Should -HaveCount 1 + @($script:testRequests[0].Attributes) | Should -Contain 'nTSecurityDescriptor' + @( + $script:testRequests[0].Controls | + Where-Object { $_ -is [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl] } + ) | Should -HaveCount 1 + [Convert]::ToBase64String($target.BinarySecurityDescriptor) | + Should -BeExactly ([Convert]::ToBase64String($script:testDescriptorBytes)) + } + } + + It 'Should resolve a contained write target and read its allowed base without a descriptor' { + InModuleScope WindowsAccessControl { + $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new( + 'O:BAG:BAD:(A;;RPWPCCDCLCSWRCWDWOGA;;;BA)' + ) + $script:testDescriptorBytes = [byte[]]::new($descriptor.BinaryLength) + $descriptor.GetBinaryForm($script:testDescriptorBytes, 0) + $script:testRequests = [System.Collections.Generic.List[object]]::new() + Mock Get-WindowsADRootDse { + [pscustomobject]@{ + DefaultNamingContext = 'DC=example,DC=test' + ConfigurationNamingContext = 'CN=Configuration,DC=example,DC=test' + SchemaNamingContext = 'CN=Schema,CN=Configuration,DC=example,DC=test' + RootDomainNamingContext = 'DC=example,DC=test' + } + } + Mock Send-WindowsADSearchRequest { + $script:testRequests.Add($Request) + $attributes = @{ + objectGUID = @(, [guid]::NewGuid().ToByteArray()) + objectClass = @('top', 'organizationalUnit') + } + if (@($Request.Attributes) -contains 'nTSecurityDescriptor') { + $attributes['nTSecurityDescriptor'] = @(, $script:testDescriptorBytes) + } + [pscustomobject]@{ + Entries = @( + [pscustomobject]@{ + DistinguishedName = $Request.DistinguishedName + Attributes = $attributes + } + ) + } + } + $connection = [System.DirectoryServices.Protocols.LdapConnection]::new( + [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new( + 'dc01.example.test', 389, $true, $false + ) + ) + try { + $target = Resolve-WindowsADObjectTarget ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=Child,OU=Lab,DC=example,DC=test' ` + -AllowedBaseDistinguishedName 'OU=Lab,DC=example,DC=test' ` + -TimeoutSeconds 10 ` + -ForWrite ` + -Connection $connection + { + Resolve-WindowsADObjectTarget ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=Other,DC=example,DC=test' ` + -AllowedBaseDistinguishedName 'OU=Lab,DC=example,DC=test' ` + -TimeoutSeconds 10 ` + -ForWrite ` + -Connection $connection + } | Should -Throw -ExpectedMessage '*outside the allowed organizational unit*' + } + finally { + $connection.Dispose() + } + + $target.DistinguishedName | Should -BeExactly 'OU=Child,OU=Lab,DC=example,DC=test' + [Convert]::ToBase64String($target.BinarySecurityDescriptor) | + Should -BeExactly ([Convert]::ToBase64String($script:testDescriptorBytes)) + $baseRequests = @( + $script:testRequests | + Where-Object DistinguishedName -EQ 'OU=Lab,DC=example,DC=test' + ) + $baseRequests | Should -HaveCount 2 + foreach ($request in $baseRequests) { + @($request.Attributes) | Should -Not -Contain 'nTSecurityDescriptor' + } + } + } + + It 'Should refuse to resolve a write target without its security descriptor' { + InModuleScope WindowsAccessControl { + Mock Send-WindowsADSearchRequest { throw 'No directory request is expected.' } + Mock Get-WindowsADRootDse { throw 'No directory request is expected.' } + $connection = [System.DirectoryServices.Protocols.LdapConnection]::new( + [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new( + 'dc01.example.test', 389, $true, $false + ) + ) + try { + { + Resolve-WindowsADObjectTarget ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=Child,OU=Lab,DC=example,DC=test' ` + -AllowedBaseDistinguishedName 'OU=Lab,DC=example,DC=test' ` + -TimeoutSeconds 10 ` + -ForWrite ` + -ExcludeSecurityDescriptor ` + -Connection $connection + } | Should -Throw -ExceptionType ([ArgumentException]) ` + -ExpectedMessage '*ExcludeSecurityDescriptor*' + } + finally { + $connection.Dispose() + } + + Should -Invoke Get-WindowsADRootDse -Times 0 -Exactly + Should -Invoke Send-WindowsADSearchRequest -Times 0 -Exactly + } + } + + It 'Should name the missing read-control access when a requested descriptor is ' -ForEach @( + @{ Shape = 'absent'; WithEmptyValue = $false } + @{ Shape = 'present without a value'; WithEmptyValue = $true } + ) { + InModuleScope WindowsAccessControl -Parameters @{ WithEmptyValue = $WithEmptyValue } { + $script:testWithEmptyValue = $WithEmptyValue + Mock Send-WindowsADSearchRequest { + # A caller without READ_CONTROL receives the entry without the + # attribute rather than an error. + $attributes = @{ + objectGUID = @(, [guid]::NewGuid().ToByteArray()) + objectClass = @('top', 'organizationalUnit') + } + if ($script:testWithEmptyValue) { + $attributes['nTSecurityDescriptor'] = @() + } + [pscustomobject]@{ + Entries = @( + [pscustomobject]@{ + DistinguishedName = $Request.DistinguishedName + Attributes = $attributes + } + ) + } + } + $connection = [System.DirectoryServices.Protocols.LdapConnection]::new( + [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new( + 'dc01.example.test', 389, $true, $false + ) + ) + try { + { + Get-WindowsADObjectRecord ` + -Connection $connection ` + -DistinguishedName 'OU=Lab,DC=example,DC=test' ` + -IncludeSecurityDescriptor + } | Should -Throw -ExceptionType ([UnauthorizedAccessException]) ` + -ExpectedMessage '*READ_CONTROL*' + } + finally { + $connection.Dispose() + } + } + } + It 'Should split a distinguished name at the first unescaped comma only' { & $script:module { Get-WindowsADParentDistinguishedName ` diff --git a/tests/Unit/Public/Get-ADObjectCallerEffectiveAccess.Tests.ps1 b/tests/Unit/Public/Get-ADObjectCallerEffectiveAccess.Tests.ps1 index b1f0abe..17f43e0 100644 --- a/tests/Unit/Public/Get-ADObjectCallerEffectiveAccess.Tests.ps1 +++ b/tests/Unit/Public/Get-ADObjectCallerEffectiveAccess.Tests.ps1 @@ -115,6 +115,132 @@ Describe 'Get-ADObjectCallerEffectiveAccess behavior' -Tag 'Unit', 'WindowsOnly' $result.Account | Should -BeExactly 'CONTOSO\analyst' } + + It 'Should evaluate a caller who cannot read the security descriptor' { + $outcome = InModuleScope WindowsAccessControl { + $script:testRequests = [System.Collections.Generic.List[object]]::new() + Mock New-WindowsADConnection { + [System.DirectoryServices.Protocols.LdapConnection]::new( + [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new( + 'dc01.example.test', + 389 + ) + ) + } + Mock Get-WindowsADRootDse { + [pscustomobject]@{ + DefaultNamingContext = 'DC=example,DC=test' + ConfigurationNamingContext = 'CN=Configuration,DC=example,DC=test' + SchemaNamingContext = 'CN=Schema,CN=Configuration,DC=example,DC=test' + RootDomainNamingContext = 'DC=example,DC=test' + } + } + Mock Send-WindowsADSearchRequest { + $script:testRequests.Add($Request) + # Without READ_CONTROL the controller omits nTSecurityDescriptor + # from the entry instead of failing the search. + [pscustomobject]@{ + Entries = @( + [pscustomobject]@{ + DistinguishedName = $Request.DistinguishedName + Attributes = @{ + objectGUID = @(, [guid]::NewGuid().ToByteArray()) + objectClass = @('top', 'organizationalUnit') + } + } + ) + } + } + Mock Get-WindowsADEffectiveAccessRecord { + [pscustomobject]@{ + WritableAttribute = @('description') + CreatableChildClass = @() + SDRightsEffective = 0 + } + } + + $script:WindowsAccessControlBatchWorker.Value = $true + try { + $result = Get-ADObjectCallerEffectiveAccess ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=Lab,DC=example,DC=test' ` + -ThrottleLimit 1 + } + finally { + $script:WindowsAccessControlBatchWorker.Value = $false + } + [pscustomobject]@{ + Result = $result + Requests = $script:testRequests.ToArray() + } + } + + $outcome.Result.DistinguishedName | Should -BeExactly 'OU=Lab,DC=example,DC=test' + $outcome.Result.WritableAttribute | Should -Be @('description') + $outcome.Requests | Should -Not -BeNullOrEmpty + foreach ($request in $outcome.Requests) { + @($request.Attributes) | Should -Not -Contain 'nTSecurityDescriptor' + @( + $request.Controls | + Where-Object { $_ -is [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl] } + ) | Should -BeNullOrEmpty + } + } + + It 'Should prevalidate every target without requesting the security descriptor' { + $requests = InModuleScope WindowsAccessControl { + $script:testRequests = [System.Collections.Generic.List[object]]::new() + Mock New-WindowsADConnection { + [System.DirectoryServices.Protocols.LdapConnection]::new( + [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new( + 'dc01.example.test', + 389 + ) + ) + } + Mock Get-WindowsADRootDse { + [pscustomobject]@{ + DefaultNamingContext = 'DC=example,DC=test' + ConfigurationNamingContext = 'CN=Configuration,DC=example,DC=test' + SchemaNamingContext = 'CN=Schema,CN=Configuration,DC=example,DC=test' + RootDomainNamingContext = 'DC=example,DC=test' + } + } + Mock Send-WindowsADSearchRequest { + $script:testRequests.Add($Request) + [pscustomobject]@{ + Entries = @( + [pscustomobject]@{ + DistinguishedName = $Request.DistinguishedName + Attributes = @{ + objectGUID = @(, [guid]::NewGuid().ToByteArray()) + objectClass = @('top', 'organizationalUnit') + } + } + ) + } + } + Mock Invoke-WindowsAccessControlBatch + + Get-ADObjectCallerEffectiveAccess ` + -Server 'dc01.example.test' ` + -DistinguishedName 'OU=One,OU=Lab,DC=example,DC=test', + 'OU=Two,OU=Lab,DC=example,DC=test' ` + -ThrottleLimit 2 + + Should -Invoke Invoke-WindowsAccessControlBatch -Times 1 -Exactly + , $script:testRequests.ToArray() + } + + $requests | Should -HaveCount 2 + foreach ($request in $requests) { + @($request.Attributes) | Should -Not -Contain 'nTSecurityDescriptor' + @( + $request.Controls | + Where-Object { $_ -is [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl] } + ) | Should -BeNullOrEmpty + } + } } Describe 'Caller-scoped directory result conversion' -Tag 'Unit', 'WindowsOnly' { From fbced954cfd58bcb28c040ef79c91f1f139fae30 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 16:38:25 +0000 Subject: [PATCH 07/20] perf: reduce descriptor processing and batch overhead Cache immutable rights displays, filter NTFS SIDs before conversion, and streamline exact ACE comparison and batch parameter copying. Add regression cases and cross-edition benchmark evidence while preserving public contracts. Core: 1811 passed with 82.64% asserted coverage. Final focused suites: 145 passed per edition. Full Desktop retains two existing WinRM-blocked DSC checks; no host configuration or remote was changed. Ported from ai/performance-refactor onto ai/post-release-fixes. Its Memory Bank hunks resolve to the current files, and the docs/README.md row joins the current rows. The figures above are from the original 2026-09-10 run against 5991673. (cherry picked from commit a3e3152a4d6c027204d4b075da27db779852c38e) Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + docs/README.md | 1 + docs/performance-refactor-2026-09-10.md | 118 ++++++++++ source/Prefix.ps1 | 9 + .../ConvertTo-WindowsAccessRightsDisplay.ps1 | 37 ++-- source/Private/Get-WindowsADRemovedAce.ps1 | 32 +-- .../Private/Invoke-WindowsADCommandBatch.ps1 | 5 +- .../Invoke-WindowsAccessControlBatch.ps1 | 10 +- .../Invoke-WindowsNtfsCommandBatch.ps1 | 6 +- .../Invoke-WindowsProcessCommandBatch.ps1 | 6 +- .../Invoke-WindowsRegistryCommandBatch.ps1 | 6 +- .../Invoke-WindowsServiceCommandBatch.ps1 | 6 +- .../Invoke-WindowsSmbShareCommandBatch.ps1 | 5 +- ...nvoke-WindowsTaskSchedulerCommandBatch.ps1 | 5 +- .../Test-WindowsCngKeyDaclEquivalent.ps1 | 18 +- source/Public/Get-NTFSAccessRule.ps1 | 16 +- source/Public/Get-NTFSAuditRule.ps1 | 14 +- ...easure-DescriptorProcessingPerformance.ps1 | 209 ++++++++++++++++++ .../Measure-NtfsBatchPerformance.ps1 | 16 +- ...ertTo-WindowsAccessRightsDisplay.Tests.ps1 | 55 +++++ .../Private/WindowsADAccessControl.Tests.ps1 | 51 +++++ .../Private/WindowsCngKeyMutation.Tests.ps1 | 25 +++ .../Unit/Public/Get-NTFSAccessRule.Tests.ps1 | 27 +++ tests/Unit/Public/Get-NTFSAuditRule.Tests.ps1 | 27 +++ 24 files changed, 616 insertions(+), 91 deletions(-) create mode 100644 docs/performance-refactor-2026-09-10.md create mode 100644 tests/Performance/Measure-DescriptorProcessingPerformance.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d401e16..e008136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add [repeatable descriptor-processing benchmarks](tests/Performance/Measure-DescriptorProcessingPerformance.ps1) and explicit artifact selection for NTFS batch benchmarks, with module hashes and per-run measurements - Allow domain-lab acceptance runs to reuse the payload already on the management domain controller with `-SkipPayloadDeployment`. The existing `-SkipPayload` spelling remains an alias, and `-SkipDeployment` provides a @@ -414,6 +415,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Reduce repeated rights formatting with bounded per-enum caching, filter NTFS accounts before rule conversion, and streamline parameter copying across all batch adapters +- Make Active Directory removed-ACE detection use counted ordinal lookups and reduce allocation and sorting overhead in CNG DACL comparison while preserving duplicate ACEs and exact identity - Expand the comment-based help examples for `Add-ADObjectAccessRule` and its sibling mutators `Set-ADObjectAccessRule`, `Remove-ADObjectAccessRule`, and `Clear-ADObjectAccessRule`. Each command had carried only one or two basic diff --git a/docs/README.md b/docs/README.md index ec1b517..688aa4d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ what the module does, and comment-based help defines every parameter. | [research.md](research.md) | Source review, platform API semantics, and the comparison that informed the specifications | Contributors and reviewers | | [domain-lab-inventory.md](domain-lab-inventory.md) | Machines, forests, and roles of the disposable acceptance lab | Contributors running the lab suites | | [lab-reacceptance-2026-09-07.md](lab-reacceptance-2026-09-07.md) | Corrected-candidate lab, package, DSC, local coverage, and cleanup evidence | Maintainers and reviewers | +| [performance-refactor-2026-09-10.md](performance-refactor-2026-09-10.md) | Performance changes, repeatable measurements, validation evidence, and remaining gates | Contributors and reviewers | ## The usage guide diff --git a/docs/performance-refactor-2026-09-10.md b/docs/performance-refactor-2026-09-10.md new file mode 100644 index 0000000..0e9c933 --- /dev/null +++ b/docs/performance-refactor-2026-09-10.md @@ -0,0 +1,118 @@ +# Performance refactor evidence + +This pass reduces repeated work in the shared descriptor helpers and all seven +batch adapters. It preserves the 105 public commands, 20 DSC resources, runtime +dependencies, selected-section persistence, native resource ownership, +confirmation behavior, and target locking. It does not rewrite every file or +claim that every workload is faster. + +## Changes + +- Cache immutable rights-enum metadata per module instance and at most 128 + rendered masks per enum. Additional masks are calculated normally. Security + descriptors, account lookups, tokens, and handles are not cached. +- Apply the NTFS access and audit SID filters before account translation and + rule formatting. Resolve requested identities as before, and use a SID set + for membership checks. +- Count retained Active Directory ACE identities with ordinal hash lookups + instead of searching and shrinking a list. Preserve full binary identity, + duplicate counts, and the original order of removed ACEs. +- Sort CNG comparison keys using ordinal .NET array sorting. Keep ordered + desired-state comparison distinct from unordered post-write comparison. +- Replace pipeline-based metric-parameter counting with Boolean checks and + replace per-target parameter-copy loops with shallow hashtable clones in + NTFS, registry, service, process, SMB, AD, and Task Scheduler adapters. + +## Measurements + +Measured on Windows build 26200 with eight logical processors, PowerShell +7.6.5 and Windows PowerShell 5.1.26100.9168. The baseline artifact matches the +original implementations of all 13 changed functions at commit `5991673`. +Each artifact ran in a fresh process without concurrent tests or builds. + +The [descriptor benchmark](../tests/Performance/Measure-DescriptorProcessingPerformance.ps1) +warms each workload, alternates workload order, records every elapsed time and +checksum, and reports medians. Ordinary rows use 50 operations, seven measured +runs, and 256 ACEs where applicable. Core CNG uses an isolated 200-operation, +nine-run comparison because the mixed-workload runs were noisy. Candidate ran +before baseline in that isolated comparison. All paired output checksums match. + +| Workload | Edition | Before (ms) | After (ms) | Time reduction | +| --- | --- | ---: | ---: | ---: | +| Repeated rights display | Core | 3.56 | 1.20 | 66.4% | +| Repeated rights display | Desktop | 4.10 | 2.69 | 34.4% | +| Removed-ACE calculation | Core | 702.65 | 250.35 | 64.4% | +| Removed-ACE calculation | Desktop | 738.91 | 39.63 | 94.6% | +| Single-target dispatch | Core | 8.30 | 7.11 | 14.4% | +| Single-target dispatch | Desktop | 21.65 | 12.18 | 43.8% | +| CNG equivalence, isolated | Core | 864.71 | 742.22 | 14.2% | +| CNG equivalence | Desktop | 1224.83 | 1122.51 | 8.4% | + +The [NTFS benchmark](../tests/Performance/Measure-NtfsBatchPerformance.ps1) +uses 256 disposable files, seven runs per mode, and a parallel throttle of +eight. Its averages were effectively unchanged for sequential owner reads: +477.24 to 478.01 ms on Core and 543.91 to 548.23 ms on Desktop. Parallel owner +reads changed from 531.58 to 520.40 ms on Core and 647.86 to 625.82 ms on +Desktop. These small differences are not evidence of a general I/O speedup. + +Rights-cache results represent repeated-mask workloads, not cold or mostly +unique masks. Pure-helper timings do not measure LDAP latency, CNG persistence, +or every public command. There are no timing assertions in the unit suite. + +## Validation + +- Added nine regression and characterization cases in existing test files. + Both NTFS filter tests failed before the change because excluded accounts + reached conversion, then passed after rebuilding. The other cases pin enum + isolation, cache overflow, duplicate ACEs, removal order, and exact payloads. +- Full Core Sampler `test`: 1,811 passed, zero failed, two environment skips. + Asserted coverage is 82.64% against the unchanged 80% gate. Whole-module + coverage is 79.96%; no domain-lab evidence was merged. The final packaged + module was used, and the module-identity gate observed one compilation. +- Full Desktop QA, unit, and integration run: 1,764 passed, two failed, two + environment skips. Only the two DSC-engine calls failed, both with + `HRESULT 0x80338012` because local WinRM is stopped. This is the previously + recorded host prerequisite blocker, not a green Desktop acceptance result. +- After the final analyzer annotation and packaging, all 145 focused tests + passed in each edition against the exact packaged module bytes. +- PSScriptAnalyzer 1.25.0 checked the entire source tree and changed tests. + Changed files have zero findings. Three warnings in unchanged helpers match + their `HEAD` versions: callback use of `DomainSid` in + `ConvertTo-WindowsADAbsoluteSddl`, and `ShouldProcess` heuristics for the + in-memory helpers `New-NTFSFileSystemRule` and + `Remove-NTFSFileSystemRuleSpecific`. Standalone DSC analysis requires the + compiled `WindowsAccessControlDscReason` type to be registered in that + isolated analyzer process. +- Sampler `pack`: 22 tasks passed, zero errors or warnings. Archive inspection + confirmed matching module bytes, all 20 DSC help files, and MAML help. Source + and built manifests validate. The local version remains the existing + GitVersion fallback `0.0.1`; this is not a published release. + +## Artifact identity + +| Artifact | SHA-256 | +| --- | --- | +| Original module | `A70F3C1E5347C8196D5A9656F785C91B9008316E54778C0A3ED389D2DAEF019D` | +| Final module | `358158116D14787E5EF82D3C129D987F2587A973D7671E8ECFDC012900428B16` | +| Final package | `8197CC5A89C12D571628E24D3A6D9EC06ED310ED796968E694B822C26FBFD8E3` | + +Raw logs, NUnit results, coverage, before/after JSON measurements, and the +comparison receipt remain in the administrator's local +`%TEMP%/wac-performance-c2da0d57f81549c186fae225009431f1` directory. They are not +part of the repository or lab payload. + +## Reproduction and remaining gates + +Build through the repository's isolated Sampler workflow, then run each +benchmark in a fresh PowerShell process. Both accept `ModuleManifestPath` to +select an explicit artifact and `OutputPath` for JSON evidence. Match the +parameters above and compare the recorded module hashes and output checksums +before comparing times. The descriptor benchmark leaves its module loaded +until process exit to preserve the module-identity invariant. + +No migration is needed. Rollback means reverting the focused refactor commit +and rebuilding; no persisted format or runtime dependency changed. Host WinRM, +service configuration, and remoting settings were not changed for validation. +Live domain-lab and installed-package acceptance were not performed. Independent +review is recommended with `review: on` because the changes affect shared +batching and security-sensitive ACE comparisons; it was not requested here. diff --git a/source/Prefix.ps1 b/source/Prefix.ps1 index d6acdec..893e9c5 100644 --- a/source/Prefix.ps1 +++ b/source/Prefix.ps1 @@ -153,6 +153,15 @@ $script:WindowsAccessControlTargetLockSyncRoot = $targetLockState.SyncRoot $script:WindowsAccessControlTargetLocks = $targetLockState.Locks $script:WindowsAccessControlMetricSyncRoot = [object]::new() $script:WindowsAccessControlMetrics = @{} +$script:WindowsAccessRightsDisplayCache = @{} +$script:WindowsAccessStandardRightNames = [ordered]@{ + 0x01000000L = 'AccessSystemSecurity' + 0x02000000L = 'MaximumAllowed' + 0x10000000L = 'GenericAll' + 0x20000000L = 'GenericExecute' + 0x40000000L = 'GenericWrite' + 0x80000000L = 'GenericRead' +} $script:WindowsADSchemaGuidNames = @{} $script:WindowsAccessControlBatchWorker = [System.Threading.ThreadLocal[bool]]::new() diff --git a/source/Private/ConvertTo-WindowsAccessRightsDisplay.ps1 b/source/Private/ConvertTo-WindowsAccessRightsDisplay.ps1 index 8209108..4761a1b 100644 --- a/source/Private/ConvertTo-WindowsAccessRightsDisplay.ps1 +++ b/source/Private/ConvertTo-WindowsAccessRightsDisplay.ps1 @@ -11,12 +11,23 @@ function ConvertTo-WindowsAccessRightsDisplay { ) $mask = [uint64]$AccessMask - $values = [uint64[]]@( - foreach ($value in [System.Enum]::GetValues($RightsType)) { - [uint64]([int64]$value -band 0xFFFFFFFFL) + if (-not $script:WindowsAccessRightsDisplayCache.ContainsKey($RightsType)) { + $values = [uint64[]]@( + foreach ($value in [System.Enum]::GetValues($RightsType)) { + [uint64]([int64]$value -band 0xFFFFFFFFL) + } + ) + [System.Array]::Sort($values) + $script:WindowsAccessRightsDisplayCache[$RightsType] = [pscustomobject]@{ + Values = $values + Displays = @{} } - ) - [System.Array]::Sort($values) + } + $metadata = $script:WindowsAccessRightsDisplayCache[$RightsType] + if ($metadata.Displays.ContainsKey($AccessMask)) { + return $metadata.Displays[$AccessMask] + } + $values = $metadata.Values # Repeat the greedy decomposition Enum.ToString performs so that the part # the enum can name is still rendered by .NET, and only the bits it has no @@ -40,15 +51,7 @@ function ConvertTo-WindowsAccessRightsDisplay { # object type on access, and the standard rights above them are legal in any # mask, so an object family whose enum omits them still has to report them by # name. Ascending bit order matches Enum.ToString output. - $standardRightName = [ordered]@{ - 0x01000000L = 'AccessSystemSecurity' - 0x02000000L = 'MaximumAllowed' - 0x10000000L = 'GenericAll' - 0x20000000L = 'GenericExecute' - 0x40000000L = 'GenericWrite' - 0x80000000L = 'GenericRead' - } - foreach ($entry in $standardRightName.GetEnumerator()) { + foreach ($entry in $script:WindowsAccessStandardRightNames.GetEnumerator()) { $bit = [uint64]$entry.Key if (($residual -band $bit) -eq $bit) { $parts.Add($entry.Value) @@ -60,5 +63,9 @@ function ConvertTo-WindowsAccessRightsDisplay { $parts.Add(('0x{0:X8}' -f $residual)) } - $parts -join ', ' + $display = $parts -join ', ' + if ($metadata.Displays.Count -lt 128) { + $metadata.Displays[$AccessMask] = $display + } + $display } diff --git a/source/Private/Get-WindowsADRemovedAce.ps1 b/source/Private/Get-WindowsADRemovedAce.ps1 index 3665060..dbba732 100644 --- a/source/Private/Get-WindowsADRemovedAce.ps1 +++ b/source/Private/Get-WindowsADRemovedAce.ps1 @@ -1,4 +1,9 @@ function Get-WindowsADRemovedAce { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseLiteralInitializerForHashtable', + '', + Justification = 'Binary ACE identities require an ordinal comparer; a literal hashtable is case-insensitive.' + )] [CmdletBinding()] [OutputType([System.Security.AccessControl.GenericAce])] param( @@ -9,14 +14,6 @@ function Get-WindowsADRemovedAce { [byte[]]$SecurityDescriptor ) - $getAceBytes = { - param([System.Security.AccessControl.GenericAce]$Ace) - - $bytes = [byte[]]::new($Ace.BinaryLength) - $Ace.GetBinaryForm($bytes, 0) - [Convert]::ToBase64String($bytes) - } - $originalAcl = [System.Security.AccessControl.RawSecurityDescriptor]::new( $OriginalSecurityDescriptor, 0 @@ -30,16 +27,25 @@ function Get-WindowsADRemovedAce { ).DiscretionaryAcl # Multiset difference, so a duplicated ACE is reported once per removed copy. - $retained = [System.Collections.Generic.List[string]]::new() + $retained = [System.Collections.Hashtable]::new( + [System.StringComparer]::Ordinal + ) if ($currentAcl) { foreach ($ace in $currentAcl) { - $retained.Add((& $getAceBytes $ace)) + $bytes = [byte[]]::new($ace.BinaryLength) + $ace.GetBinaryForm($bytes, 0) + $identity = [Convert]::ToBase64String($bytes) + $remainingCount = [int]$retained[$identity] + $retained[$identity] = $remainingCount + 1 } } foreach ($ace in $originalAcl) { - $index = $retained.IndexOf((& $getAceBytes $ace)) - if ($index -ge 0) { - $retained.RemoveAt($index) + $bytes = [byte[]]::new($ace.BinaryLength) + $ace.GetBinaryForm($bytes, 0) + $identity = [Convert]::ToBase64String($bytes) + $remainingCount = [int]$retained[$identity] + if ($remainingCount -gt 0) { + $retained[$identity] = $remainingCount - 1 continue } $ace diff --git a/source/Private/Invoke-WindowsADCommandBatch.ps1 b/source/Private/Invoke-WindowsADCommandBatch.ps1 index 8eaa058..af585d2 100644 --- a/source/Private/Invoke-WindowsADCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsADCommandBatch.ps1 @@ -73,10 +73,7 @@ function Invoke-WindowsADCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() $targetParameters.DistinguishedName = $Target.DistinguishedName $script:WindowsAccessControlBatchWorker.Value = $true try { diff --git a/source/Private/Invoke-WindowsAccessControlBatch.ps1 b/source/Private/Invoke-WindowsAccessControlBatch.ps1 index 7e4105f..77ef945 100644 --- a/source/Private/Invoke-WindowsAccessControlBatch.ps1 +++ b/source/Private/Invoke-WindowsAccessControlBatch.ps1 @@ -42,16 +42,14 @@ function Invoke-WindowsAccessControlBatch { 'SerializeByCanonicalTarget requires CanonicalTargetProperty.' ) } - $metricParameterCount = @( - $PSBoundParameters.ContainsKey('CommandName') - $PSBoundParameters.ContainsKey('ObjectFamily') - ) | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count - if ($metricParameterCount -eq 1) { + $hasCommandName = $PSBoundParameters.ContainsKey('CommandName') + $hasObjectFamily = $PSBoundParameters.ContainsKey('ObjectFamily') + if ($hasCommandName -ne $hasObjectFamily) { throw [System.ArgumentException]::new( 'CommandName and ObjectFamily must be supplied together.' ) } - $metricsEnabled = $metricParameterCount -eq 2 + $metricsEnabled = $hasCommandName -and $hasObjectFamily $inputValues = @( if ($PSBoundParameters.ContainsKey('CanonicalTargetProperty')) { $seenTargets = [System.Collections.Generic.HashSet[string]]::new( diff --git a/source/Private/Invoke-WindowsNtfsCommandBatch.ps1 b/source/Private/Invoke-WindowsNtfsCommandBatch.ps1 index 9ace887..e6480e6 100644 --- a/source/Private/Invoke-WindowsNtfsCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsNtfsCommandBatch.ps1 @@ -56,11 +56,7 @@ function Invoke-WindowsNtfsCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = - $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() $targetParameters.LiteralPath = $Target.LiteralPath $script:WindowsAccessControlBatchWorker.Value = $true try { diff --git a/source/Private/Invoke-WindowsProcessCommandBatch.ps1 b/source/Private/Invoke-WindowsProcessCommandBatch.ps1 index a605059..af28a54 100644 --- a/source/Private/Invoke-WindowsProcessCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsProcessCommandBatch.ps1 @@ -69,11 +69,7 @@ function Invoke-WindowsProcessCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = - $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() if ($Target.DescriptorSource -eq 'Handle') { $targetParameters.Handle = [IntPtr]$Target.TargetValue } else { diff --git a/source/Private/Invoke-WindowsRegistryCommandBatch.ps1 b/source/Private/Invoke-WindowsRegistryCommandBatch.ps1 index ae3beea..e7230dd 100644 --- a/source/Private/Invoke-WindowsRegistryCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsRegistryCommandBatch.ps1 @@ -52,11 +52,7 @@ function Invoke-WindowsRegistryCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = - $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() $targetParameters.Path = $Target.TargetValue $script:WindowsAccessControlBatchWorker.Value = $true try { diff --git a/source/Private/Invoke-WindowsServiceCommandBatch.ps1 b/source/Private/Invoke-WindowsServiceCommandBatch.ps1 index 08f6523..bd1453c 100644 --- a/source/Private/Invoke-WindowsServiceCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsServiceCommandBatch.ps1 @@ -61,11 +61,7 @@ function Invoke-WindowsServiceCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = - $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() if ($Target.ObjectFamily -eq 'ServiceControlManager') { $targetParameters.ServiceControlManager = $true } else { diff --git a/source/Private/Invoke-WindowsSmbShareCommandBatch.ps1 b/source/Private/Invoke-WindowsSmbShareCommandBatch.ps1 index 3a07337..2ed08b0 100644 --- a/source/Private/Invoke-WindowsSmbShareCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsSmbShareCommandBatch.ps1 @@ -47,10 +47,7 @@ function Invoke-WindowsSmbShareCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() $targetParameters.Name = $Target.ShareName $script:WindowsAccessControlBatchWorker.Value = $true try { diff --git a/source/Private/Invoke-WindowsTaskSchedulerCommandBatch.ps1 b/source/Private/Invoke-WindowsTaskSchedulerCommandBatch.ps1 index 92d5bee..748b6b0 100644 --- a/source/Private/Invoke-WindowsTaskSchedulerCommandBatch.ps1 +++ b/source/Private/Invoke-WindowsTaskSchedulerCommandBatch.ps1 @@ -61,10 +61,7 @@ function Invoke-WindowsTaskSchedulerCommandBatch { $worker = { param($Target, $Context) - $targetParameters = @{} - foreach ($parameterName in $Context.Parameters.Keys) { - $targetParameters[$parameterName] = $Context.Parameters[$parameterName] - } + $targetParameters = $Context.Parameters.Clone() $targetParameters[$Context.PathParameterName] = $Target.TaskPath if ($Target.ObjectType -eq 'ScheduledTask') { $targetParameters.TaskName = $Target.TaskName diff --git a/source/Private/Test-WindowsCngKeyDaclEquivalent.ps1 b/source/Private/Test-WindowsCngKeyDaclEquivalent.ps1 index 8e6d799..bf145a7 100644 --- a/source/Private/Test-WindowsCngKeyDaclEquivalent.ps1 +++ b/source/Private/Test-WindowsCngKeyDaclEquivalent.ps1 @@ -31,20 +31,22 @@ function Test-WindowsCngKeyDaclEquivalent { return $false } - $leftKeys = @(ConvertTo-WindowsCngKeyAceKey -Acl $Left.DiscretionaryAcl) - $rightKeys = @(ConvertTo-WindowsCngKeyAceKey -Acl $Right.DiscretionaryAcl) + $leftKeys = [string[]]@(ConvertTo-WindowsCngKeyAceKey -Acl $Left.DiscretionaryAcl) + $rightKeys = [string[]]@(ConvertTo-WindowsCngKeyAceKey -Acl $Right.DiscretionaryAcl) if ($leftKeys.Count -ne $rightKeys.Count) { return $false } # Verification after a write compares the multiset, because the provider # decides the stored order. A caller asking whether a candidate is already # the desired state must compare the sequence, because ACE order changes - # which rule wins. The array wrapper is outside the if so a one-ACE DACL - # does not collapse to a string and compare character by character. - $leftOrdered = @(if ($Ordered) { $leftKeys } else { $leftKeys | Sort-Object }) - $rightOrdered = @(if ($Ordered) { $rightKeys } else { $rightKeys | Sort-Object }) - for ($index = 0; $index -lt $leftOrdered.Count; $index++) { - if ($leftOrdered[$index] -cne $rightOrdered[$index]) { + # which rule wins. Typed arrays keep a one-ACE DACL from collapsing to a + # string and comparing character by character. + if (-not $Ordered) { + [Array]::Sort($leftKeys, [StringComparer]::Ordinal) + [Array]::Sort($rightKeys, [StringComparer]::Ordinal) + } + for ($index = 0; $index -lt $leftKeys.Count; $index++) { + if ($leftKeys[$index] -cne $rightKeys[$index]) { return $false } } diff --git a/source/Public/Get-NTFSAccessRule.ps1 b/source/Public/Get-NTFSAccessRule.ps1 index 685fbfb..1f8fa0d 100644 --- a/source/Public/Get-NTFSAccessRule.ps1 +++ b/source/Public/Get-NTFSAccessRule.ps1 @@ -85,11 +85,12 @@ function Get-NTFSAccessRule { throw 'ExcludeInherited and ExcludeExplicit cannot be used together.' } - $accountSids = @( - foreach ($accountName in $Account) { - (Resolve-WindowsIdentityReference -Identity $accountName).Value - } + $accountSids = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase ) + foreach ($accountName in $Account) { + $null = $accountSids.Add((Resolve-WindowsIdentityReference -Identity $accountName).Value) + } } process { @@ -136,6 +137,10 @@ function Get-NTFSAccessRule { if ($ExcludeExplicit -and -not $rule.IsInherited) { continue } + if ($accountSids.Count -gt 0 -and + -not $accountSids.Contains($rule.IdentityReference.Value)) { + continue + } $conversionParameters = @{ Rule = $rule Path = $item.FullName @@ -146,9 +151,6 @@ function Get-NTFSAccessRule { } } $result = ConvertTo-NTFSAccessRuleObject @conversionParameters - if ($accountSids.Count -gt 0 -and $result.SID -notin $accountSids) { - continue - } if ($Orphaned -and -not $result.IsOrphaned) { continue } diff --git a/source/Public/Get-NTFSAuditRule.ps1 b/source/Public/Get-NTFSAuditRule.ps1 index 7c6dc08..97ca802 100644 --- a/source/Public/Get-NTFSAuditRule.ps1 +++ b/source/Public/Get-NTFSAuditRule.ps1 @@ -77,11 +77,12 @@ function Get-NTFSAuditRule { if ($ExcludeInherited -and $ExcludeExplicit) { throw 'ExcludeInherited and ExcludeExplicit cannot be used together.' } - $accountSids = @( - foreach ($accountName in $Account) { - (Resolve-WindowsIdentityReference -Identity $accountName).Value - } + $accountSids = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase ) + foreach ($accountName in $Account) { + $null = $accountSids.Add((Resolve-WindowsIdentityReference -Identity $accountName).Value) + } } process { @@ -110,10 +111,11 @@ function Get-NTFSAuditRule { [System.Security.Principal.SecurityIdentifier] ) foreach ($rule in $rules) { - $result = ConvertTo-NTFSAuditRuleObject -Rule $rule -Path $item.FullName - if ($accountSids.Count -gt 0 -and $result.SID -notin $accountSids) { + if ($accountSids.Count -gt 0 -and + -not $accountSids.Contains($rule.IdentityReference.Value)) { continue } + $result = ConvertTo-NTFSAuditRuleObject -Rule $rule -Path $item.FullName if ($Orphaned -and -not $result.IsOrphaned) { continue } diff --git a/tests/Performance/Measure-DescriptorProcessingPerformance.ps1 b/tests/Performance/Measure-DescriptorProcessingPerformance.ps1 new file mode 100644 index 0000000..3ed3988 --- /dev/null +++ b/tests/Performance/Measure-DescriptorProcessingPerformance.ps1 @@ -0,0 +1,209 @@ +[CmdletBinding()] +param( + [Parameter()] + [ValidateRange(4, 1024)] + [int]$AceCount = 256, + + [Parameter()] + [ValidateRange(1, 10000)] + [int]$OperationCount = 50, + + [Parameter()] + [ValidateRange(1, 20)] + [int]$Iterations = 5, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateSet('RightsDisplay', 'RemovedAce', 'CngEquivalence', 'SingleTargetDispatch')] + [string[]]$Workload = @('RightsDisplay', 'RemovedAce', 'CngEquivalence', 'SingleTargetDispatch'), + + [Parameter()] + [string]$ModuleManifestPath, + + [Parameter()] + [string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (Get-Module -Name WindowsAccessControl) { + throw 'Run the benchmark in a fresh PowerShell process without WindowsAccessControl loaded.' +} +if ([string]::IsNullOrWhiteSpace($ModuleManifestPath)) { + $repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path + $manifest = Get-ChildItem -Path ( + Join-Path $repositoryRoot 'output\module\WindowsAccessControl\*\WindowsAccessControl.psd1' + ) | Sort-Object -Property { [version]$_.Directory.Name } -Descending | Select-Object -First 1 + if (-not $manifest) { + throw 'Build the module before running the performance benchmark.' + } + $ModuleManifestPath = $manifest.FullName +} + +$module = Import-Module -Name $ModuleManifestPath -PassThru -ErrorAction Stop +& { + param($Module, $AceCount, $OperationCount, $Iterations, $Workload, $OutputPath) + + $original = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:SYG:SYD:') + $reversed = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:SYG:SYD:') + $retained = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:SYG:SYD:') + $trustee = [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0') + for ($aceIndex = 0; $aceIndex -lt $AceCount; $aceIndex++) { + $ace = [System.Security.AccessControl.CommonAce]::new( + [System.Security.AccessControl.AceFlags]::None, + [System.Security.AccessControl.AceQualifier]::AccessAllowed, + $aceIndex + 1, + $trustee, + $false, + $null + ) + $original.DiscretionaryAcl.InsertAce($aceIndex, $ace) + $reversed.DiscretionaryAcl.InsertAce(0, $ace) + if ($aceIndex % 4 -ne 0) { + $retained.DiscretionaryAcl.InsertAce(0, $ace) + } + } + $originalBytes = [byte[]]::new($original.BinaryLength) + $original.GetBinaryForm($originalBytes, 0) + $retainedBytes = [byte[]]::new($retained.BinaryLength) + $retained.GetBinaryForm($retainedBytes, 0) + $fixtures = @{ + Original = $original + Reversed = $reversed + OriginalBytes = $originalBytes + RetainedBytes = $retainedBytes + RemovedCount = [int][Math]::Ceiling($AceCount / 4.0) + } + + $runWorkload = { + param($Name, $Count, $Fixtures) + + $checksum = [long]0 + switch ($Name) { + RightsDisplay { + $rightsTypes = @( + [System.Security.AccessControl.FileSystemRights] + [System.Security.AccessControl.RegistryRights] + [WindowsServiceRights] + ) + $masks = @(0x001200A9L, 0xE0010000L, 0x80000400L, 0x001F01FFL) + for ($operation = 0; $operation -lt $Count; $operation++) { + $parameters = @{ + AccessMask = $masks[$operation % $masks.Count] + RightsType = $rightsTypes[$operation % $rightsTypes.Count] + } + $display = ConvertTo-WindowsAccessRightsDisplay @parameters + $checksum += $display.Length + } + } + RemovedAce { + $parameters = @{ + OriginalSecurityDescriptor = $Fixtures.OriginalBytes + SecurityDescriptor = $Fixtures.RetainedBytes + } + for ($operation = 0; $operation -lt $Count; $operation++) { + $removed = @(Get-WindowsADRemovedAce @parameters) + if ($removed.Count -ne $Fixtures.RemovedCount) { + throw 'The ACE difference returned an incorrect removal count.' + } + $checksum += $removed.Count + } + } + CngEquivalence { + $parameters = @{ Left = $Fixtures.Original; Right = $Fixtures.Reversed } + for ($operation = 0; $operation -lt $Count; $operation++) { + if (-not (Test-WindowsCngKeyDaclEquivalent @parameters)) { + throw 'The reordered DACL must remain equivalent.' + } + $checksum++ + } + } + SingleTargetDispatch { + $parameters = @{ + InputObject = @(1) + ScriptBlock = { param($InputValue) $InputValue } + ThrottleLimit = 1 + } + for ($operation = 0; $operation -lt $Count; $operation++) { + $result = @(Invoke-WindowsAccessControlBatch @parameters) + if ($result.Count -ne 1 -or $result[0] -ne 1) { + throw 'The dispatcher must return its single input.' + } + $checksum += $result.Count + } + } + } + $checksum + } + + $expected = @{} + foreach ($name in $Workload) { + $expected[$name] = & $module $runWorkload $name $OperationCount $fixtures + } + $runs = [System.Collections.Generic.List[object]]::new() + for ($iteration = 1; $iteration -le $Iterations; $iteration++) { + $orderedWorkload = [string[]]$Workload.Clone() + if ($iteration % 2 -eq 0) { + [Array]::Reverse($orderedWorkload) + } + foreach ($name in $orderedWorkload) { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $checksum = & $module $runWorkload $name $OperationCount $fixtures + $stopwatch.Stop() + if ($checksum -ne $expected[$name]) { + throw "The $name workload returned inconsistent results." + } + $runs.Add([pscustomobject][ordered]@{ + Workload = $name + Iteration = $iteration + OperationCount = $OperationCount + ElapsedMilliseconds = [Math]::Round($stopwatch.Elapsed.TotalMilliseconds, 3) + Checksum = $checksum + }) + } + } + $summary = @( + foreach ($group in $runs | Group-Object -Property Workload) { + $elapsed = [double[]]$group.Group.ElapsedMilliseconds + [Array]::Sort($elapsed) + $middle = [int][Math]::Floor($elapsed.Count / 2.0) + $median = if ($elapsed.Count % 2 -eq 0) { + ($elapsed[$middle - 1] + $elapsed[$middle]) / 2.0 + } else { + $elapsed[$middle] + } + [pscustomobject][ordered]@{ + Workload = $group.Name + MedianElapsedMilliseconds = [Math]::Round($median, 3) + MinimumElapsedMilliseconds = $elapsed[0] + MaximumElapsedMilliseconds = $elapsed[$elapsed.Count - 1] + } + } + ) + $benchmark = [pscustomobject][ordered]@{ + SchemaVersion = 1 + MeasuredUtc = [DateTime]::UtcNow.ToString('o') + ModuleVersion = $module.Version.ToString() + ModuleSha256 = (Get-FileHash -LiteralPath $module.Path -Algorithm SHA256).Hash + PowerShellVersion = $PSVersionTable.PSVersion.ToString() + PowerShellEdition = $PSVersionTable.PSEdition + OperatingSystem = [Environment]::OSVersion.VersionString + ProcessorCount = [Environment]::ProcessorCount + AceCount = $AceCount + OperationCount = $OperationCount + Iterations = $Iterations + Runs = $runs.ToArray() + Summary = $summary + } + if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) + $null = [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($resolvedPath)) + [System.IO.File]::WriteAllText( + $resolvedPath, + ($benchmark | ConvertTo-Json -Depth 6), + [System.Text.UTF8Encoding]::new($false) + ) + } + $benchmark +} $module $AceCount $OperationCount $Iterations $Workload $OutputPath diff --git a/tests/Performance/Measure-NtfsBatchPerformance.ps1 b/tests/Performance/Measure-NtfsBatchPerformance.ps1 index 743c723..93f6d51 100644 --- a/tests/Performance/Measure-NtfsBatchPerformance.ps1 +++ b/tests/Performance/Measure-NtfsBatchPerformance.ps1 @@ -15,6 +15,9 @@ param( [Math]::Min(8, [Environment]::ProcessorCount) ), + [Parameter()] + [string]$ModuleManifestPath, + [Parameter()] [string]$OutputPath ) @@ -23,10 +26,14 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path -$moduleManifest = Get-ChildItem -Path ( - Join-Path $repositoryRoot 'output\module\WindowsAccessControl\*\WindowsAccessControl.psd1' -) | Sort-Object -Property { [version]$_.Directory.Name } -Descending | - Select-Object -First 1 +$moduleManifest = if ([string]::IsNullOrWhiteSpace($ModuleManifestPath)) { + Get-ChildItem -Path ( + Join-Path $repositoryRoot 'output\module\WindowsAccessControl\*\WindowsAccessControl.psd1' + ) | Sort-Object -Property { [version]$_.Directory.Name } -Descending | + Select-Object -First 1 +} else { + Get-Item -LiteralPath $ModuleManifestPath -ErrorAction Stop +} if (-not $moduleManifest) { throw 'Build the module before running the performance benchmark.' } @@ -103,6 +110,7 @@ try { SchemaVersion = 1 MeasuredUtc = [DateTime]::UtcNow.ToString('o') ModuleVersion = $module.Version.ToString() + ModuleSha256 = (Get-FileHash -LiteralPath $module.Path -Algorithm SHA256).Hash PowerShellVersion = $PSVersionTable.PSVersion.ToString() PowerShellEdition = $PSVersionTable.PSEdition OperatingSystem = [Environment]::OSVersion.VersionString diff --git a/tests/Unit/Private/ConvertTo-WindowsAccessRightsDisplay.Tests.ps1 b/tests/Unit/Private/ConvertTo-WindowsAccessRightsDisplay.Tests.ps1 index 2208141..ba1d4aa 100644 --- a/tests/Unit/Private/ConvertTo-WindowsAccessRightsDisplay.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-WindowsAccessRightsDisplay.Tests.ps1 @@ -53,6 +53,61 @@ Describe 'Access rights display' -Tag 'Unit', 'WindowsOnly' { $display | Should -Be 'GenericExecute, GenericWrite, GenericRead' } + It 'Should keep repeated rights decompositions isolated by enum type' { + $comparisons = @(& $script:module { + $rightsTypes = @( + [System.Security.AccessControl.FileSystemRights] + [System.Security.AccessControl.RegistryRights] + [WindowsServiceRights] + ) + for ($iteration = 0; $iteration -lt 3; $iteration++) { + foreach ($rightsType in $rightsTypes) { + $parameters = @{ AccessMask = 1; RightsType = $rightsType } + [pscustomobject]@{ + Actual = ConvertTo-WindowsAccessRightsDisplay @parameters + Expected = [string][System.Enum]::ToObject($rightsType, 1) + } + } + } + }) + + $comparisons | Should -HaveCount 9 + foreach ($comparison in $comparisons) { + $comparison.Actual | Should -BeExactly $comparison.Expected + } + } + + It 'Should still reject a non-enum type after an enum has been used' { + { + & $script:module { + $null = ConvertTo-WindowsAccessRightsDisplay -AccessMask 1 -RightsType ( + [System.Security.AccessControl.FileSystemRights] + ) + ConvertTo-WindowsAccessRightsDisplay -AccessMask 1 -RightsType ([string]) + } + } | Should -Throw -ExceptionType ([System.Management.Automation.MethodInvocationException]) + } + + It 'Should preserve every result when repeated masks exceed the display cache capacity' { + $comparisons = @(& $script:module { + $rightsType = [System.Security.AccessControl.FileSystemRights] + for ($iteration = 0; $iteration -lt 2; $iteration++) { + for ($mask = 0; $mask -lt 256; $mask++) { + $parameters = @{ AccessMask = $mask; RightsType = $rightsType } + [pscustomobject]@{ + Actual = ConvertTo-WindowsAccessRightsDisplay @parameters + Expected = [string][System.Enum]::ToObject($rightsType, $mask) + } + } + } + }) + + $comparisons | Should -HaveCount 512 + foreach ($comparison in $comparisons) { + $comparison.Actual | Should -BeExactly $comparison.Expected + } + } + It 'Should reject a mask outside the 32-bit range' { { & $script:module { diff --git a/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 b/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 index 3996479..d7cc5d8 100644 --- a/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 +++ b/tests/Unit/Private/WindowsADAccessControl.Tests.ps1 @@ -1289,6 +1289,57 @@ Describe 'Active Directory broader DACL mutation' -Tag 'Unit', 'WindowsOnly' { @($removed.AccessMask | Sort-Object) | Should -Be @(16, 32) } + It 'Should consume retained duplicate ACEs once and preserve removal order' { + $commonAce = New-TestCommonAce -Sid $script:everyone -Mask 16 + $objectAce = New-TestObjectAce -Sid $script:everyone -Mask 32 -ObjectType $script:userClass + $adminAce = New-TestCommonAce -Sid $script:administrators -Mask 0x40000 + $original = New-TestAdDescriptor -Ace @($commonAce, $objectAce, $commonAce, $adminAce) + $current = New-TestAdDescriptor -Ace @($adminAce, $commonAce) + + $removed = @(& $script:module { + param($Original, $Current) + $parameters = @{ OriginalSecurityDescriptor = $Original; SecurityDescriptor = $Current } + Get-WindowsADRemovedAce @parameters + } $original $current) + + $removed | Should -HaveCount 2 + $removed[0] | Should -BeOfType ([Security.AccessControl.ObjectAce]) + $removed[0].ObjectAceType | Should -Be $script:userClass + $removed[0].AccessMask | Should -Be 32 + $removed[1].AccessMask | Should -Be 16 + $removed[1].SecurityIdentifier.Value | Should -Be $script:everyone.Value + } + + It 'Should compare removed binary ACE identities case-sensitively' { + $originalAce = [Security.AccessControl.CommonAce]::new( + [Security.AccessControl.AceFlags]::None, + [Security.AccessControl.AceQualifier]::AccessAllowed, + 16, + $script:everyone, + $true, + [byte[]]@(0, 0, 0, 0) + ) + $currentAce = [Security.AccessControl.CommonAce]::new( + [Security.AccessControl.AceFlags]::None, + [Security.AccessControl.AceQualifier]::AccessAllowed, + 16, + $script:everyone, + $true, + [byte[]]@(104, 0, 0, 0) + ) + $original = New-TestAdDescriptor -Ace @($originalAce) + $current = New-TestAdDescriptor -Ace @($currentAce) + + $removed = @(& $script:module { + param($Original, $Current) + $parameters = @{ OriginalSecurityDescriptor = $Original; SecurityDescriptor = $Current } + Get-WindowsADRemovedAce @parameters + } $original $current) + + $removed | Should -HaveCount 1 + $removed[0].GetOpaque() | Should -Be ([byte[]]@(0, 0, 0, 0)) + } + It 'Should accept a DACL that still grants a principal WriteDacl' { $bytes = New-TestAdDescriptor -Ace @( New-TestCommonAce -Sid $script:administrators -Mask 0x40000 ` diff --git a/tests/Unit/Private/WindowsCngKeyMutation.Tests.ps1 b/tests/Unit/Private/WindowsCngKeyMutation.Tests.ps1 index c39e6a1..da54200 100644 --- a/tests/Unit/Private/WindowsCngKeyMutation.Tests.ps1 +++ b/tests/Unit/Private/WindowsCngKeyMutation.Tests.ps1 @@ -77,6 +77,31 @@ Describe 'Certificate private-key rights normalization' -Tag 'Unit', 'WindowsOnl $result | Should -BeFalse } + + It 'Should preserve duplicate ACE counts during an unordered comparison of ' -ForEach @( + @{ + Name = 'the same multiset' + RightSddl = 'D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;SY)' + Expected = $true + } + @{ + Name = 'different multiplicities' + RightSddl = 'D:P(A;;FA;;;BA)(A;;FA;;;BA)(A;;FA;;;SY)' + Expected = $false + } + ) { + $left = script:ConvertTo-Descriptor 'D:P(A;;FA;;;SY)(A;;FA;;;SY)(A;;FA;;;BA)' + $right = script:ConvertTo-Descriptor $RightSddl + $originalSddl = $left.GetSddlForm([Security.AccessControl.AccessControlSections]::All) + + $result = & $script:module { + param($Left, $Right) + Test-WindowsCngKeyDaclEquivalent -Left $Left -Right $Right + } $left $right + + $result | Should -Be $Expected + $left.GetSddlForm([Security.AccessControl.AccessControlSections]::All) | Should -BeExactly $originalSddl + } } Describe 'Certificate private-key protected ACE gate' -Tag 'Unit', 'WindowsOnly' { diff --git a/tests/Unit/Public/Get-NTFSAccessRule.Tests.ps1 b/tests/Unit/Public/Get-NTFSAccessRule.Tests.ps1 index 149d4c8..f34b886 100644 --- a/tests/Unit/Public/Get-NTFSAccessRule.Tests.ps1 +++ b/tests/Unit/Public/Get-NTFSAccessRule.Tests.ps1 @@ -31,6 +31,33 @@ Describe 'Get-NTFSAccessRule orphan handling' -Tag 'Unit', 'WindowsOnly' { $result.IsOrphaned | Should -BeTrue } + It 'Should not translate or format accounts excluded by the account filter' { + $matchingRule = [System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'), + [System.Security.AccessControl.FileSystemRights]::Read, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $script:testSecurity.AddAccessRule($matchingRule) + Mock -ModuleName WindowsAccessControl -CommandName ConvertTo-NTFSAccessRuleObject -ParameterFilter { + $Rule.IdentityReference.Value -eq $script:orphanSid + } -MockWith { + throw 'An excluded account must not be translated or formatted.' + } + + $parameters = @{ + LiteralPath = $script:testFile + Account = 'S-1-1-0' + ExcludeInherited = $true + ThrottleLimit = 1 + ErrorAction = 'Stop' + } + $result = @(Get-NTFSAccessRule @parameters) + + $result | Should -HaveCount 1 + $result[0].SID | Should -Be 'S-1-1-0' + Should -Invoke -ModuleName WindowsAccessControl -CommandName ConvertTo-NTFSAccessRuleObject -Times 0 -Exactly + } + It 'Should show the SID in the account column of the table view' { $result = Get-NTFSAccessRule -LiteralPath $script:testFile -Orphaned diff --git a/tests/Unit/Public/Get-NTFSAuditRule.Tests.ps1 b/tests/Unit/Public/Get-NTFSAuditRule.Tests.ps1 index 0d1d4b4..33a7849 100644 --- a/tests/Unit/Public/Get-NTFSAuditRule.Tests.ps1 +++ b/tests/Unit/Public/Get-NTFSAuditRule.Tests.ps1 @@ -30,6 +30,33 @@ Describe 'Get-NTFSAuditRule' -Tag 'Unit', 'WindowsOnly' { $result.AuditFlags | Should -Be ([System.Security.AccessControl.AuditFlags]::Failure) } + It 'Should not translate or format accounts excluded by the account filter' { + $excludedRule = [System.Security.AccessControl.FileSystemAuditRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18'), + [System.Security.AccessControl.FileSystemRights]::Write, + [System.Security.AccessControl.AuditFlags]::Success + ) + $script:testSecurity.AddAuditRule($excludedRule) + Mock -ModuleName WindowsAccessControl -CommandName ConvertTo-NTFSAuditRuleObject -ParameterFilter { + $Rule.IdentityReference.Value -eq 'S-1-5-18' + } -MockWith { + throw 'An excluded account must not be translated or formatted.' + } + + $parameters = @{ + LiteralPath = $script:testFile + Account = 'S-1-1-0' + ExcludeInherited = $true + ThrottleLimit = 1 + ErrorAction = 'Stop' + } + $result = @(Get-NTFSAuditRule @parameters) + + $result | Should -HaveCount 1 + $result[0].SID | Should -Be 'S-1-1-0' + Should -Invoke -ModuleName WindowsAccessControl -CommandName ConvertTo-NTFSAuditRuleObject -Times 0 -Exactly + } + It 'Should surface a missing SeSecurityPrivilege error' { Mock -ModuleName WindowsAccessControl -CommandName Get-Acl -MockWith { throw [System.Security.AccessControl.PrivilegeNotHeldException]::new( From d13a5172391cad4f33f9275995c27d8dc219b57a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 16:47:08 +0000 Subject: [PATCH 08/20] docs: state the user-visible impact of the performance change Rewrite the release notes to lead with measured impact and the unchanged command surface instead of internal mechanism, document the descriptor benchmark on the diagnostics page, and record why the ordinal comparer is required for Base64 directory identities but only defensive for uppercase-hexadecimal private-key identities. Ported from ai/performance-refactor onto ai/post-release-fixes. Its Memory Bank hunks resolve to the current files. (cherry picked from commit 44dcc70247872a9bbb46f62265e38476a6bdc07a) Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 26 ++++++++++++++++++++++--- docs/performance-refactor-2026-09-10.md | 10 ++++++++-- docs/usage/diagnostics.md | 12 ++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e008136..cc9c804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add [repeatable descriptor-processing benchmarks](tests/Performance/Measure-DescriptorProcessingPerformance.ps1) and explicit artifact selection for NTFS batch benchmarks, with module hashes and per-run measurements +- Add a repeatable descriptor-processing benchmark beside the existing NTFS + read benchmark, covering rights rendering, removed-entry detection, DACL + equivalence, and single-target dispatch. Both benchmarks now accept + `-ModuleManifestPath` to measure one specific built artifact and record its + SHA-256 with every run, so a before-and-after comparison names the exact + module it measured - Allow domain-lab acceptance runs to reuse the payload already on the management domain controller with `-SkipPayloadDeployment`. The existing `-SkipPayload` spelling remains an alias, and `-SkipDeployment` provides a @@ -415,8 +420,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Reduce repeated rights formatting with bounded per-enum caching, filter NTFS accounts before rule conversion, and streamline parameter copying across all batch adapters -- Make Active Directory removed-ACE detection use counted ordinal lookups and reduce allocation and sorting overhead in CNG DACL comparison while preserving duplicate ACEs and exact identity +- Speed up the shared descriptor paths that every object family reads through. + Measured on PowerShell 7 over 256 access-control entries, repeated rights + rendering is 66% faster, removed-entry detection 64% faster, and + single-target batch dispatch 14% faster; on Windows PowerShell 5.1 + removed-entry detection is 95% faster. Every measured workload improved on + both editions. The public command surface, output objects, descriptor + semantics, and required privileges are unchanged, and filesystem owner + reads are unchanged because their cost is the operating-system call rather + than the module. See the + [measurement report](docs/performance-refactor-2026-09-10.md) for the + method, the artifact hashes, and the limits of each figure +- Keep exact access-control entry identity while comparing descriptors faster. + Removed-entry detection for Active Directory still reports one entry per + removed copy in its original order and still compares the complete binary + entry, so the deny-removal warning raised before a directory write keeps + its exact-match guarantee. Private-key DACL comparison keeps an ordered + desired-state check separate from unordered post-write verification - Expand the comment-based help examples for `Add-ADObjectAccessRule` and its sibling mutators `Set-ADObjectAccessRule`, `Remove-ADObjectAccessRule`, and `Clear-ADObjectAccessRule`. Each command had carried only one or two basic diff --git a/docs/performance-refactor-2026-09-10.md b/docs/performance-refactor-2026-09-10.md index 0e9c933..4fcd245 100644 --- a/docs/performance-refactor-2026-09-10.md +++ b/docs/performance-refactor-2026-09-10.md @@ -16,9 +16,15 @@ claim that every workload is faster. for membership checks. - Count retained Active Directory ACE identities with ordinal hash lookups instead of searching and shrinking a list. Preserve full binary identity, - duplicate counts, and the original order of removed ACEs. + duplicate counts, and the original order of removed ACEs. The comparer has to + stay ordinal: those identities are Base64, where two distinct entries can + differ only in letter case, so a case-insensitive map would merge them and + report a removed entry as retained. - Sort CNG comparison keys using ordinal .NET array sorting. Keep ordered - desired-state comparison distinct from unordered post-write comparison. + desired-state comparison distinct from unordered post-write comparison. Every + component of a private-key ACE key is uppercase hexadecimal or a fixed + literal, so two keys cannot differ only in case and the ordinal sort is + defensive rather than a behavior fix. - Replace pipeline-based metric-parameter counting with Boolean checks and replace per-target parameter-copy loops with shallow hashtable clones in NTFS, registry, service, process, SMB, AD, and Task Scheduler adapters. diff --git a/docs/usage/diagnostics.md b/docs/usage/diagnostics.md index 372bf9f..1231480 100644 --- a/docs/usage/diagnostics.md +++ b/docs/usage/diagnostics.md @@ -127,6 +127,18 @@ result instead of a flaky timing assertion: -OutputPath .\output\testResults\NtfsBatchBenchmark.json ``` +A second benchmark measures the shared descriptor paths every object family +uses, rather than filesystem calls: rights rendering, removed-entry detection, +DACL equivalence, and single-target dispatch. + +```powershell +.\tests\Performance\Measure-DescriptorProcessingPerformance.ps1 -AceCount 256 -Iterations 5 +``` + +Run each in a fresh process. Both accept `-ModuleManifestPath` to measure one +specific built module and record its SHA-256 in the result, which is what lets +a before-and-after comparison prove which artifact produced each number. + ## Commands on this page | Area | Commands | From b8604489639488e01bb9dc5b19d7f8e221a23bb6 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 16:47:39 +0000 Subject: [PATCH 09/20] docs: reconcile the performance evidence with the review outcome State that the refactor diff was reviewed in the session that wrote it with no finding of the two highest severities, that independent review is still recommended, and that a later repository-wide review requested changes elsewhere that also gate acceptance. Ported from ai/performance-refactor onto ai/post-release-fixes. The original body named the second review severity by its label, which GitVersion.yml treats as a version bump keyword; this body describes the severity instead. (cherry picked from commit 5f06c03e1f894da3c5f1778b14aa86a89cf0ea30) Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/performance-refactor-2026-09-10.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/performance-refactor-2026-09-10.md b/docs/performance-refactor-2026-09-10.md index 4fcd245..9389496 100644 --- a/docs/performance-refactor-2026-09-10.md +++ b/docs/performance-refactor-2026-09-10.md @@ -119,6 +119,9 @@ until process exit to preserve the module-identity invariant. No migration is needed. Rollback means reverting the focused refactor commit and rebuilding; no persisted format or runtime dependency changed. Host WinRM, service configuration, and remoting settings were not changed for validation. -Live domain-lab and installed-package acceptance were not performed. Independent -review is recommended with `review: on` because the changes affect shared -batching and security-sensitive ACE comparisons; it was not requested here. +Live domain-lab and installed-package acceptance were not performed. This diff +was reviewed for security and quality in the session that wrote it, with no +Blocker or Major finding; that is not independent review, which stays +recommended for the shared batching and ACE-comparison changes. A later +repository-wide review requested changes for findings in other, unchanged +files, so acceptance is gated on those as well as on the runs above. From 87d4f35c28e487195373a0c6e79ffb4b73502372 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 16:50:57 +0000 Subject: [PATCH 10/20] test(batch): cover the dispatcher command and family guard Pin the ArgumentException that Invoke-WindowsAccessControlBatch raises when CommandName or ObjectFamily arrives without the other, and prove that no worker ran. With the guard removed from source, both cases failed in PowerShell 7.6.6 and Windows PowerShell 5.1 because no exception was thrown; with it restored, all 14 dispatcher tests pass in both editions. Also pin that an NTFS adapter worker copies the parameters every runspace shares before it writes its target path, as the independent review asked. With the copy replaced by a reference, the shared table kept the first target in both editions; with the copy restored, all 15 tests in the file pass in both editions. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...Invoke-WindowsAccessControlBatch.Tests.ps1 | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/Unit/Private/Invoke-WindowsAccessControlBatch.Tests.ps1 b/tests/Unit/Private/Invoke-WindowsAccessControlBatch.Tests.ps1 index d1100fd..81aa08c 100644 --- a/tests/Unit/Private/Invoke-WindowsAccessControlBatch.Tests.ps1 +++ b/tests/Unit/Private/Invoke-WindowsAccessControlBatch.Tests.ps1 @@ -225,6 +225,41 @@ Describe 'Invoke-WindowsAccessControlBatch' -Tag 'Unit', 'WindowsOnly' { $targetErrors[0].Exception.Message | Should -Be 'Expected target failure.' } + It 'Should reject without before dispatching any target' -ForEach @( + @{ + Supplied = 'CommandName' + Missing = 'ObjectFamily' + MetricParameters = @{ CommandName = 'Test-UnpairedMetricParameter' } + } + @{ + Supplied = 'ObjectFamily' + Missing = 'CommandName' + MetricParameters = @{ ObjectFamily = 'FileSystem' } + } + ) { + $recorder = [pscustomobject]@{ InvocationCount = 0 } + $worker = & $script:module { + { + param($InputValue, $Recorder) + $Recorder.InvocationCount++ + $InputValue + } + } + + { + & $script:module { + param($Worker, $Recorder, $MetricParameters) + Invoke-WindowsAccessControlBatch ` + -InputObject @(1, 2) ` + -ScriptBlock $Worker ` + -ArgumentList $Recorder ` + -ThrottleLimit 1 ` + @MetricParameters + } $worker $recorder $MetricParameters + } | Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage 'CommandName and ObjectFamily must be supplied together.' + $recorder.InvocationCount | Should -Be 0 + } + It 'Should classify a nonterminating inline target error as a failure' { $worker = & $script:module { { @@ -436,3 +471,45 @@ $module = Get-Module -Name 'WindowsAccessControl' $counter.Current | Should -Be 0 } } + +Describe 'Batch adapter workers' -Tag 'Unit', 'WindowsOnly' { + It 'Should give each NTFS target its own copy of the shared parameters' { + $paths = @( + (Join-Path $TestDrive 'first.txt') + (Join-Path $TestDrive 'second.txt') + ) + foreach ($path in $paths) { + Set-Content -LiteralPath $path -Value 'x' + } + Mock -ModuleName WindowsAccessControl -CommandName Invoke-WindowsAccessControlBatch -MockWith { + [pscustomobject]@{ + Worker = $ScriptBlock + Context = $ArgumentList[0] + Targets = $InputObject + } + } + Mock -ModuleName WindowsAccessControl -CommandName Get-NTFSItemOwner -MockWith { + [pscustomobject]@{ + LiteralPath = [string]$LiteralPath + ThrottleLimit = $ThrottleLimit + } + } + + $capture = & $script:module { + param($Paths) + Invoke-WindowsNtfsCommandBatch ` + -CommandName 'Get-NTFSItemOwner' ` + -BoundParameters @{ LiteralPath = $Paths; ThrottleLimit = 8 } ` + -LiteralPath $Paths ` + -ThrottleLimit 8 + } $paths + $first = & $capture.Worker $capture.Targets[0] $capture.Context + $second = & $capture.Worker $capture.Targets[1] $capture.Context + + $first.LiteralPath | Should -BeExactly $capture.Targets[0].LiteralPath + $second.LiteralPath | Should -BeExactly $capture.Targets[1].LiteralPath + $first.ThrottleLimit | Should -Be 1 + $capture.Context.Parameters.ContainsKey('LiteralPath') | Should -BeFalse -Because 'a worker must not write its target into the parameters every runspace shares' + $capture.Context.Parameters.Keys | Should -Be @('ThrottleLimit') + } +} From 7786f08d448929abbda50105ca201ed2b3fef001 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 16:53:39 +0000 Subject: [PATCH 11/20] docs(performance): re-measure against the current baseline Rerun both benchmarks in fresh processes in PowerShell 7.6.6 and Windows PowerShell 5.1 against the integration branch before and after the ported perf commit, after checking module hashes and output checksums. The documented rows keep their direction: on PowerShell 7 rights rendering is 69% faster, removed-entry detection 66%, single-target batch calls 18%, and isolated private-key DACL comparison 15%. Label the 2026-09-10 sections historical, record the mixed-workload warm-up that makes one Core CNG row slower, and cite the new figures in the release notes. Record the final gates, the package hash, and the independent review, and correct the private-key key description, the cache scope, and the scope of the 256-entry figures that the review flagged. Port the durable lessons of the refactor branch into the Memory Bank and close out handoff 03. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 58 +++++----- .memory-bank/debugging-insights.md | 20 ++++ .memory-bank/progress.md | 69 +++++------ .memory-bank/systemPatterns.md | 7 ++ CHANGELOG.md | 17 +-- docs/performance-refactor-2026-09-10.md | 146 ++++++++++++++++++++++-- 6 files changed, 236 insertions(+), 81 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 205a88b..81b2b70 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,24 +9,30 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Handoff 02 of the 2026-10-06 sequence is complete on the local branch -`ai/post-release-fixes`: three review defects are fixed test-first, one commit -each. A Task Scheduler write may now repair a DACL that has no Local System -ACE, while a missing or null current DACL is still refused. An SMB share DACL -write reads the description immediately before the native call, keeps a -concurrent edit, and after a committed write reports description trouble as a -warning, never an error. `Get-ADObjectCallerEffectiveAccess` no longer -requests `nTSecurityDescriptor`, and a command that needs a missing descriptor -raises a typed read-control error. One independent review approved with minor -findings; the cheap ones were fixed before the commits were finalized. The two -Desktop DSC-engine tests fail locally only because WinRM is stopped. No live lab -run took place; handoff 07 runs the two new AD live cases. Next is handoff 03. -No git remote was mutated. +Handoff 03 of the 2026-10-06 sequence is complete on the local branch +`ai/post-release-fixes`; the user chose to finish the orphaned performance +refactor. `fbced95` ports its code, tests, and benchmarks from +`ai/performance-refactor` as one revertable `perf:` commit, and `d13a517` and +`b860448` port its documentation; every Memory Bank hunk resolved to the +current files. The ported private-key DACL comparison holds over the FIND-001 +returned-bytes setter, and the seven batch adapters still pass handoff 02's +Task Scheduler, SMB, and AD parameters unchanged. `87d4f35` pins the +dispatcher's `CommandName`/`ObjectFamily` guard and an NTFS adapter worker's +per-target parameter copy; each test failed with its guard removed. The +`docs(performance)` commit re-measured both benchmarks before and after the +`perf:` commit: hashes and checksums match, and the gains hold. One +independent review approved with one Minor and three Nits, all addressed. The +ported `5f06c03` body was reworded because "Major" in it would have made the +next release 1.0.0. The Desktop gate still fails only the two WinRM-bound +DSC-engine tests. No live lab run took place; handoff 07 owns the first live +and installed-package acceptance of the refactor. Next is handoff 04. No git +remote was mutated. -Handoff 01 made the domain-lab acceptance runner refuse to delete directories -it did not create. The 2026-10-06 triage below remains the record of the open -work. `main` is `40e364a`, in sync with `origin/main`; the triage restored the -deleted local branch `ai/performance-refactor` at `5f06c03`. +Handoffs 01 and 02 made the lab runner refuse to delete what it did not +create and fixed the Task Scheduler, SMB, and AD review defects. The +2026-10-06 triage below remains the record of the open work. `main` is +`40e364a`, in sync with `origin/main`; `ai/performance-refactor` stays at +`5f06c03` until handoff 08 deletes it with approval. ## Handoff sequence @@ -55,13 +61,12 @@ and cleanup. Only 08 touches the remote, with approval per action. ## Open work, in recommended order -1. **Orphaned performance refactor.** `ai/performance-refactor` holds five - commits on `5991673` (2026-09-10): `a3e3152` plus review and documentation - commits. It was never pushed, and its ref was deleted before 2026-10-05. - Source and test files merge cleanly onto `main`; the Memory Bank files and - `docs/README.md` conflict. Its CNG comparison change must be revalidated - over `f5731f1`. Live-lab and installed-package acceptance never ran. - Decide whether to rebase and finish it or drop it deliberately. +1. **Performance refactor, ported by handoff 03.** `ai/performance-refactor` + (five commits on `5991673`, 2026-09-10, never pushed) is now on + `ai/post-release-fixes` as `fbced95`, `d13a517`, and `b860448`, re-measured + against the current baseline. Live-lab and installed-package acceptance of + it never ran; handoff 07 owns them. The source branch stays at `5f06c03` + until handoff 08 deletes it with approval. 2. **Repository-wide review findings (2026-09-10).** They were recorded only on that branch. Rechecked by reading `main` on 2026-10-06, not by running anything: @@ -90,8 +95,9 @@ and cleanup. Only 08 touches the remote, with approval per action. - Minor, fixed by handoff 02: `Get-ADObjectCallerEffectiveAccess` requested `nTSecurityDescriptor` it never used, and a caller without read-control access got a null-index error. Live evidence is owed by handoff 07. - - Follow-ups: `README.md` line 315 still calls private keys read-only; the - batch dispatcher `CommandName`/`ObjectFamily` guard has no test. + - Follow-ups: `README.md` line 315 still calls private keys read-only. The + batch dispatcher `CommandName`/`ObjectFamily` guard is tested since + handoff 03. 3. **Dependabot pull requests #1-#3** bump `actions/checkout` to 7.0.1, `actions/upload-artifact` to 7.0.1, and `actions/download-artifact` to 8.0.1. Their red test jobs are inherited: all three branch from `4806726` diff --git a/.memory-bank/debugging-insights.md b/.memory-bank/debugging-insights.md index df4dbf5..81fc900 100644 --- a/.memory-bank/debugging-insights.md +++ b/.memory-bank/debugging-insights.md @@ -61,6 +61,26 @@ task`; pass the list through `-Command` instead. And the `Clean` task deletes `output/*`, so a detached run whose transcript is redirected into `output` fails on its own open log file. Redirect build logs outside `output`. +Dot-sourcing a changed function inside `& $module { ... }` does not replace a +rebuild: the exported command can keep the old definition. On 2026-09-10 both +NTFS filter regression tests stayed red because `Get-Command` still returned +the old export, and a rebuild turned the same tests green. Test a rebuilt +artifact, or check the exported definition itself. + +## A review severity word in a commit body bumps the version + +`GitVersion.yml` reads every commit message case-insensitively for +`(breaking\schange|breaking|major)\b`, `(adds?|features?|minor)\b`, and +`\s?(fix|patch)`, and only the last has no leading word boundary. Ordinary +prose trips them: "no Blocker or Major" in a review summary asks for a new +major version, "Add regression cases" for a minor one, and "dispatcher", +"prefix", or the branch name `ai/post-release-fixes` for a patch. On +2026-10-06 the body of the ported `5f06c03` said "no Blocker or Major"; ported +verbatim it would have made the next `main` release 1.0.0. Check every message +against the three patterns before committing, and describe a review severity +instead of naming it. A minor or patch match is harmless while `v0.2.0` plus +the existing minor bumps already put `main` on 0.3.0. + ## DscResource.DocGenerator wants one DSC resource class per source file Adding the DSC Community wiki tasks failed on the first one. diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index f20892f..9ac6f0b 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -12,15 +12,29 @@ source: repository evidence `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage found unmerged and untracked work and split it into eight handoffs on local -`ai/post-release-fixes`. Handoffs 01 and 02 fixed the lab-runner Blocker and -the Task Scheduler, SMB, and AD review defects there; the restored -`ai/performance-refactor` branch, three dependabot pull requests with -inherited failures, unanswered issue #4 with a missing NTFSSecurity migration -map, an uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit -remain. `activeContext.md` holds the evidence and the order. +`ai/post-release-fixes`. Handoffs 01 to 03 fixed the lab-runner Blocker, the +Task Scheduler, SMB, and AD review defects, and ported and re-measured the +performance refactor there; three dependabot pull requests with inherited +failures, unanswered issue #4 with a missing NTFSSecurity migration map, an +uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit remain. +`activeContext.md` holds the evidence and the order. ## Recent milestones +- 2026-10-06: Handoff 03 finished the orphaned performance refactor on + `ai/post-release-fixes`, as the user chose. Its code, tests, and + evidence were ported without the old Memory Bank hunks, and the code + stays one revertable commit. A new test pins the dispatcher's + `CommandName`/`ObjectFamily` guard, and one pins an adapter worker's + per-target parameter copy; each failed with its guard removed. + Re-measured in fresh processes against the branch before the port, with + matching hashes and checksums: Core rights display 69 percent faster, + removed ACEs 66, dispatch 18, isolated CNG 15; Desktop removed ACEs 95. + The Core gate passes 1,917 tests with two environmental skips at 83.11 + percent asserted coverage; Desktop fails only the two WinRM-bound + DSC-engine tests, with 80.95 percent scoped coverage computed. One + independent review approved; its Minor and three Nits were addressed. + - 2026-10-06: Handoff 02 fixed three review defects test-first on `ai/post-release-fixes`, one commit each. A Task Scheduler write may repair a DACL without a Local System ACE; a missing or null current DACL @@ -146,37 +160,12 @@ remain. `activeContext.md` holds the evidence and the order. uncommitted. It was subsequently withdrawn at the user's request in favor of the standard task and runner investigation recorded above. -- 2026-09-06: Diagnosed GitHub Actions run `34026468199`, attempt 2. Secret - validation, the GitHub release, both release assets, and PowerShell Gallery - publication succeeded. `Publish_GitHub_Wiki_Content` then hung at - `git commit` for exactly the dependency's 120-second timeout and reported - exit code `-1` with empty output. The generated archive has 127 files and - about 6,878 bytes of per-file commit summary. `Invoke-Git` waits for Git to - exit before draining redirected output, reproducing the open upstream bug - DscResource.DocGenerator#111. The wiki remains at its initial Home page and - has no version tag. A blind rerun can collide with the already-published, - immutable Gallery version; no rerun or remote mutation was performed. - -- 2026-09-06: Fixed GitHub Actions run `34021812398`. A static PowerShell - class method left all new `AccessRights` completers attached to a disposed - bounded-worker context in Windows PowerShell 5.1. The shared completion - method is now instance-bound, and a real before/after batch regression was - red then green. A direct HTTP.sys, WinRM, and Remote Desktop binding test - raised hosted executable-scope coverage without weakening ADR 0027. Final - Desktop: 1,723 passed, zero failed, two skips, 80.20% coverage. Final Core: - 1,768 passed, zero failed, two skips, 82.40% coverage. Both Sampler gates - passed ten tasks with zero errors or warnings; no remote operation ran. - -- 2026-09-06: Reviewed `ai/access-rights-completion` against the validated - audit result and found one focused, additive commit. Its production and - test files merge cleanly; documentation conflicts came from stale complete - copies and retain the current records plus the new completion facts. The - fresh build passed, both PowerShell editions passed 30 focused completion - tests, and the full Core gate passed 1,766 tests with two environmental - skips and 81.93% asserted coverage. - -- 2026-09-03 to 2026-09-05: OI-31, the 105-command audit, the audit-gap - closure, and the `6f7ba15` close-out are in - `git show a48ec3d:.memory-bank/progress.md`. Lessons kept: record transient - host paths without a user profile; keep imports unforced and run docs and - tests in separate processes. +- 2026-09-03 to 2026-09-06: OI-31, the 105-command audit, the audit-gap + closure, the `6f7ba15` close-out, the `34026468199` wiki-publication hang + (DscResource.DocGenerator#111), the `34021812398` completer fix, and the + `ai/access-rights-completion` review are in + `git show 8b67058:.memory-bank/progress.md` and, for the oldest, + `git show a48ec3d:.memory-bank/progress.md`. Lessons kept: record + transient host paths without a user profile; keep imports unforced and run + docs and tests in separate processes; bind completers to an instance, not + a disposed worker context. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index 056544a..804832e 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -45,6 +45,13 @@ platforms before replacing a dependency. Package and test this module on Windows; publish the prepared artifacts on Ubuntu as the DSC Community reference pipelines do. Run `34055979655` verified this split on 2026-09-06: the standard task published `0.2.0-preview0002` and its generated wiki. +Cache only immutable enum data and bounded rendered masks per module +instance, never descriptors or identity lookups. Binary ACE keys need ordinal +comparison and duplicate counts, load-bearing for Base64 identities that can +differ only in case. Measure real PowerShell code in fresh processes: ordinal +`Hashtable` indexing beat `[ref]` dictionary lookups. Check artifact hashes and +output checksums before medians, prefer an isolated row over a warm-up-bound +mixed one, and do not read helper gains as faster native I/O. | Decision | Summary | | --- | --- | diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9c804..81ae972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -421,14 +421,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Speed up the shared descriptor paths that every object family reads through. - Measured on PowerShell 7 over 256 access-control entries, repeated rights - rendering is 66% faster, removed-entry detection 64% faster, and - single-target batch dispatch 14% faster; on Windows PowerShell 5.1 - removed-entry detection is 95% faster. Every measured workload improved on - both editions. The public command surface, output objects, descriptor - semantics, and required privileges are unchanged, and filesystem owner - reads are unchanged because their cost is the operating-system call rather - than the module. See the + Measured on PowerShell 7, repeated rights rendering is 69% faster, + removed-entry detection over 256 access-control entries 66% faster, and + single-target batch dispatch 18% faster; on Windows PowerShell 5.1 + removed-entry detection is 95% faster. Private-key DACL comparison over + 256 entries is 15% faster on PowerShell 7 when measured on its own and 4% + faster on Windows PowerShell 5.1. The public command surface, output + objects, descriptor semantics, and required privileges are unchanged, and + filesystem owner reads are unchanged because their cost is the + operating-system call rather than the module. See the [measurement report](docs/performance-refactor-2026-09-10.md) for the method, the artifact hashes, and the limits of each figure - Keep exact access-control entry identity while comparing descriptors faster. diff --git a/docs/performance-refactor-2026-09-10.md b/docs/performance-refactor-2026-09-10.md index 9389496..c1ad30c 100644 --- a/docs/performance-refactor-2026-09-10.md +++ b/docs/performance-refactor-2026-09-10.md @@ -6,11 +6,19 @@ dependencies, selected-section persistence, native resource ownership, confirmation behavior, and target locking. It does not rewrite every file or claim that every workload is faster. +The measurement, validation, and identity sections dated 2026-09-10 are +historical: they compare against `5991673`. The change was later ported onto +the integration branch and measured again; see the +[2026-10-06 addendum](#addendum-re-measurement-on-2026-10-06). + ## Changes - Cache immutable rights-enum metadata per module instance and at most 128 rendered masks per enum. Additional masks are calculated normally. Security - descriptors, account lookups, tokens, and handles are not cached. + descriptors, account lookups, tokens, and handles are not cached. Each + parallel worker runspace imports its own module instance, so a parallel + batch warms the cache once per worker; the gain is for masks repeated within + one runspace, such as many rules on one target or a sequential batch. - Apply the NTFS access and audit SID filters before account translation and rule formatting. Resolve requested identities as before, and use a SID set for membership checks. @@ -22,14 +30,18 @@ claim that every workload is faster. report a removed entry as retained. - Sort CNG comparison keys using ordinal .NET array sorting. Keep ordered desired-state comparison distinct from unordered post-write comparison. Every - component of a private-key ACE key is uppercase hexadecimal or a fixed - literal, so two keys cannot differ only in case and the ordinal sort is - defensive rather than a behavior fix. + component of a private-key ACE key is a SID string, a decimal or uppercase + hexadecimal number, a lowercase GUID, or a fixed literal, each formatted + deterministically, so two keys cannot differ only in case and the ordinal + sort is defensive rather than a behavior fix. - Replace pipeline-based metric-parameter counting with Boolean checks and replace per-target parameter-copy loops with shallow hashtable clones in NTFS, registry, service, process, SMB, AD, and Task Scheduler adapters. -## Measurements +## Historical measurements, 2026-09-10 + +These figures are kept for comparison; the current ones are in the +[addendum](#addendum-re-measurement-on-2026-10-06). Measured on Windows build 26200 with eight logical processors, PowerShell 7.6.5 and Windows PowerShell 5.1.26100.9168. The baseline artifact matches the @@ -65,7 +77,7 @@ Rights-cache results represent repeated-mask workloads, not cold or mostly unique masks. Pure-helper timings do not measure LDAP latency, CNG persistence, or every public command. There are no timing assertions in the unit suite. -## Validation +## Historical validation, 2026-09-10 - Added nine regression and characterization cases in existing test files. Both NTFS filter tests failed before the change because excluded accounts @@ -94,7 +106,7 @@ or every public command. There are no timing assertions in the unit suite. and built manifests validate. The local version remains the existing GitVersion fallback `0.0.1`; this is not a published release. -## Artifact identity +## Historical artifact identity, 2026-09-10 | Artifact | SHA-256 | | --- | --- | @@ -125,3 +137,123 @@ Blocker or Major finding; that is not independent review, which stays recommended for the shared batching and ACE-comparison changes. A later repository-wide review requested changes for findings in other, unchanged files, so acceptance is gated on those as well as on the runs above. + +## Addendum: re-measurement on 2026-10-06 + +The 2026-09-10 figures compare against `5991673`. On 2026-10-06 the change was +ported onto the integration branch `ai/post-release-fixes`, which by then also +carried the FIND-001 private-key correction and the lab-runner, Task +Scheduler, SMB share, and Active Directory corrections. Both benchmarks ran +again against that branch: the baseline is the branch just before the ported +`perf:` commit, and the candidate is the branch just after it. + +### Method + +- Both artifacts were built with `.\build.ps1 -Tasks build` in PowerShell 7 + and copied out of `output` before the next build, so every run names one + fixed module. +- Every measurement ran in its own fresh process, one at a time, with no + concurrent tests or builds, on Windows build 26300 with eight logical + processors, PowerShell 7.6.6, and Windows PowerShell 5.1.26100.9444. +- The parameters match the 2026-09-10 runs: 256 ACEs, 50 operations, and seven + measured runs for the descriptor benchmark; 256 files, seven runs per mode, + and a parallel throttle of eight for the NTFS benchmark; and 200 operations + with nine runs for the isolated Core CNG comparison. +- Each edition ran the descriptor and NTFS benchmarks twice per artifact in + the order baseline, candidate, candidate, baseline, so drift during the + session cannot favor one artifact. The isolated CNG comparison started with + the candidate, as on 2026-09-10, and then reversed the order. +- Each figure is the median of every measured run of one artifact: 14 runs + per row, and 18 for the isolated row. + +### Identity and output checks + +These checks passed before any time was compared: + +- All 20 result files record the module hash of the artifact they were meant + to measure. +- Every paired output checksum matches in every run of both artifacts and + editions: rights display 1,860, removed ACEs 3,200, CNG equivalence 50 (200 + in the isolated runs), and dispatch 50. +- Every NTFS run returned all 256 owners; the benchmark fails a run with any + other count. + +| Artifact | Commit | `WindowsAccessControl.psm1` SHA-256 | +| --- | --- | --- | +| Baseline | `8b67058` | `28DCBE2F9C899547A1AD04FAA41FDFAB45A817595A95E412979CF95AAA04CFB4` | +| Candidate | `fbced95` | `8DCEC9CBFCF71359D253E536A7E30AF90DCE1B7D154E589DF571F58AA6B3F371` | + +A later rebuild of the candidate reproduced the same hash. + +### Results + +| Workload | Edition | Before (ms) | After (ms) | Time reduction | +| --- | --- | ---: | ---: | ---: | +| Repeated rights display | Core | 4.10 | 1.27 | 69.0% | +| Repeated rights display | Desktop | 4.96 | 3.31 | 33.1% | +| Removed-ACE calculation | Core | 883.21 | 297.27 | 66.3% | +| Removed-ACE calculation | Desktop | 866.64 | 47.59 | 94.5% | +| Single-target dispatch | Core | 9.56 | 7.83 | 18.1% | +| Single-target dispatch | Desktop | 25.25 | 14.68 | 41.9% | +| CNG equivalence, isolated | Core | 1001.34 | 855.25 | 14.6% | +| CNG equivalence | Desktop | 1469.56 | 1410.93 | 4.0% | + +Every row keeps the direction and roughly the size it had on 2026-09-10. The +Desktop CNG gain is the smallest: 4.0% against 8.4%. + +The mixed-workload Core CNG row, which the 2026-09-10 table also replaced with +the isolated comparison, went the other way: 295.42 ms before and 325.51 ms +after, 10.2% slower. In every process its first two iterations took 510 to +785 ms. From the fourth iteration on, the baseline took 258 to 355 ms and the +candidate 216 to 337 ms, medians of 265 and 228 ms; the candidate's third +iterations, 477 and 376 ms, were still warming up. A seven-run median +therefore lands inside the warm-up. The isolated comparison runs four times as +many operations per iteration and moved by less than 1% between rounds: +1,005.37 and 998.59 ms before, 855.39 and 855.11 ms after. It remains the Core +CNG figure. + +The NTFS owner-read benchmark was again unchanged within noise. Mean +sequential reads took 568.10 ms before and 571.99 ms after on Core, and +597.15 and 599.81 ms on Desktop. Mean parallel reads took 613.41 and 597.60 ms +on Core, and 704.16 and 714.31 ms on Desktop. + +The ported change keeps the 2026-09-10 gains on the current baseline, and the +release notes in `CHANGELOG.md` now cite these figures. The 20 raw result +files and the comparison output stay in `%TEMP%\wac03-perf` on the development +machine; they are not part of the repository. + +### Validation and review + +- The nine ported tests pass in both editions, and the whole private-key + mutation test file passes 54 of 54 in both, so the FIND-001 returned-bytes + behavior and the ported multiset comparison hold together. +- Two new tests pin the batch layer: the dispatcher rejects `CommandName` or + `ObjectFamily` without the other before any target runs, and an NTFS adapter + worker copies the parameters every runspace shares before it writes its + target. Each failed in both editions with its guard removed from source and + passed with it restored. +- Full Core `test` on the final commit: 1,917 passed, zero failed, two + environment skips. Asserted coverage is 83.11% (6,910 of 8,314 executable + commands) against the unchanged 80% gate. +- Full Desktop `test` on the same module: 1,870 passed, two failed, two + environment skips. Only the two DSC-engine calls failed, because local WinRM + is stopped, so that build stops before its coverage assertion. Coverage + computed from its JaCoCo document with the repository's own scoping + functions is 80.95% (6,730 of 8,314). +- No domain-lab-only source file ran locally: 0 of 192 commands over 13 files. +- PSScriptAnalyzer 1.25.0 reports no finding in any of the 22 changed + PowerShell files. +- Sampler `pack`: 22 tasks, zero errors or warnings. The package SHA-256 is + `858945AB6DFD168D1AEAE9A965299AF6F93153EFDEE802523E1A33A46529F007`, and its + module is the candidate measured above. +- One independent security and quality review of the finished diff approved it + with one Minor and three Nits and no Blocker or Major. It found no input for + which the rewritten comparisons or parameter copies diverge from the code + they replace. The Minor asked for the adapter test above; the Nits corrected + this report's private-key key description and cache scope, and which + release-note figures use 256 entries. + +This addendum supersedes the review and gate statements in the 2026-09-10 +sections. Rollback is unchanged: revert the `perf:` commit and rebuild. Live +domain-lab and installed-package acceptance of the change remain open; they +belong to the next lab acceptance run. From 830bc8cbc395720f6494960fa07b290a7ae7e3e6 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 18:25:53 +0000 Subject: [PATCH 12/20] docs: record the passing Windows PowerShell 5.1 gate WinRM now runs on the development machine, so the two DSC-engine tests that need it pass, and the Windows PowerShell 5.1 gate asserts its coverage here for the first time: 1,872 passed, zero failed, two environment skips, and 80.95 percent of 8,314 commands. This session changed no host setting; the service was already running. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 9 +++++---- .memory-bank/progress.md | 5 +++-- docs/performance-refactor-2026-09-10.md | 4 ++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 81b2b70..3bf2ca7 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -23,10 +23,11 @@ per-target parameter copy; each test failed with its guard removed. The `perf:` commit: hashes and checksums match, and the gains hold. One independent review approved with one Minor and three Nits, all addressed. The ported `5f06c03` body was reworded because "Major" in it would have made the -next release 1.0.0. The Desktop gate still fails only the two WinRM-bound -DSC-engine tests. No live lab run took place; handoff 07 owns the first live -and installed-package acceptance of the refactor. Next is handoff 04. No git -remote was mutated. +next release 1.0.0. With WinRM running on the development machine since a +later check on 2026-10-06, the Desktop gate passes too: 1,872 tests at 80.95 +percent asserted coverage, a thin margin. No live lab run took place; handoff +07 owns the first live and installed-package acceptance of the refactor. Next +is handoff 04. No git remote was mutated. Handoffs 01 and 02 made the lab runner refuse to delete what it did not create and fixed the Task Scheduler, SMB, and AD review defects. The diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 9ac6f0b..f89ab24 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -31,8 +31,9 @@ uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit remain. matching hashes and checksums: Core rights display 69 percent faster, removed ACEs 66, dispatch 18, isolated CNG 15; Desktop removed ACEs 95. The Core gate passes 1,917 tests with two environmental skips at 83.11 - percent asserted coverage; Desktop fails only the two WinRM-bound - DSC-engine tests, with 80.95 percent scoped coverage computed. One + percent asserted coverage. Once WinRM ran on the development machine, + the Desktop gate passed 1,872 at 80.95 percent asserted coverage; before + that, only its two WinRM-bound DSC-engine tests failed. One independent review approved; its Minor and three Nits were addressed. - 2026-10-06: Handoff 02 fixed three review defects test-first on diff --git a/docs/performance-refactor-2026-09-10.md b/docs/performance-refactor-2026-09-10.md index c1ad30c..420ad44 100644 --- a/docs/performance-refactor-2026-09-10.md +++ b/docs/performance-refactor-2026-09-10.md @@ -240,6 +240,10 @@ machine; they are not part of the repository. is stopped, so that build stops before its coverage assertion. Coverage computed from its JaCoCo document with the repository's own scoping functions is 80.95% (6,730 of 8,314). +- With WinRM later running on the development machine, the Desktop `test` of + the same module passed: 1,872 passed, zero failed, two environment skips, + and asserted coverage 80.95% (6,730 of 8,314). Both DSC-engine calls pass; + they cover no command the rest of the suite leaves uncovered. - No domain-lab-only source file ran locally: 0 of 192 commands over 13 files. - PSScriptAnalyzer 1.25.0 reports no finding in any of the 22 changed PowerShell files. From 4461b905c180af7b7c78e7d70e2ba13d5e5faebd Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 21:26:13 +0000 Subject: [PATCH 13/20] docs: move the decision log out of system patterns and name the descriptor benchmark in the specs The user reviewed every recorded agent decision of handoffs 01 to 03. The 79-row Memory Bank decision log moves unchanged to topics/decision-log.md, and the index routes to it, which brings systemPatterns.md within its line budget. Specs 0005 and 0006 now name the descriptor-processing benchmark, its per-run checksums and module hash, and the per-instance rights cache. The tech context records that local WinRM runs, and the two lab-runner follow-ups become post-release items. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 13 ++- .memory-bank/index.md | 4 +- .memory-bank/progress.md | 7 ++ .memory-bank/systemPatterns.md | 83 +--------------- .memory-bank/techContext.md | 7 +- .memory-bank/topics/decision-log.md | 97 +++++++++++++++++++ specs/0005-verification-and-traceability.md | 4 + .../0006-windows-access-control-expansion.md | 14 ++- 8 files changed, 140 insertions(+), 89 deletions(-) create mode 100644 .memory-bank/topics/decision-log.md diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 3bf2ca7..f1ad93a 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -30,7 +30,12 @@ percent asserted coverage, a thin margin. No live lab run took place; handoff is handoff 04. No git remote was mutated. Handoffs 01 and 02 made the lab runner refuse to delete what it did not -create and fixed the Task Scheduler, SMB, and AD review defects. The +create and fixed the Task Scheduler, SMB, and AD review defects. On +2026-10-06 the user reviewed all 14 recorded agent decisions of handoffs 01 +to 03: twelve are accepted as recorded, and the other two were overtaken by +doing the work (the decision log moved out of `systemPatterns.md`, and specs +0005 and 0006 name the descriptor benchmark). WinRM stays enabled here. +Later handoffs ask their own questions with fresh evidence. The 2026-10-06 triage below remains the record of the open work. `main` is `40e364a`, in sync with `origin/main`; `ai/performance-refactor` stays at `5f06c03` until handoff 08 deletes it with approval. @@ -79,11 +84,13 @@ and cleanup. Only 08 touches the remote, with approval per action. refuses unmarked directories. The existing `C:\WacRepo` and the `0.0.1` installation on `F1ADC1` predate the marker, so 07 must decide what to do with them before its first run. - - Follow-up (review of handoff 01, not scheduled): the SMB, certificate + - Post-release follow-ups (user, 2026-10-06): the SMB, certificate private-key, Task Scheduler, and foreign-principal suites recursively delete `C:\WindowsAccessControlLab\ModuleUnderTest` on the member server without an ownership check; it is a fixed child of the harness's marked - member root. + member root. And `-SkipPayloadDeployment -ModuleSource Installed` with no + payload root creates an unmarked root that a later full deployment + refuses; it fails safe and can be removed by hand. - Major, fixed by handoff 02: `Test-WindowsTaskSchedulerSystemAce` refused a write that restores SYSTEM to a DACL without a SYSTEM ACE. A missing or null current DACL is still refused. diff --git a/.memory-bank/index.md b/.memory-bank/index.md index 2eb4d8e..c2766f8 100644 --- a/.memory-bank/index.md +++ b/.memory-bank/index.md @@ -3,7 +3,7 @@ schema-version: 1 loading-mode: routed status: accepted owner: shared -last-verified: 2026-07-25 +last-verified: 2026-10-06 source: repository evidence --- @@ -31,7 +31,7 @@ read `activeContext.md` before editing. | `scope` | Purpose, scope, requirements, Acceptance criteria | `projectbrief.md` | | `product` | Users, problem, workflow, experience goal | `productContext.md` | | `implementation` | Code, configuration, build, test, dependency, deployment | `techContext.md`, `activeContext.md` | -| `architecture` | Design, pattern, decision, migration, integration | `systemPatterns.md`, relevant `specs/decisions/*.md` | +| `architecture` | Design, pattern, decision, migration, integration | `systemPatterns.md`, relevant `specs/decisions/*.md`; `topics/decision-log.md` when a numbered Memory Bank decision matters | | `status` | Progress, recent change, open work | `progress.md`, `activeContext.md` | | `language` | Canonical terms in authored artifacts | `glossary.md` | | `interaction-history` | Session analysis, prompt trends, Memory Bank evals | `promptHistory.md`, `progress.md` | diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index f89ab24..206d812 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -21,6 +21,13 @@ uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit remain. ## Recent milestones +- 2026-10-06: The user reviewed all 14 recorded agent decisions of handoffs + 01 to 03 and accepted twelve as recorded. The other two were overtaken: + the decision log moved unchanged to `topics/decision-log.md`, bringing + `systemPatterns.md` within budget, and specs 0005 and 0006 now name the + descriptor benchmark and the per-instance rights cache. WinRM stays + enabled; the two lab-runner follow-ups are post-release items. + - 2026-10-06: Handoff 03 finished the orphaned performance refactor on `ai/post-release-fixes`, as the user chose. Its code, tests, and evidence were ported without the old Memory Bank hunks, and the code diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index 804832e..4cbbd0c 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -53,84 +53,5 @@ differ only in case. Measure real PowerShell code in fresh processes: ordinal output checksums before medians, prefer an isolated row over a warm-up-bound mixed one, and do not read helper gains as faster native I/O. -| Decision | Summary | -| --- | --- | -| 1 | Use the canonical Memory Bank base | -| 2 | Use only in-box runtime APIs | -| 3 | Keep destructive ACL semantics explicit | -| 4 | Preserve descriptor sections | -| 5 | Use versioned, validated JSON backups | -| 6 | Keep destructive modes separately testable | -| 7 | Batch only after identity prevalidation | -| 8 | Make privilege gaps executable and explicit | -| 9 | Keep specifications authoritative and help beside code | -| 10 | Persist ACL protection with the selected ACL | -| 11 | Scope required privileges to operations | -| 12 | Rename to WindowsAccessControl | -| 13 | Share one binary descriptor engine | -| 14 | Keep the release local and testable | -| 15 | Use object-specific public and DSC surfaces | -| 16 | Bound parallel target execution | -| 17 | Normalize and verify registry targets before native calls | -| 18 | Keep ACL masks and objects explicit across editions | -| 19 | Skip semantically identical named-descriptor writes | -| 20 | Address the SCM through a scoped native handle | -| 21 | Keep service and SCM rights distinct | -| 22 | Pin one live process handle per PID operation | -| 23 | Retry process access without broadening token authority | -| 24 | Use one integrity-protected descriptor envelope | -| 25 | Validate and prepare the complete restore before writing | -| 26 | Make absence and backup replacement explicit | -| 27 | Keep aggregate backup writes singular | -| 28 | Make exact DSC identity object-specific | -| 29 | Normalize only system-derived ACL flags in DSC | -| 30 | Give rule-presence DSC exact ACE identity | -| 31 | Normalize NTFS rule masks through .NET | -| 32 | Scope explicit credentials to local impersonation | -| 33 | Use breakpoint coverage across PowerShell editions | -| 34 | Align native ACE metadata with managed rule enumeration | -| 35 | Gate enterprise expansion on a disposable domain lab | -| 36 | Build once and test the same artifact in GitHub Actions | -| 37 | Version symbolic lab evidence, not infrastructure identity | -| 38 | Mark, compensate, and explicitly delete lab keys | -| 39 | Address SMB shares through local provider authority | -| 40 | Bind Active Directory operations to strict LDAP authority | -| 41 | Require schema version 2 for enterprise targets | -| 42 | Verify Task Scheduler DACLs semantically | -| 43 | Keep caller callbacks in one runspace | -| 44 | Make unattended suite success explicit | -| 45 | Separate CNG inspection from mutation | -| 46 | Fail closed on an unloaded descriptor section | -| 47 | Keep a descriptor projection consistent with its native object | -| 48 | Never let a post-write step throw | -| 49 | Default to the typed parameter set for descriptor input | -| 50 | Name rights after the operation, per object type | -| 51 | Evaluate the whole service token before a Task Scheduler write | -| 52 | Reject ACE types the Task Scheduler store re-revisions | -| 53 | Make inheritance scope part of ACE identity when adding | -| 54 | Re-read and compare before a staged descriptor write | -| 55 | Emit batched worker output outside the dispatcher's catch | -| 56 | Degrade registry provenance instead of losing the rules | -| 57 | Enrich directory rules over the bound connection | -| 58 | Discover and pin one domain controller | -| 59 | Scope a directory rule mutation by both object GUIDs | -| 60 | Expand a stored generic bit before subtracting rights | -| 61 | Refuse a directory DACL that leaves nobody able to manage it | -| 62 | Compare against the staging read before a directory write | -| 63 | Disclose a deny removal before the operator commits | -| 64 | Pin the backup record version to the object family | -| 65 | Qualify an SMB canonical target with its owning computer | -| 66 | Match a restored directory object by GUID, not by server | -| 67 | Keep directory credentials out of desired state | -| 68 | Defer directory effective access on measured evidence | -| 69 | Qualify a Task Scheduler target with its owning computer | -| 70 | Compare a Task Scheduler desired state semantically | -| 71 | Gate a suite on the Pester run result, not its failure count | -| 72 | Key the private-key binding gate on the key, not the certificate | -| 73 | Refuse a new deny ACE and any non-plain ACE on a private key | -| 74 | Compare a private-key ACE by type and payload, not by qualifier | -| 75 | Tag the GitHub release before the Gallery publish | -| 76 | Complete argument values from a class in the module | -| 77 | Build an NTFS rule through a mask-range helper | -| 78 | Refuse a path that does not name one canonical target | -| 79 | Address the object the caller named, per object family | +The numbered Memory Bank decisions 1 to 79 are listed in the +[decision log](topics/decision-log.md). diff --git a/.memory-bank/techContext.md b/.memory-bank/techContext.md index c905f7e..7889933 100644 --- a/.memory-bank/techContext.md +++ b/.memory-bank/techContext.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-07-29 +last-verified: 2026-10-06 owner: active-agent source: repository evidence --- @@ -25,7 +25,10 @@ source: repository evidence - Windows development host. - Build orchestration runs in PowerShell 7. -- PowerShell 7.6.1 and Windows PowerShell 5.1 are available. +- PowerShell 7.6.6 and Windows PowerShell 5.1.26100 are available. +- Local WinRM runs with automatic start and an HTTP listener since + 2026-10-06; its inbound firewall rules stay disabled on a Public network. + The two Desktop DSC-engine tests need it. - Git default branch is `main`; the CI migration branch is `ai/github-actions-build`. diff --git a/.memory-bank/topics/decision-log.md b/.memory-bank/topics/decision-log.md new file mode 100644 index 0000000..661359a --- /dev/null +++ b/.memory-bank/topics/decision-log.md @@ -0,0 +1,97 @@ +--- +status: current +last-verified: 2026-10-06 +owner: software-engineer +source: repository evidence +--- + +# Decision log + +Titles of the numbered Memory Bank decisions, moved unchanged from +`systemPatterns.md` on 2026-10-06 to keep that file within its line budget. +They are separate from the architecture decision records in +[`specs/decisions`](../../specs/decisions/README.md), which control +architecture. Earlier rationale: +`git show 124a356:.memory-bank/systemPatterns.md`. + +| Decision | Summary | +| --- | --- | +| 1 | Use the canonical Memory Bank base | +| 2 | Use only in-box runtime APIs | +| 3 | Keep destructive ACL semantics explicit | +| 4 | Preserve descriptor sections | +| 5 | Use versioned, validated JSON backups | +| 6 | Keep destructive modes separately testable | +| 7 | Batch only after identity prevalidation | +| 8 | Make privilege gaps executable and explicit | +| 9 | Keep specifications authoritative and help beside code | +| 10 | Persist ACL protection with the selected ACL | +| 11 | Scope required privileges to operations | +| 12 | Rename to WindowsAccessControl | +| 13 | Share one binary descriptor engine | +| 14 | Keep the release local and testable | +| 15 | Use object-specific public and DSC surfaces | +| 16 | Bound parallel target execution | +| 17 | Normalize and verify registry targets before native calls | +| 18 | Keep ACL masks and objects explicit across editions | +| 19 | Skip semantically identical named-descriptor writes | +| 20 | Address the SCM through a scoped native handle | +| 21 | Keep service and SCM rights distinct | +| 22 | Pin one live process handle per PID operation | +| 23 | Retry process access without broadening token authority | +| 24 | Use one integrity-protected descriptor envelope | +| 25 | Validate and prepare the complete restore before writing | +| 26 | Make absence and backup replacement explicit | +| 27 | Keep aggregate backup writes singular | +| 28 | Make exact DSC identity object-specific | +| 29 | Normalize only system-derived ACL flags in DSC | +| 30 | Give rule-presence DSC exact ACE identity | +| 31 | Normalize NTFS rule masks through .NET | +| 32 | Scope explicit credentials to local impersonation | +| 33 | Use breakpoint coverage across PowerShell editions | +| 34 | Align native ACE metadata with managed rule enumeration | +| 35 | Gate enterprise expansion on a disposable domain lab | +| 36 | Build once and test the same artifact in GitHub Actions | +| 37 | Version symbolic lab evidence, not infrastructure identity | +| 38 | Mark, compensate, and explicitly delete lab keys | +| 39 | Address SMB shares through local provider authority | +| 40 | Bind Active Directory operations to strict LDAP authority | +| 41 | Require schema version 2 for enterprise targets | +| 42 | Verify Task Scheduler DACLs semantically | +| 43 | Keep caller callbacks in one runspace | +| 44 | Make unattended suite success explicit | +| 45 | Separate CNG inspection from mutation | +| 46 | Fail closed on an unloaded descriptor section | +| 47 | Keep a descriptor projection consistent with its native object | +| 48 | Never let a post-write step throw | +| 49 | Default to the typed parameter set for descriptor input | +| 50 | Name rights after the operation, per object type | +| 51 | Evaluate the whole service token before a Task Scheduler write | +| 52 | Reject ACE types the Task Scheduler store re-revisions | +| 53 | Make inheritance scope part of ACE identity when adding | +| 54 | Re-read and compare before a staged descriptor write | +| 55 | Emit batched worker output outside the dispatcher's catch | +| 56 | Degrade registry provenance instead of losing the rules | +| 57 | Enrich directory rules over the bound connection | +| 58 | Discover and pin one domain controller | +| 59 | Scope a directory rule mutation by both object GUIDs | +| 60 | Expand a stored generic bit before subtracting rights | +| 61 | Refuse a directory DACL that leaves nobody able to manage it | +| 62 | Compare against the staging read before a directory write | +| 63 | Disclose a deny removal before the operator commits | +| 64 | Pin the backup record version to the object family | +| 65 | Qualify an SMB canonical target with its owning computer | +| 66 | Match a restored directory object by GUID, not by server | +| 67 | Keep directory credentials out of desired state | +| 68 | Defer directory effective access on measured evidence | +| 69 | Qualify a Task Scheduler target with its owning computer | +| 70 | Compare a Task Scheduler desired state semantically | +| 71 | Gate a suite on the Pester run result, not its failure count | +| 72 | Key the private-key binding gate on the key, not the certificate | +| 73 | Refuse a new deny ACE and any non-plain ACE on a private key | +| 74 | Compare a private-key ACE by type and payload, not by qualifier | +| 75 | Tag the GitHub release before the Gallery publish | +| 76 | Complete argument values from a class in the module | +| 77 | Build an NTFS rule through a mask-range helper | +| 78 | Refuse a path that does not name one canonical target | +| 79 | Address the object the caller named, per object family | diff --git a/specs/0005-verification-and-traceability.md b/specs/0005-verification-and-traceability.md index e5aa346..8e2bc33 100644 --- a/specs/0005-verification-and-traceability.md +++ b/specs/0005-verification-and-traceability.md @@ -323,6 +323,10 @@ zero failures or skips and leaves no staged files or local users. sequential and bounded-parallel NTFS owner reads over disposable targets. It emits elapsed time and throughput plus optional JSON evidence without a flaky hard timing assertion. +`tests/Performance/Measure-DescriptorProcessingPerformance.ps1` measures the +shared descriptor helpers with a per-run output checksum. Both benchmarks +record the SHA-256 of the module they measured, so a before-and-after +comparison checks hashes and checksums before it compares times. ### Domain-lab payload ownership diff --git a/specs/0006-windows-access-control-expansion.md b/specs/0006-windows-access-control-expansion.md index f4385bd..d246f39 100644 --- a/specs/0006-windows-access-control-expansion.md +++ b/specs/0006-windows-access-control-expansion.md @@ -274,18 +274,30 @@ default, with `ThrottleLimit` defaulting to the smaller of eight and the logical processor count. `ThrottleLimit 1` provides deterministic sequential execution. Identity translation and target normalization are cached within one invocation. +Immutable rights-enum metadata and at most 128 rendered masks per rights enum +are cached for the life of a module instance, and every parallel worker +runspace imports its own instance. Descriptors, identity lookups, tokens, and +handles are never cached across invocations. Each target descriptor is loaded once and persisted once per selected mutation. Workers stream completed objects and structured errors without retaining native buffers. Representative batch benchmarks must show no material regression from the current NTFS implementation; correctness and section preservation remain hard gates. -`tests/Performance/Measure-NtfsBatchPerformance.ps1` is the repeatable +`tests/Performance/Measure-NtfsBatchPerformance.ps1` is the repeatable batch benchmark harness. It alternates sequential and bounded-parallel run order, uses disposable local files, verifies the complete result count, and records elapsed milliseconds plus targets per second. Timing is retained as evidence, not enforced as a flaky test threshold. +`tests/Performance/Measure-DescriptorProcessingPerformance.ps1` measures the +shared descriptor helpers: rights rendering, removed-entry detection, +private-key DACL equivalence, and single-target dispatch. It warms each +workload, alternates workload order, and records every elapsed time with a +per-run output checksum. Both harnesses accept `-ModuleManifestPath` and record +the module SHA-256, so a comparison checks artifact identity and output +equality before it compares times. + ## Observability Commands provide structured error identifiers, verbose decision traces, and From d683f2a5c4a08f61a5f9197c4ffef04300518d7b Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 22:30:36 +0000 Subject: [PATCH 14/20] docs: document migration from NTFSSecurity NTFSSecurity is deprecated in favor of this module, and issue #4 asks whether this module replaces it. The documentation index pointed NTFSSecurity users at the NTFSPermission rename map, which never mentions NTFSSecurity, and the wiki Home page promised a map that did not exist. The guide maps every command of the NTFSSecurity 4.2.6 and 5.0.0-rc4 manifests, confirmed against the built module, and states the parameter and output differences and the decision behind each missing capability. Every published example ran verbatim in PowerShell 7.6.6 and Windows PowerShell 5.1. Refs #4 Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 + README.md | 4 + docs/README.md | 5 +- docs/migration-from-ntfssecurity.md | 432 ++++++++++++++++++++++++++++ docs/usage-guide.md | 1 + docs/usage/command-reference.md | 1 + docs/usage/getting-started.md | 1 + source/WikiSource/Home.md | 5 +- 8 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 docs/migration-from-ntfssecurity.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ae972..445e7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 filter in the usage guide, which is how an operator separates the delegation they configured from the entries a schema class applies to every new object +- Add a migration guide for `NTFSSecurity` users, whose module is deprecated in + favor of this one. It maps every command that the NTFSSecurity 4.2.6 and + 5.0.0 manifests export to its replacement or to the decision that leaves it + out, and it calls out the differences that change what a migrated script + does: `Remove-NTFSAccessRule` matches exactly unless `-RemovalMode Rights` + is given, the remove, clear, and owner commands prompt for confirmation, and + a path longer than 260 characters needs PowerShell 7. The documentation + index had pointed NTFSSecurity users at the `NTFSPermission` rename map, + which never mentions NTFSSecurity - Document the twenty DSC resources in the wiki, and ship their conceptual help inside the module. The resources were the one part of the public surface a reader could not look up anywhere: the wiki carried a page per command and diff --git a/README.md b/README.md index 6abf495..51d17ca 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ to manipulate .NET access-control objects directly. The module has no third-party runtime dependency. It supports Windows PowerShell 5.1 and PowerShell 7 on Windows. +`WindowsAccessControl` is the successor of `NTFSSecurity`. The +[NTFSSecurity migration guide](docs/migration-from-ntfssecurity.md) maps every +NTFSSecurity command to its replacement. + The unpublished package was renamed from `NTFSPermission`. See the [migration map](docs/migration-from-ntfspermission.md) for package, command, and output type changes. diff --git a/docs/README.md b/docs/README.md index 688aa4d..4cc7592 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ what the module does, and comment-based help defines every parameter. | Learn the module from scratch | [Usage guide](usage-guide.md) | | Install and run a first command | [Getting started](usage/getting-started.md) | | Find the command for a task | [Command reference](usage/command-reference.md) | -| Move an existing script over | [Migration from NTFSPermission](migration-from-ntfspermission.md) | +| Move a script over from NTFSSecurity | [Migration from NTFSSecurity](migration-from-ntfssecurity.md) | | Fix a failing operation | [Troubleshooting](usage/troubleshooting.md) | ## Documents in this folder @@ -20,7 +20,8 @@ what the module does, and comment-based help defines every parameter. | Document | Covers | Audience | | --- | --- | --- | | [usage-guide.md](usage-guide.md) | Entry point and map of every task page | Everyone | -| [migration-from-ntfspermission.md](migration-from-ntfspermission.md) | Package, command, and output type changes after the rename | Existing `NTFSPermission` and `NTFSSecurity` users | +| [migration-from-ntfssecurity.md](migration-from-ntfssecurity.md) | Command map, parameter and output differences, and NTFSSecurity features without an equivalent | Existing `NTFSSecurity` users | +| [migration-from-ntfspermission.md](migration-from-ntfspermission.md) | Package, command, and output type changes after the rename | Builds of the unpublished `NTFSPermission` package | | [research.md](research.md) | Source review, platform API semantics, and the comparison that informed the specifications | Contributors and reviewers | | [domain-lab-inventory.md](domain-lab-inventory.md) | Machines, forests, and roles of the disposable acceptance lab | Contributors running the lab suites | | [lab-reacceptance-2026-09-07.md](lab-reacceptance-2026-09-07.md) | Corrected-candidate lab, package, DSC, local coverage, and cleanup evidence | Maintainers and reviewers | diff --git a/docs/migration-from-ntfssecurity.md b/docs/migration-from-ntfssecurity.md new file mode 100644 index 0000000..f371ea5 --- /dev/null +++ b/docs/migration-from-ntfssecurity.md @@ -0,0 +1,432 @@ +# Migrate from NTFSSecurity + +This guide is for administrators and script authors who manage NTFS +permissions with the [NTFSSecurity](https://github.com/raandree/NTFSSecurity) +module. It maps every NTFSSecurity command to its `WindowsAccessControl` +replacement, explains the parameter and output differences that change what a +script does, and names the NTFSSecurity features that have no equivalent here, +with the decision behind each. + +## Status of NTFSSecurity + +`WindowsAccessControl` is the successor of `NTFSSecurity`. NTFSSecurity is +deprecated and will be archived: the PowerShell Gallery description of its +5.0.0 prerelease reads "NTFSSecurity will be archived; its successor is +WindowsAccessControl", and its change log deprecates the module as a whole. +Plan new automation on `WindowsAccessControl`, and move existing scripts over. + +The successor is not a drop-in replacement. It keeps what worked in +NTFSSecurity, such as pipeline input, account filters, orphaned-SID reports, +and in-memory descriptor editing, but every command has a new name, some +defaults differ, and the general file system commands are gone. + +## Before you start + +- The command map applies to `WindowsAccessControl` 0.2.0 and later. Install + the module as [Getting started](usage/getting-started.md) describes. +- No command name exists in both modules. Move one script at a time, and run + each changed command with `WhatIf` before you remove NTFSSecurity. +- `WindowsAccessControl` runs on Windows PowerShell 5.1 and PowerShell 7. A + path longer than 260 characters needs PowerShell 7; see + [Long paths](#long-paths). +- The examples use placeholder accounts and paths such as `CONTOSO\Analysts` + and `C:\Data`. Replace them with targets from your environment. + +## Command map + +The tables cover every command that the NTFSSecurity 4.2.6 and 5.0.0-rc4 +manifests on the PowerShell Gallery export, checked on 2026-10-06. The notes +name the changes that need attention, and +[Parameter differences](#parameter-differences) explains them. + +### Access rules + +| NTFSSecurity | WindowsAccessControl | Notes | +| --- | --- | --- | +| `Get-NTFSAccess` | `Get-NTFSAccessRule` | `Account` accepts several accounts | +| `Add-NTFSAccess` | `Add-NTFSAccessRule` | `AccessType` is now `AccessControlType` | +| `Remove-NTFSAccess` | `Remove-NTFSAccessRule` | The default differs; see [Removal defaults differ](#removal-defaults-differ) | +| `Clear-NTFSAccess` | `Clear-NTFSAccessRule` | `DisableInheritance` is a separate command | +| `Get-NTFSOrphanedAccess` | `Get-NTFSAccessRule -Orphaned` | | +| `Get-NTFSEffectiveAccess` | `Get-NTFSItemEffectiveAccess` | Local paths only; no `ServerName` | +| `Get-NTFSSimpleAccess` | None | See [Features without an equivalent](#features-without-an-equivalent) | +| `Show-NTFSSimpleAccess` | None | Listed in the 4.2.6 manifest only; the command no longer existed | + +### Audit rules + +The audit commands that read or write a SACL need `SeSecurityPrivilege`. They +enable it for the duration of the operation when the process token holds it. +See [Auditing and SACLs](usage/auditing.md). + +| NTFSSecurity | WindowsAccessControl | Notes | +| --- | --- | --- | +| `Get-NTFSAudit` | `Get-NTFSAuditRule` | `Account` accepts several accounts | +| `Add-NTFSAudit` | `Add-NTFSAuditRule` | | +| `Remove-NTFSAudit` | `Remove-NTFSAuditRule` | The default differs; see [Removal defaults differ](#removal-defaults-differ) | +| `Clear-NTFSAudit` | `Clear-NTFSAuditRule` | `DisableInheritance` is a separate command | +| `Get-NTFSOrphanedAudit` | `Get-NTFSAuditRule -Orphaned` | | + +### Inheritance + +| NTFSSecurity | WindowsAccessControl | Notes | +| --- | --- | --- | +| `Get-NTFSInheritance` | `Get-NTFSItemInheritance -Section All` | The default section is `Access` | +| `Enable-NTFSAccessInheritance` | `Enable-NTFSItemInheritance` | `RemoveExplicitAccessRules` is now `RemoveExplicitRules` | +| `Disable-NTFSAccessInheritance` | `Disable-NTFSItemInheritance` | `RemoveInheritedAccessRules` is now `PreserveInherited:$false` | +| `Enable-NTFSAuditInheritance` | `Enable-NTFSItemInheritance -Section Audit` | `RemoveExplicitAuditRules` is now `RemoveExplicitRules` | +| `Disable-NTFSAuditInheritance` | `Disable-NTFSItemInheritance -Section Audit` | `RemoveInheritedAuditRules` is now `PreserveInherited:$false` | +| `Set-NTFSInheritance` | `Enable-NTFSItemInheritance`, `Disable-NTFSItemInheritance` | One command for each direction | + +Before NTFSSecurity 5.0.0, `Set-NTFSInheritance` removed the inherited entries +when it disabled access inheritance. `Disable-NTFSItemInheritance` keeps them +as explicit rules unless you pass `-PreserveInherited:$false`. + +### Owner and security descriptor + +| NTFSSecurity | WindowsAccessControl | Notes | +| --- | --- | --- | +| `Get-NTFSOwner` | `Get-NTFSItemOwner` | | +| `Set-NTFSOwner` | `Set-NTFSItemOwner` | Prompts for confirmation by default | +| `Get-NTFSSecurityDescriptor` | `Get-NTFSItemSecurityDescriptor` | `Sections` selects what is read and later written | +| `Set-NTFSSecurityDescriptor` | `Set-NTFSItemSecurityDescriptor` | Takes the descriptor from the pipeline | + +### Privileges + +| NTFSSecurity | WindowsAccessControl | Notes | +| --- | --- | --- | +| `Get-Privileges` | `Get-WindowsPrivilege` | | +| `Enable-Privileges` | `Enable-WindowsPrivilege` | Names each privilege; rarely needed, because commands enable what they use | +| `Disable-Privileges` | `Disable-WindowsPrivilege` | Names each privilege | + +### File system commands + +| NTFSSecurity | WindowsAccessControl | Use instead | +| --- | --- | --- | +| `Get-ChildItem2`, `Get-Item2`, `Test-Path2` | None | `Get-ChildItem`, `Get-Item`, `Test-Path` | +| `Copy-Item2`, `Move-Item2`, `Remove-Item2` | None | `Copy-Item`, `Move-Item`, `Remove-Item` | +| `New-NTFSHardLink`, `New-NTFSSymbolicLink` | None | `New-Item -ItemType HardLink` or `-ItemType SymbolicLink` | +| `Get-NTFSHardLink` | None | `fsutil hardlink list` | +| `Get-FileHash2` | None | `Get-FileHash` | +| `Get-DiskSpace` | None | `Get-Volume` | + +### Commands without an NTFSSecurity predecessor + +- `Set-NTFSAccessRule` replaces the rules of one account and one allow or deny + type, and leaves other accounts alone. `Set-NTFSAuditRule` is its audit + counterpart. +- `New-NTFSAccessRule` and `New-NTFSAuditRule` build a rule in memory without + applying it. +- `Edit-NTFSItemSecurityDescriptor` reads, edits, and writes a descriptor + inside one lock. +- `Copy-NTFSItemSecurityDescriptor`, `Backup-NTFSItemSecurityDescriptor`, and + `Restore-NTFSItemSecurityDescriptor` copy, back up, and restore selected + descriptor sections. +- `Test-NTFSItemAcl` reports access control entries that are out of canonical + order. +- `Resolve-WindowsIdentity` returns the account name and the SID of an + identity. + +Beyond the file system, the module manages registry keys, services, live +processes, SMB shares, Active Directory objects, Task Scheduler, and +certificate private keys. The [command reference](usage/command-reference.md) +lists every command. + +## Parameter differences + +### Removal defaults differ + +`Remove-NTFSAccess` and `Remove-NTFSAudit` take the named rights away from a +matching entry and delete the entry only when no right is left. With +`-RemoveSpecific`, they remove only an entry that matches exactly. +`Remove-NTFSAccessRule` and `Remove-NTFSAuditRule` select the behavior with +`RemovalMode`, and the default is the exact match: + +| NTFSSecurity | WindowsAccessControl | +| --- | --- | +| Default behavior | `-RemovalMode Rights` | +| `-RemoveSpecific` | `-RemovalMode Exact`, the default | +| `Get-NTFSAccess` piped to `Remove-NTFSAccess` | `Get-NTFSAccessRule` piped to `Remove-NTFSAccessRule`, or `-RemovalMode All` for every explicit rule of one account | + +A migrated call that names other rights than the entry holds, such as one +right of a larger entry, does not fail without `-RemovalMode Rights`. The exact +match finds no identical entry, and nothing is removed. Add +`-RemovalMode Rights` wherever a call takes rights away. + +### Confirmation and preview + +The NTFSSecurity permission cmdlets do not support `WhatIf` or `Confirm`. +Every mutating command here supports both, and the commands with a high impact +prompt before they act: `Remove-NTFSAccessRule`, `Remove-NTFSAuditRule`, +`Clear-NTFSAccessRule`, `Clear-NTFSAuditRule`, `Set-NTFSItemOwner`, +`Copy-NTFSItemSecurityDescriptor`, `Restore-NTFSItemSecurityDescriptor`, +`Enable-WindowsPrivilege`, and `Disable-WindowsPrivilege`. + +A scheduled task or another non-interactive host cannot answer that prompt. +Preview the command with `-WhatIf`, then pass `-Confirm:$false` in the +unattended script. See +[The confirmation model](usage/safety-and-privileges.md#the-confirmation-model). + +### Rights + +- `AccessRights` keeps its name. The alias `FileSystemRights` does not exist + here. +- Every right name except `None` and the four generic rights has the same + value in both modules. +- Pass a generic right as a raw mask: `0x10000000` for `GenericAll`, + `0x20000000` for `GenericExecute`, `0x40000000` for `GenericWrite`, and + `0x80000000` for `GenericRead`. The `AccessRightsDisplay` property of a rule + shows those names. +- An allow rule receives `Synchronize` in addition to the requested rights, as + it did in NTFSSecurity. + +### Scope with AppliesTo + +The `InheritanceFlags` and `PropagationFlags` parameters do not exist here; +use `AppliesTo`. Its thirteen values are the same in both modules and set the +same flags: + +| `AppliesTo` | `InheritanceFlags` | `PropagationFlags` | +| --- | --- | --- | +| `ThisFolderOnly` | `None` | `None` | +| `ThisFolderSubfoldersAndFiles` | `ContainerInherit, ObjectInherit` | `None` | +| `ThisFolderAndSubfolders` | `ContainerInherit` | `None` | +| `ThisFolderAndFiles` | `ObjectInherit` | `None` | +| `SubfoldersAndFilesOnly` | `ContainerInherit, ObjectInherit` | `InheritOnly` | +| `SubfoldersOnly` | `ContainerInherit` | `InheritOnly` | +| `FilesOnly` | `ObjectInherit` | `InheritOnly` | + +Every value except `ThisFolderOnly` also exists with the suffix `OneLevel`, +which adds `NoPropagateInherit`. Without `AppliesTo`, a rule on a directory +applies to `ThisFolderSubfoldersAndFiles` and a rule on a file to +`ThisFolderOnly`, which matches what NTFSSecurity stored. Rule objects still +report `InheritanceFlags` and `PropagationFlags`. + +### Accounts + +- `Account` keeps its name. On the add commands it keeps the aliases + `IdentityReference` and `ID`. +- Account names and SID strings are accepted. Each account is resolved to one + SID before anything is written, and duplicates are removed + ([ADR 0006](../specs/decisions/0006-prevalidate-and-deduplicate-identities.md)). +- The get and add commands accept several accounts; the set and remove + commands accept one. +- `Get-NTFSItemEffectiveAccess` has no `NTAccount` alias for `Account`. + +### Paths + +- `Path` keeps its alias `FullName`. The new `LiteralPath` parameter, with + the alias `PSPath`, takes a path exactly as written; the examples use it. +- The remove commands do not take piped files or folders. Pipe the rules from + `Get-NTFSAccessRule` into them, or pass the paths to `-LiteralPath`. +- A path that starts with `\\?\` or `\\.\` is refused, and so is a bare drive + such as `C:`; write `C:\` + ([ADR 0029](../specs/decisions/0029-refuse-device-namespace-paths.md)). +- No command walks a folder tree. Pipe `Get-ChildItem -Recurse` into the + command instead + ([ADR 0031](../specs/decisions/0031-never-expand-a-supplied-target-set.md)). +- A UNC path works everywhere except in `Get-NTFSItemEffectiveAccess`. + +### Long paths + +NTFSSecurity reaches paths longer than 260 characters through the AlphaFS +library. `WindowsAccessControl` uses the in-box file system provider instead. +In PowerShell 7, such a path works without a prefix; in Windows PowerShell 5.1, +it cannot be reached +([ADR 0029](../specs/decisions/0029-refuse-device-namespace-paths.md)). Run +migrated scripts that touch long paths in PowerShell 7. + +### Descriptor editing + +- The access, audit, owner, and inheritance commands accept the result of + `Get-NTFSItemSecurityDescriptor` from the pipeline. Each one returns the + edited descriptor without writing, and `Set-NTFSItemSecurityDescriptor` + writes it once. See + [Descriptor editing and concurrency](usage/descriptor-editing.md). +- A descriptor can be edited only in the sections it was read with, which + `Get-NTFSItemSecurityDescriptor -Sections` selects. +- `Get-NTFSAccessRule`, `Get-NTFSAuditRule`, `Get-NTFSItemOwner`, + `Get-NTFSItemInheritance`, and `Get-NTFSItemEffectiveAccess` read from the + path and have no `SecurityDescriptor` parameter. A descriptor object exposes + its .NET security object as `NativeSecurity` and its SDDL as `Sddl`. +- `Clear-NTFSAccess -DisableInheritance` becomes two steps: + `Disable-NTFSItemInheritance -PreserveInherited:$false` and + `Clear-NTFSAccessRule`. Together they leave an empty DACL, which denies + everyone access, so grant the access you need in the same write. The + [rebuild example](#rebuild-a-folder-acl-in-one-write) chains all three. + +### Other parameters + +- `Get-NTFSItemEffectiveAccess` has no `ServerName` and no + `ExcludeNoneAccessEntries`. Its `AccessRights` parameter tests the named + rights and fills `IsAllowed`, so `Where-Object IsAllowed` keeps the items + on which the account holds them. +- `Add-NTFSAccessRule -PassThru` returns the rule that was written, not every + entry of the item. Call `Get-NTFSAccessRule` to list the whole DACL. +- Commands that take several targets accept `ThrottleLimit` and process up to + that many targets in parallel. + +## Output differences + +| NTFSSecurity type | WindowsAccessControl type | Returned by | +| --- | --- | --- | +| `Security2.FileSystemAccessRule2` | `WindowsAccessControl.AccessRule` | `Get-NTFSAccessRule`, `New-NTFSAccessRule` | +| `Security2.FileSystemAuditRule2` | `WindowsAccessControl.AuditRule` | `Get-NTFSAuditRule`, `New-NTFSAuditRule` | +| `Security2.FileSystemAccessRule2` | `WindowsAccessControl.EffectiveAccess` | `Get-NTFSItemEffectiveAccess` | +| `Security2.FileSystemInheritanceInfo` | `WindowsAccessControl.Inheritance` | `Get-NTFSItemInheritance` | +| `Security2.FileSystemOwner` | `WindowsAccessControl.Owner` | `Get-NTFSItemOwner` | +| `Security2.FileSystemSecurity2` | `WindowsAccessControl.SecurityDescriptor` | `Get-NTFSItemSecurityDescriptor` | +| `ProcessPrivileges.PrivilegeAndAttributes` | `WindowsAccessControl.Privilege` | `Get-WindowsPrivilege` | + +An access rule keeps `Account`, `AccessRights`, `AccessControlType`, +`InheritanceFlags`, `PropagationFlags`, `IsInherited`, and `InheritedFrom`. +The item path moves from `FullName` to `Path`. New properties are `SID`, +`AccessMask`, `AccessRightsDisplay`, `AppliesTo`, `IsOrphaned`, and +`NativeRule`, the underlying .NET rule. `AccountType`, which NTFSSecurity +read from Active Directory, has no counterpart. + +The module has no settings in its manifest: + +- `InheritedFrom` is always filled when Windows can name the source of an + inherited rule, so `GetInheritedFrom` has no counterpart. +- Every rule carries `SID`, so `ShowAccountSid` becomes a column choice, such + as `Format-Table Account, SID, AccessRightsDisplay`. +- Each operation enables the privilege it needs, so `EnablePrivileges` has no + counterpart. + +The module adds no members to file and folder objects. Instead of the `Owner` +and `IsInheritanceBlocked` properties and the `EnableInheritance()` and +`DisableInheritance()` methods, use `Get-NTFSItemOwner`, +`Get-NTFSItemInheritance`, `Enable-NTFSItemInheritance`, and +`Disable-NTFSItemInheritance`. + +## Features without an equivalent + +| NTFSSecurity feature | Decision | Use instead | +| --- | --- | --- | +| The `*-Item2` commands, `Test-Path2`, the link commands, `Get-FileHash2`, `Get-DiskSpace`, and the `LengthOnDisk` and `GetHash()` members | General file system commands are a [non-goal](../specs/0001-vision-and-scope.md#non-goals), and [ADR 0002](../specs/decisions/0002-use-only-in-box-runtime-security-apis.md) rejects the AlphaFS dependency | The in-box commands in [File system commands](#file-system-commands) | +| Paths longer than 260 characters in Windows PowerShell 5.1 | [ADR 0029](../specs/decisions/0029-refuse-device-namespace-paths.md) refuses the `\\?\` prefix that would reach them | PowerShell 7 | +| `Get-NTFSSimpleAccess`, which reduces rights to read, write, and delete | Deferred, because composite rights, deny rules, and inheritance scopes cannot always be reduced without changing their meaning ([research](research.md#detailed-ntfssecurity-comparison)) | `AccessRightsDisplay` of `Get-NTFSAccessRule`, or `Get-NTFSItemEffectiveAccess` | +| Effective access evaluated by another computer, through `ServerName` or a UNC path | [ADR 0017](../specs/decisions/0017-defer-remote-and-combined-effective-access.md) keeps the evaluation local | `Get-NTFSItemEffectiveAccess` on the computer that stores the files | +| All four file system privileges enabled for every command, whether or not it needs them | [ADR 0008](../specs/decisions/0008-use-scoped-automatic-privilege-enablement.md) enables only the privilege an operation needs, restores it afterward, and reports an error when the token does not hold it | Nothing; `Enable-WindowsPrivilege` remains for deliberate token changes | +| Taking ownership and retrying after an access-denied error | [ADR 0008](../specs/decisions/0008-use-scoped-automatic-privilege-enablement.md) rejects it, because it changes an unrelated security boundary | `Set-NTFSItemOwner`, as a deliberate step | +| Public wrapper classes such as `IdentityReference2` | [ADR 0012](../specs/decisions/0012-use-object-specific-commands-and-dsc-resources.md) rejects public wrapper classes | Account names, SID strings, and `Resolve-WindowsIdentity` | + +## Side-by-side examples + +Each example shows the NTFSSecurity command first and the +`WindowsAccessControl` command after it. + +### Grant access and deny one right + +```powershell +Add-NTFSAccess -Path C:\Data -Account 'CONTOSO\Analysts' -AccessRights Read +Add-NTFSAccess -Path C:\Data -Account 'CONTOSO\Domain Users' ` + -AccessRights CreateFiles -AccessType Deny -AppliesTo ThisFolderOnly +``` + +```powershell +Add-NTFSAccessRule -LiteralPath 'C:\Data' ` + -Account 'CONTOSO\Analysts' ` + -AccessRights Read ` + -WhatIf + +Add-NTFSAccessRule -LiteralPath 'C:\Data' ` + -Account 'CONTOSO\Domain Users' ` + -AccessRights CreateFiles ` + -AccessControlType Deny ` + -AppliesTo ThisFolderOnly ` + -WhatIf +``` + +### Take one right away from an entry + +```powershell +Remove-NTFSAccess -Path C:\Data -Account 'CONTOSO\Domain Users' ` + -AccessRights DeleteSubdirectoriesAndFiles ` + -AppliesTo ThisFolderSubfoldersAndFiles +``` + +```powershell +Remove-NTFSAccessRule -LiteralPath 'C:\Data' ` + -Account 'CONTOSO\Domain Users' ` + -AccessRights DeleteSubdirectoriesAndFiles ` + -AppliesTo ThisFolderSubfoldersAndFiles ` + -RemovalMode Rights ` + -WhatIf +``` + +Without `-RemovalMode Rights`, the command looks for an identical entry, finds +none, and changes nothing. + +### Report the explicit permissions of a folder tree + +```powershell +Get-ChildItem2 -Path C:\Data -Recurse | Get-NTFSAccess -ExcludeInherited +``` + +```powershell +Get-ChildItem -LiteralPath 'C:\Data' -Recurse | + Get-NTFSAccessRule -ExcludeInherited | + Format-Table Path, Account, AccessRightsDisplay, AccessControlType, AppliesTo +``` + +### Back up and restore permissions + +NTFSSecurity restores exported entries by piping them back to +`Add-NTFSAccess`: + +```powershell +Get-ChildItem -Path C:\Data -Recurse | Get-NTFSAccess -ExcludeInherited | + Export-Csv -Path C:\Backup\acl.csv -NoTypeInformation +Import-Csv -Path C:\Backup\acl.csv | Add-NTFSAccess +``` + +`WindowsAccessControl` writes a JSON backup with a SHA-256 digest for each +item, validates every record before the first write, and restores the recorded +DACL as it was rather than adding entries: + +```powershell +Get-ChildItem -LiteralPath 'C:\Data' -Recurse | + Backup-NTFSItemSecurityDescriptor ` + -DestinationPath 'C:\Backup\permissions.json' ` + -Sections Access + +Restore-NTFSItemSecurityDescriptor ` + -BackupPath 'C:\Backup\permissions.json' ` + -WhatIf +``` + +See [Backup, restore, and copy](usage/backup-and-restore.md) for signing and +the restore boundaries. + +### Rebuild a folder ACL in one write + +```powershell +$sd = Get-NTFSSecurityDescriptor -Path C:\Data +Clear-NTFSAccess -SecurityDescriptor $sd -DisableInheritance +Add-NTFSAccess -SecurityDescriptor $sd -Account 'BUILTIN\Administrators' ` + -AccessRights FullControl -AppliesTo ThisFolderSubfoldersAndFiles +Set-NTFSSecurityDescriptor -SecurityDescriptor $sd +``` + +```powershell +Get-NTFSItemSecurityDescriptor -LiteralPath 'C:\Data' -Sections Access | + Disable-NTFSItemInheritance -Section Access -PreserveInherited:$false | + Clear-NTFSAccessRule | + Add-NTFSAccessRule -Account 'BUILTIN\Administrators' ` + -AccessRights FullControl ` + -AppliesTo ThisFolderSubfoldersAndFiles | + Set-NTFSItemSecurityDescriptor -WhatIf +``` + +Nothing is written until `Set-NTFSItemSecurityDescriptor`, which writes only +the access section. The result is a protected DACL with a single rule, so +preview it with `-WhatIf` before you run it. + +## See also + +- [Command reference](usage/command-reference.md) +- [File system](usage/file-system.md) +- [Safety, preview, and privileges](usage/safety-and-privileges.md) +- [Detailed NTFSSecurity comparison](research.md#detailed-ntfssecurity-comparison) +- [NTFSSecurity documentation](https://github.com/raandree/NTFSSecurity/tree/master/Docs) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7024700..0954e2b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -165,6 +165,7 @@ refusals in more detail. - [Documentation index](README.md) - [Project overview and command catalog](../README.md) +- [Migration from NTFSSecurity](migration-from-ntfssecurity.md) - [Migration from NTFSPermission](migration-from-ntfspermission.md) - [Public API contract](../specs/0003-public-api.md) - [Security and persistence contract](../specs/0004-security-and-persistence.md) diff --git a/docs/usage/command-reference.md b/docs/usage/command-reference.md index d06d4fc..47e9c24 100644 --- a/docs/usage/command-reference.md +++ b/docs/usage/command-reference.md @@ -187,4 +187,5 @@ List the members of any of them: - [Usage guide overview](../usage-guide.md) - [Public API contract](../../specs/0003-public-api.md) +- [Migration from NTFSSecurity](../migration-from-ntfssecurity.md) - [Migration from NTFSPermission](../migration-from-ntfspermission.md) diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index 5b88b3d..ff94f1b 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -165,4 +165,5 @@ Get-Item -LiteralPath 'HKLM:\Software\Contoso' | Get-RegistryKeyAccessRule ## See also - [Usage guide overview](../usage-guide.md) +- [Migration from NTFSSecurity](../migration-from-ntfssecurity.md) - [Migration from NTFSPermission](../migration-from-ntfspermission.md) diff --git a/source/WikiSource/Home.md b/source/WikiSource/Home.md index 941f00c..65d7d90 100644 --- a/source/WikiSource/Home.md +++ b/source/WikiSource/Home.md @@ -49,7 +49,10 @@ Get-DscResource -Module WindowsAccessControl - [`docs/usage-guide.md`](https://github.com/raandree/WindowsAccessControl/blob/main/docs/usage-guide.md) walks through the common tasks and links to a page for each object family. - The [`docs/`](https://github.com/raandree/WindowsAccessControl/tree/main/docs) - index lists every guide, including the migration map from `NTFSSecurity`. + index lists every guide. +- [`docs/migration-from-ntfssecurity.md`](https://github.com/raandree/WindowsAccessControl/blob/main/docs/migration-from-ntfssecurity.md) + maps every `NTFSSecurity` command to its replacement and explains what + changes when a script moves over. - The [`specs/`](https://github.com/raandree/WindowsAccessControl/tree/main/specs) folder records the accepted behavior, including the cases the module deliberately refuses. From add92731bd892b546e72d70d4f17439352824e77 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 22:30:36 +0000 Subject: [PATCH 15/20] docs: correct the private-key capability statement The README still called certificate private keys read-only and listed mutation as out of scope, although specification 0015 superseded that boundary and the usage guide already describes the write commands, their fail-closed gates, backup and restore, and the DSC resources. Also record handoff 04 in the Memory Bank. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 75 ++++++++++++++++++----------------- .memory-bank/progress.md | 71 +++++++++++---------------------- README.md | 19 +++++++-- 3 files changed, 76 insertions(+), 89 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index f1ad93a..68f7c58 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,36 +9,32 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Handoff 03 of the 2026-10-06 sequence is complete on the local branch -`ai/post-release-fixes`; the user chose to finish the orphaned performance -refactor. `fbced95` ports its code, tests, and benchmarks from -`ai/performance-refactor` as one revertable `perf:` commit, and `d13a517` and -`b860448` port its documentation; every Memory Bank hunk resolved to the -current files. The ported private-key DACL comparison holds over the FIND-001 -returned-bytes setter, and the seven batch adapters still pass handoff 02's -Task Scheduler, SMB, and AD parameters unchanged. `87d4f35` pins the -dispatcher's `CommandName`/`ObjectFamily` guard and an NTFS adapter worker's -per-target parameter copy; each test failed with its guard removed. The -`docs(performance)` commit re-measured both benchmarks before and after the -`perf:` commit: hashes and checksums match, and the gains hold. One -independent review approved with one Minor and three Nits, all addressed. The -ported `5f06c03` body was reworded because "Major" in it would have made the -next release 1.0.0. With WinRM running on the development machine since a -later check on 2026-10-06, the Desktop gate passes too: 1,872 tests at 80.95 -percent asserted coverage, a thin margin. No live lab run took place; handoff -07 owns the first live and installed-package acceptance of the refactor. Next -is handoff 04. No git remote was mutated. +Handoff 04 of the 2026-10-06 sequence is complete on the local branch +`ai/post-release-fixes`. The user confirmed the positioning: +`WindowsAccessControl` is the successor of NTFSSecurity, which is deprecated +and will be archived, as the NTFSSecurity 5.0.0-rc4 Gallery description and +change log already said on 2026-10-06. `docs/migration-from-ntfssecurity.md` +maps all 37 commands that the 4.2.6 and 5.0.0-rc4 manifests export, confirmed +against the built module, with the parameter and output differences and the +decision behind each missing feature. Every published example ran verbatim in +Core 7.6.6 and Desktop 5.1. The documentation index, the wiki Home page, the +README, and three See also lists point at the guide, and the README no longer +calls private keys read-only. The issue #4 reply is a draft in the handoff +`reports` folder. Next is handoff 05. No git remote was mutated. -Handoffs 01 and 02 made the lab runner refuse to delete what it did not -create and fixed the Task Scheduler, SMB, and AD review defects. On -2026-10-06 the user reviewed all 14 recorded agent decisions of handoffs 01 -to 03: twelve are accepted as recorded, and the other two were overtaken by -doing the work (the decision log moved out of `systemPatterns.md`, and specs -0005 and 0006 name the descriptor benchmark). WinRM stays enabled here. -Later handoffs ask their own questions with fresh evidence. The -2026-10-06 triage below remains the record of the open work. `main` is -`40e364a`, in sync with `origin/main`; `ai/performance-refactor` stays at -`5f06c03` until handoff 08 deletes it with approval. +Handoffs 01 to 03 made the lab runner refuse to delete what it did not +create, fixed the Task Scheduler, SMB, and AD review defects, and ported and +re-measured the performance refactor; the Desktop gate passes narrowly at +80.95 percent asserted coverage, and handoff 07 owns the first live and +installed-package acceptance of the refactor. On 2026-10-06 the user reviewed +all 14 recorded agent decisions of handoffs 01 to 03: twelve are accepted as +recorded, and the other two were overtaken by doing the work (the decision log +moved out of `systemPatterns.md`, and specs 0005 and 0006 name the descriptor +benchmark). WinRM stays enabled here. Later handoffs ask their own questions +with fresh evidence. The 2026-10-06 triage below remains the record of the +open work. `main` is `40e364a`, in sync with `origin/main`; +`ai/performance-refactor` stays at `5f06c03` until handoff 08 deletes it with +approval. ## Handoff sequence @@ -103,9 +99,9 @@ and cleanup. Only 08 touches the remote, with approval per action. - Minor, fixed by handoff 02: `Get-ADObjectCallerEffectiveAccess` requested `nTSecurityDescriptor` it never used, and a caller without read-control access got a null-index error. Live evidence is owed by handoff 07. - - Follow-ups: `README.md` line 315 still calls private keys read-only. The - batch dispatcher `CommandName`/`ObjectFamily` guard is tested since - handoff 03. + - Follow-ups: the batch dispatcher `CommandName`/`ObjectFamily` guard is + tested since handoff 03, and handoff 04 corrected the README private-key + statement against specification 0015. 3. **Dependabot pull requests #1-#3** bump `actions/checkout` to 7.0.1, `actions/upload-artifact` to 7.0.1, and `actions/download-artifact` to 8.0.1. Their red test jobs are inherited: all three branch from `4806726` @@ -115,11 +111,16 @@ and cleanup. Only 08 touches the remote, with approval per action. All three merge cleanly onto `main`, and no test pins action versions. The v4 pins raise the Node.js 20 deprecation warning. Land the artifact pair together after reading the major-version release notes. -4. **Issue #4** (2026-09-07, unanswered) asks whether this module replaces - NTFSSecurity. `docs/README.md` and `source/WikiSource/Home.md` promise an - NTFSSecurity migration map, but `docs/migration-from-ntfspermission.md` - covers only the unpublished NTFSPermission rename. Source material: - `docs/research.md#detailed-ntfssecurity-comparison`. +4. **Issue #4** (2026-09-07) asks whether this module replaces NTFSSecurity. + Handoff 04 answered it in `docs/migration-from-ntfssecurity.md` and left + the reply as a draft in the handoff `reports` folder. Handoff 08 posts it + only with approval and only after the guide reaches `main`, because the + linked URL returns 404 until then; re-check the NTFSSecurity Gallery state + first. Observation for a later decision: `Remove-NTFSAccessRule` and + `Remove-NTFSAuditRule` in their default `Exact` mode remove nothing and + report nothing when no identical entry exists, which a migrated + NTFSSecurity call that subtracts rights hits. The guide documents it; no + behavior changed. 5. **Changelog.** The v0.2.0 run reported "Send changelog pull request" as successful, yet the repository has no closed pull request. Branch `updateChangelogAfterv0.2.0` (`38cdabd`) inserts `## [0.2.0]` directly diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 206d812..f7d82fc 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -12,15 +12,25 @@ source: repository evidence `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage found unmerged and untracked work and split it into eight handoffs on local -`ai/post-release-fixes`. Handoffs 01 to 03 fixed the lab-runner Blocker, the -Task Scheduler, SMB, and AD review defects, and ported and re-measured the -performance refactor there; three dependabot pull requests with inherited -failures, unanswered issue #4 with a missing NTFSSecurity migration map, an -uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit remain. -`activeContext.md` holds the evidence and the order. +`ai/post-release-fixes`. Handoffs 01 to 04 fixed the lab-runner Blocker, the +Task Scheduler, SMB, and AD review defects, ported and re-measured the +performance refactor, and wrote the NTFSSecurity migration guide there; three +dependabot pull requests with inherited failures, the unposted issue #4 reply, +an uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit +remain. `activeContext.md` holds the evidence and the order. ## Recent milestones +- 2026-10-06: Handoff 04 documented the move from NTFSSecurity, which the + user positioned as deprecated, with `WindowsAccessControl` as its + successor. `docs/migration-from-ntfssecurity.md` maps every command of the + NTFSSecurity 4.2.6 and 5.0.0-rc4 manifests, confirmed against the built + module, and names what changes for a migrated script: exact removal by + default, confirmation prompts, `AppliesTo` instead of flags, and long + paths only in PowerShell 7. Its examples ran verbatim in both editions. + The README private-key statement now matches specification 0015, and the + issue #4 reply is drafted but not posted. + - 2026-10-06: The user reviewed all 14 recorded agent decisions of handoffs 01 to 03 and accepted twelve as recorded. The other two were overtaken: the decision log moved unchanged to `topics/decision-log.md`, bringing @@ -130,50 +140,15 @@ uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit remain. Desktop DSC checks, live acceptance, and independent review as open gates. Generated packages and raw logs remain excluded from the Git transfer. -- 2026-09-06: Audited the complete script inventory and controlling safety - paths. Reproduced and fixed escaped-DN containment, restore GUID loss, - wrong native ACE removal, DSC cleanup ownership, and misleading lab - evidence success. Core: 1,802 passed, zero failed, two environment skips; - coverage: 82.74 percent after moving the locally exercised AD setter into - asserted scope. Added the audit record and detached lab checklist. All - eight live suites discover 95 tests, but no live acceptance was run. - Desktop: 1,755 passed, two failed, two skips. The failing DSC-engine calls - cannot reach local WS-Management; WinRM is stopped with manual startup. - No test or host remoting setting was changed to bypass that prerequisite. - Packaging passed all 22 tasks; the local `0.0.1` candidate contains the - exact tested module plus generated help. The audit record retains hashes, - raw-evidence location, and the remaining lab/review obligations. - -- 2026-09-06: Confirmed hosted closure in run `34055979655` for `830a909`. - All four jobs passed; the Ubuntu publish job completed at 20:04:59 UTC. - The live wiki Home page names `v0.2.0-preview0002` and the sidebar contains - generated command and DSC resource navigation. The standard task works on - the selected runner, closing the outstanding Linux validation without a - custom task or manual wiki seed. No further incident work remains. - -- 2026-09-06: Reinvestigated why the stock wiki publisher works elsewhere. - With the actual release archive and upstream 0.13.0 on Windows, 126 added - files plus Home timed out at 3,042 ms with a shortened 3,000 ms timeout; - modifying 127 existing files passed in 157 ms and emitted only 112 bytes. - A quiet initial commit passed in 104 ms. Both DSC Community comparison - pipelines publish on Ubuntu. Removed the rejected custom publisher and - changed only the publish runner; build/test runners and standard task - order are unchanged. The new workflow guard was red then green; all 20 - build-specific tests pass in both editions, and Sampler resolves the - upstream wiki task. - Actual Ubuntu publication awaits a hosted run. No commit or remote write. - -- 2026-09-06: A custom wiki workaround passed the Core and Desktop gates - (1,771 and 1,726 tests; 82.40% and 80.20% asserted coverage), but remained - uncommitted. It was subsequently withdrawn at the user's request in favor - of the standard task and runner investigation recorded above. - - 2026-09-03 to 2026-09-06: OI-31, the 105-command audit, the audit-gap closure, the `6f7ba15` close-out, the `34026468199` wiki-publication hang - (DscResource.DocGenerator#111), the `34021812398` completer fix, and the - `ai/access-rights-completion` review are in - `git show 8b67058:.memory-bank/progress.md` and, for the oldest, + (DscResource.DocGenerator#111), the `34021812398` completer fix, the + `ai/access-rights-completion` review, the script-inventory audit, and the + wiki-publisher investigation that run `34055979655` closed are in + `git show 4461b90:.memory-bank/progress.md`, + `git show 8b67058:.memory-bank/progress.md`, and, for the oldest, `git show a48ec3d:.memory-bank/progress.md`. Lessons kept: record transient host paths without a user profile; keep imports unforced and run docs and tests in separate processes; bind completers to an instance, not - a disposed worker context. + a disposed worker context; publish the wiki with the standard task from an + Ubuntu runner. diff --git a/README.md b/README.md index 51d17ca..e7088c6 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,8 @@ remote target parameters. ## Certificate private keys -The first private-key increment is read-only and supports exact persisted RSA -keys in Microsoft Software Key Storage Provider. Supply the certificate object, +Private-key commands read and change the DACL of an exact persisted RSA key in +Microsoft Software Key Storage Provider. Supply the certificate object, provider, and key name so the module can cross-check identity without searching stores or exporting key material: @@ -330,8 +330,19 @@ Get-CertificatePrivateKeySecurityDescriptor ` -KeyName 'WorkloadKey' ``` -CAPI, hardware, ephemeral, mismatched, and mutation workflows are not part of -this increment. The command does not dispose the caller-owned certificate. +`Add-CertificatePrivateKeyAccessRule` grants an account access, +`Remove-CertificatePrivateKeyAccessRule` removes an exact rule, and +`Set-CertificatePrivateKeySecurityDescriptor` writes one complete DACL. Every +write passes fail-closed gates first. Among them, it refuses a new deny rule, a +DACL without full control for Local System and the Administrators group, and a +key that an HTTP.sys, WinRM HTTPS, Remote Desktop, or Active Directory LDAPS +binding uses, which `Test-CertificatePrivateKeyCriticalBinding` reports. The +family covers DACL descriptors, typed access rules, backup/restore, and DSC. +CAPI, hardware, removable, ephemeral, and mismatched keys, audit rules, owner +and group changes, and key creation, deletion, or export are not supported. The +commands do not dispose the caller-owned certificate. The +[certificate private-key guide](docs/usage/certificate-private-keys.md) lists +every gate. ## Services and the SCM From a6697c3f5de83e4c37bfa33a3d981532f208f9fc Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 22:58:56 +0000 Subject: [PATCH 16/20] ci: move checkout and artifact actions to their Node 24 releases The three pinned actions declared node20, which GitHub now runs on Node.js 24 while warning on every job. Each pin moves to the SHA its target tag resolves to, verified with git ls-remote; all six tags are lightweight, so no peeling was needed: - actions/checkout 11d5960a -> 3d3c42e5 (v4.4.0 to v7.0.1) - actions/upload-artifact ea165f8d -> 043fb46d (v4.6.2 to v7.0.1) - actions/download-artifact d3f86a10 -> 3e5f45b2 (v4.3.0 to v8.0.1) Every release note from v4 to each target was read and checked against this workflow. Nothing it relies on changed meaning: artifact names and paths, the layout a download by name produces, hidden-file handling, if-no-files-found, retention-days, fetch-depth, and credential persistence all behave as before, and the Node.js 24 runner requirement is met by the hosted windows-latest and ubuntu-latest runners. The new checkout refusal to check out fork pull request code covers pull_request_target and workflow_run, neither of which this workflow uses. No input needed an edit, so this is seven pins and a test. One behavior does change and is kept: download-artifact v8 ends a job when a downloaded artifact's digest does not match the server, where v4 only warned. A damaged output tree must not reach the test or the publish job. A new test in tests/Unit/Build holds every uses: reference to a 40-character SHA pin with a version comment, keeps one action from being pinned to two SHAs, and requires each of the three to sit on a release line that declares node24. It failed three of its seven cases before this change. Dependabot #1 to #3 propose exactly these seven lines; they are superseded. The real proof is the pull-request CI, which is the last handoff's work. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 14 +-- .memory-bank/activeContext.md | 50 +++++--- .memory-bank/progress.md | 20 +++- .../Build/WorkflowActionPinning.Tests.ps1 | 108 ++++++++++++++++++ 4 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 tests/Unit/Build/WorkflowActionPinning.Tests.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2356a84..bbd8178 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: module-version: ${{ steps.calculate-version.outputs.module-version }} steps: - name: Check out full repository history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -69,7 +69,7 @@ jobs: ./build.ps1 -ResolveDependency -Tasks pack - name: Upload Sampler output - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sampler-output path: output/ @@ -93,10 +93,10 @@ jobs: artifact-suffix: powershell-5.1 steps: - name: Check out source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download Sampler output - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sampler-output path: output @@ -151,7 +151,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-${{ matrix.artifact-suffix }} path: | @@ -176,12 +176,12 @@ jobs: pull-requests: write steps: - name: Check out full repository history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Download Sampler output - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sampler-output path: output diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 68f7c58..12ee922 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,18 +9,29 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Handoff 04 of the 2026-10-06 sequence is complete on the local branch -`ai/post-release-fixes`. The user confirmed the positioning: +Handoff 05 of the 2026-10-06 sequence is complete on the local branch +`ai/post-release-fixes`. The build workflow now pins `actions/checkout` +v7.0.1, `actions/upload-artifact` v7.0.1, and `actions/download-artifact` +v8.0.1, each by the 40-character SHA that its own tag resolves to, which ends +the Node.js 20 deprecation warning. The release notes from v4 to each target +were read and checked one by one against this workflow: no input changed +meaning here, so the commit is seven pins and nothing else. The notable +difference is that `download-artifact` v8 fails a job on a digest mismatch +instead of warning, which is kept. A new test in `tests/Unit/Build` holds +every `uses:` reference to a SHA pin with a version comment and to a release +line that declares Node.js 24. The three Dependabot pull requests are +superseded: the seven changed lines are byte-identical to their proposals. +Next is handoff 06. No git remote was mutated. + +Handoff 04 settled the NTFSSecurity positioning that the user confirmed: `WindowsAccessControl` is the successor of NTFSSecurity, which is deprecated -and will be archived, as the NTFSSecurity 5.0.0-rc4 Gallery description and -change log already said on 2026-10-06. `docs/migration-from-ntfssecurity.md` -maps all 37 commands that the 4.2.6 and 5.0.0-rc4 manifests export, confirmed -against the built module, with the parameter and output differences and the -decision behind each missing feature. Every published example ran verbatim in -Core 7.6.6 and Desktop 5.1. The documentation index, the wiki Home page, the +and will be archived. `docs/migration-from-ntfssecurity.md` maps all 37 +commands that the 4.2.6 and 5.0.0-rc4 manifests export, confirmed against the +built module, with the parameter and output differences and the decision +behind each missing feature. The documentation index, the wiki Home page, the README, and three See also lists point at the guide, and the README no longer calls private keys read-only. The issue #4 reply is a draft in the handoff -`reports` folder. Next is handoff 05. No git remote was mutated. +`reports` folder; its wiki link 404s until `main` has the guide. Handoffs 01 to 03 made the lab runner refuse to delete what it did not create, fixed the Task Scheduler, SMB, and AD review defects, and ported and @@ -102,15 +113,18 @@ and cleanup. Only 08 touches the remote, with approval per action. - Follow-ups: the batch dispatcher `CommandName`/`ObjectFamily` guard is tested since handoff 03, and handoff 04 corrected the README private-key statement against specification 0015. -3. **Dependabot pull requests #1-#3** bump `actions/checkout` to 7.0.1, - `actions/upload-artifact` to 7.0.1, and `actions/download-artifact` to - 8.0.1. Their red test jobs are inherited: all three branch from `4806726` - (2026-08-15, 42 commits behind `main`), whose own push build failed the - same four NTFS path tests. `0502254` fixed those tests the next morning. - The API's base `4c204ff` is the later branch tip, and its tests passed. - All three merge cleanly onto `main`, and no test pins action versions. - The v4 pins raise the Node.js 20 deprecation warning. Land the artifact - pair together after reading the major-version release notes. +3. **Dependabot pull requests #1-#3** are superseded by handoff 05, which + applied all three bumps on `ai/post-release-fixes` in one commit: + `actions/checkout` 7.0.1, `actions/upload-artifact` 7.0.1, and + `actions/download-artifact` 8.0.1, each pinned by the SHA its tag resolves + to. The seven changed lines are byte-identical to the three proposals. + Reading every release note from v4 to each target found no input that + changed meaning for this workflow; `download-artifact` v8 now fails a job + on a digest mismatch instead of warning, which is kept. Their red test jobs + were inherited anyway: all three branch from `4806726` (2026-08-15, 42 + commits behind `main`), whose own push build failed the same four NTFS path + tests that `0502254` fixed the next morning. Handoff 08 closes #1-#3 with + approval, after the pull-request CI proves the bumps. 4. **Issue #4** (2026-09-07) asks whether this module replaces NTFSSecurity. Handoff 04 answered it in `docs/migration-from-ntfssecurity.md` and left the reply as a draft in the handoff `reports` folder. Handoff 08 posts it diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index f7d82fc..f89b3b2 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -12,15 +12,25 @@ source: repository evidence `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage found unmerged and untracked work and split it into eight handoffs on local -`ai/post-release-fixes`. Handoffs 01 to 04 fixed the lab-runner Blocker, the +`ai/post-release-fixes`. Handoffs 01 to 05 fixed the lab-runner Blocker, the Task Scheduler, SMB, and AD review defects, ported and re-measured the -performance refactor, and wrote the NTFSSecurity migration guide there; three -dependabot pull requests with inherited failures, the unposted issue #4 reply, -an uncreated v0.2.0 changelog pull request, and the FIND-002 newline nit -remain. `activeContext.md` holds the evidence and the order. +performance refactor, wrote the NTFSSecurity migration guide, and moved the +build workflow to the Node 24 releases of its three pinned actions there; the +unposted issue #4 reply, an uncreated v0.2.0 changelog pull request, and the +FIND-002 newline nit remain. `activeContext.md` holds the evidence and the +order. ## Recent milestones +- 2026-10-06: Handoff 05 moved the build workflow to the Node 24 releases of + `checkout` 7.0.1, `upload-artifact` 7.0.1, and `download-artifact` 8.0.1, + each pinned by the SHA its own tag resolves to. Every release note from v4 + to the target was checked against this workflow; no input changed meaning, + so the commit is seven pins. `download-artifact` v8 now fails a job on a + digest mismatch instead of warning, which is kept. A new test holds every + `uses:` to a SHA pin with a version comment and a Node 24 release line. + Dependabot #1 to #3 are superseded; the pull-request CI in 08 is the proof. + - 2026-10-06: Handoff 04 documented the move from NTFSSecurity, which the user positioned as deprecated, with `WindowsAccessControl` as its successor. `docs/migration-from-ntfssecurity.md` maps every command of the diff --git a/tests/Unit/Build/WorkflowActionPinning.Tests.ps1 b/tests/Unit/Build/WorkflowActionPinning.Tests.ps1 new file mode 100644 index 0000000..9f073f2 --- /dev/null +++ b/tests/Unit/Build/WorkflowActionPinning.Tests.ps1 @@ -0,0 +1,108 @@ +BeforeAll { + $script:repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + $script:workflowDirectory = Join-Path $script:repositoryRoot '.github/workflows' + + # A step reference looks like 'uses: owner/repo@ # v1.2.3'. The version + # comment is the only human-readable record of what the SHA points at, so the + # raw text is read instead of the parsed document, which drops comments. + $script:actionReference = @( + foreach ($workflowFile in Get-ChildItem -Path $script:workflowDirectory -Include '*.yml', '*.yaml' -File -Recurse) + { + $lineNumber = 0 + + foreach ($line in (Get-Content -Path $workflowFile.FullName)) + { + $lineNumber++ + + if ($line -notmatch '^\s*(-\s+)?uses:\s*(?\S+)\s*(#\s*(?.*?)\s*)?$') + { + continue + } + + # A local composite action is referenced by path and cannot carry a + # SHA pin, so only published actions are checked. + if ($Matches.reference -match '^\.{1,2}/') + { + continue + } + + [pscustomobject] @{ + File = $workflowFile.Name + LineNumber = $lineNumber + Reference = $Matches.reference + Comment = $Matches.comment + } + } + } + ) +} + +Describe 'Workflow action pinning' { + It 'Should reference at least one published action' { + $script:actionReference | Should -Not -BeNullOrEmpty + } + + It 'Should pin every published action to a 40-character commit SHA' { + foreach ($reference in $script:actionReference) + { + $reference.Reference | + Should -Match '^[\w.-]+/[\w./-]+@[0-9a-f]{40}$' -Because ( + '{0} line {1} must pin a commit SHA, not a mutable tag' -f + $reference.File, $reference.LineNumber + ) + } + } + + It 'Should name the pinned version in a trailing comment' { + foreach ($reference in $script:actionReference) + { + $reference.Comment | + Should -Match '^v\d+(\.\d+){0,2}$' -Because ( + '{0} line {1} ({2}) must record the version the SHA points at' -f + $reference.File, $reference.LineNumber, $reference.Reference + ) + } + } + + # GitHub forces an action whose action.yml declares node20 onto the Node.js 24 + # runtime and warns on every run. These are the first release lines of each + # action that declare node24 themselves: checkout v5.0.0, upload-artifact + # v6.0.0, and download-artifact v7.0.0. A higher line keeps passing, so a + # later dependency update needs no change here. + It 'Should keep on a release line that declares Node.js 24' -ForEach @( + @{ Action = 'actions/checkout'; EarliestNode24Major = 5 } + @{ Action = 'actions/upload-artifact'; EarliestNode24Major = 6 } + @{ Action = 'actions/download-artifact'; EarliestNode24Major = 7 } + ) { + $reference = @($script:actionReference | Where-Object -FilterScript { + $_.Reference -like ('{0}@*' -f $Action) + }) + + $reference | Should -Not -BeNullOrEmpty -Because ( + 'the build workflow uses {0}' -f $Action + ) + + foreach ($usage in $reference) + { + [int] (($usage.Comment -replace '^v') -split '\.')[0] | + Should -BeGreaterOrEqual $EarliestNode24Major -Because ( + '{0} line {1} pins {2}, which runs on the deprecated Node.js 20' -f + $usage.File, $usage.LineNumber, $usage.Comment + ) + } + } + + It 'Should pin the same SHA for every usage of one action' { + $inconsistentAction = @( + $script:actionReference | + Group-Object -Property { ($_.Reference -split '@')[0] } | + Where-Object -FilterScript { + @($_.Group.Reference | Sort-Object -Unique).Count -gt 1 + } + ) + + $inconsistentAction.Name | Should -BeNullOrEmpty -Because ( + 'one workflow run must not mix two versions of the same action' + ) + } +} From 8be6892201b20b9727888012f1a3376cfea01ce8 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 23:25:09 +0000 Subject: [PATCH 17/20] docs(changelog): restore the 0.2.0 section the release did not merge +semver: skip The v0.2.0 release ran Create_ChangeLog_GitHub_PR, which pushed the branch updateChangelogAfterv0.2.0 and never opened a pull request, so the version heading the pipeline writes never reached main. Everything CHANGELOG.md carried at tag v0.2.0 now sits under '## [0.2.0] - 2026-09-06', in the heading format of that unmerged branch, and the entries written since the release stay under '## [Unreleased]' in their own categories. No content line moved out or appeared twice: the multiset of non-heading, non-blank lines is identical before and after at 859 lines, the 188 entry bullets are unchanged, and the 14 new lines are 5 headings and 9 blank lines around them. Get-ChangelogData parses the result and reports one released version, 0.2.0 of 2026-09-06. CONTRIBUTING.md reserves version headings for the pipeline, so this completes the pipeline step that never landed instead of hand-writing a heading. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 174 ++++++++++++++++++++++++++++----------------------- 1 file changed, 94 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 445e7da..56c9d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `-ModuleManifestPath` to measure one specific built artifact and record its SHA-256 with every run, so a before-and-after comparison names the exact module it measured +- Add a migration guide for `NTFSSecurity` users, whose module is deprecated in + favor of this one. It maps every command that the NTFSSecurity 4.2.6 and + 5.0.0 manifests export to its replacement or to the decision that leaves it + out, and it calls out the differences that change what a migrated script + does: `Remove-NTFSAccessRule` matches exactly unless `-RemovalMode Rights` + is given, the remove, clear, and owner commands prompt for confirmation, and + a path longer than 260 characters needs PowerShell 7. The documentation + index had pointed NTFSSecurity users at the `NTFSPermission` rename map, + which never mentions NTFSSecurity + +### Changed + +- Speed up the shared descriptor paths that every object family reads through. + Measured on PowerShell 7, repeated rights rendering is 69% faster, + removed-entry detection over 256 access-control entries 66% faster, and + single-target batch dispatch 18% faster; on Windows PowerShell 5.1 + removed-entry detection is 95% faster. Private-key DACL comparison over + 256 entries is 15% faster on PowerShell 7 when measured on its own and 4% + faster on Windows PowerShell 5.1. The public command surface, output + objects, descriptor semantics, and required privileges are unchanged, and + filesystem owner reads are unchanged because their cost is the + operating-system call rather than the module. See the + [measurement report](docs/performance-refactor-2026-09-10.md) for the + method, the artifact hashes, and the limits of each figure +- Keep exact access-control entry identity while comparing descriptors faster. + Removed-entry detection for Active Directory still reports one entry per + removed copy in its original order and still compares the complete binary + entry, so the deny-removal warning raised before a directory write keeps + its exact-match guarantee. Private-key DACL comparison keeps an ordered + desired-state check separate from unordered post-write verification + +### Fixed + +- Let `Get-ADObjectCallerEffectiveAccess` answer for a caller who cannot read + the object's security descriptor: resolving its targets no longer requests + `nTSecurityDescriptor`, which no output property uses. A command that does + need the descriptor now fails with an `UnauthorizedAccessException` naming + the likely missing read-control access instead of + `Cannot index into a null array` + ([specification](specs/0018-active-directory-caller-effective-access.md#output-contract)) +- Keep an SMB share description that someone edits while a share DACL write + is in flight: the write now reads the description immediately before the + native call instead of reusing the value captured at target resolution, + restores it only when the native write cleared it, and warns instead of + overwriting any other change. A description that cannot be read before + the write stops the command before anything is written, and one that + cannot be checked or restored after the DACL was written is reported as a + warning naming the earlier value instead of failing a change that is + already live + ([specification](specs/0009-smb-share-and-active-directory-dacl-management.md#smb-share-contract)) +- Accept a Task Scheduler DACL write that restores Local System to a folder or + task whose DACL has no Local System ACE; such repairs were refused even + though every current Local System ACE was preserved. A current DACL that + is missing or null is still refused, now with its own message + ([specification](specs/0010-task-scheduler-dacl-management.md#persistence-and-safety)) +- Stop the domain-lab acceptance runner from deleting directories it did not + create: it marks the payload root, its `package` staging directory, and + the module version directory it installs, refuses an existing one that is + unmarked or contains a junction or symbolic link before deleting anything, + rejects a payload root that is not a dedicated local directory on the + management domain controller, and stops when a remote step does not + confirm its directory + ([lab guide](tests/Lab/README.md#payload-and-module-ownership)) +- Return the already-verified CNG private-key descriptor bytes after a + successful DACL write, avoiding a redundant provider read that could report + failure after permissions had already changed + ([regression](tests/Unit/Private/WindowsCngKeyMutation.Tests.ps1)) +- Release the obsolete cleanup identity in the AD name-reuse acceptance test + after successful GUID-based deletion, so suite cleanup does not abort on + an already-absent object and leave subsequent fixtures behind + ([fixture regression](tests/Unit/Lab/ADObjectReplicationFixtureSafety.Tests.ps1)) +- Require selected PowerShell editions, explicit process exit status, and fresh + evidence in domain-lab acceptance; report coverage-finalization failures as + failed evidence and handle empty console output in Windows PowerShell 5.1 + ([audit](docs/test-gap-audit-2026-09-06.md)) +- Preserve existing module installations when DSC test setup refuses to + overwrite them, and restore the module search path even when cleanup fails + ([fixture safety](tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1)) + +### Security + +- Enforce Active Directory allowed-OU boundaries at real distinguished-name + components so escaped commas cannot impersonate an ancestor separator +- Preserve the recorded Active Directory object GUID through restore's write + path; direct descriptor writes can supply the same `ExpectedObjectGuid` + guard to refuse a reused distinguished name +- Match the complete native ACE during exact removal, preserving neighboring + conditional and object-specific ACEs with the same SID, mask, and flags + ([audit regressions](docs/test-gap-audit-2026-09-06.md#confirmed-findings)) + +## [0.2.0] - 2026-09-06 + +### Added + - Allow domain-lab acceptance runs to reuse the payload already on the management domain controller with `-SkipPayloadDeployment`. The existing `-SkipPayload` spelling remains an alias, and `-SkipDeployment` provides a @@ -57,15 +151,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 filter in the usage guide, which is how an operator separates the delegation they configured from the entries a schema class applies to every new object -- Add a migration guide for `NTFSSecurity` users, whose module is deprecated in - favor of this one. It maps every command that the NTFSSecurity 4.2.6 and - 5.0.0 manifests export to its replacement or to the decision that leaves it - out, and it calls out the differences that change what a migrated script - does: `Remove-NTFSAccessRule` matches exactly unless `-RemovalMode Rights` - is given, the remove, clear, and owner commands prompt for confirmation, and - a path longer than 260 characters needs PowerShell 7. The documentation - index had pointed NTFSSecurity users at the `NTFSPermission` rename map, - which never mentions NTFSSecurity - Document the twenty DSC resources in the wiki, and ship their conceptual help inside the module. The resources were the one part of the public surface a reader could not look up anywhere: the wiki carried a page per command and @@ -429,24 +514,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Speed up the shared descriptor paths that every object family reads through. - Measured on PowerShell 7, repeated rights rendering is 69% faster, - removed-entry detection over 256 access-control entries 66% faster, and - single-target batch dispatch 18% faster; on Windows PowerShell 5.1 - removed-entry detection is 95% faster. Private-key DACL comparison over - 256 entries is 15% faster on PowerShell 7 when measured on its own and 4% - faster on Windows PowerShell 5.1. The public command surface, output - objects, descriptor semantics, and required privileges are unchanged, and - filesystem owner reads are unchanged because their cost is the - operating-system call rather than the module. See the - [measurement report](docs/performance-refactor-2026-09-10.md) for the - method, the artifact hashes, and the limits of each figure -- Keep exact access-control entry identity while comparing descriptors faster. - Removed-entry detection for Active Directory still reports one entry per - removed copy in its original order and still compares the complete binary - entry, so the deny-removal warning raised before a directory write keeps - its exact-match guarantee. Private-key DACL comparison keeps an ordered - desired-state check separate from unordered post-write verification - Expand the comment-based help examples for `Add-ADObjectAccessRule` and its sibling mutators `Set-ADObjectAccessRule`, `Remove-ADObjectAccessRule`, and `Clear-ADObjectAccessRule`. Each command had carried only one or two basic @@ -609,51 +676,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Let `Get-ADObjectCallerEffectiveAccess` answer for a caller who cannot read - the object's security descriptor: resolving its targets no longer requests - `nTSecurityDescriptor`, which no output property uses. A command that does - need the descriptor now fails with an `UnauthorizedAccessException` naming - the likely missing read-control access instead of - `Cannot index into a null array` - ([specification](specs/0018-active-directory-caller-effective-access.md#output-contract)) -- Keep an SMB share description that someone edits while a share DACL write - is in flight: the write now reads the description immediately before the - native call instead of reusing the value captured at target resolution, - restores it only when the native write cleared it, and warns instead of - overwriting any other change. A description that cannot be read before - the write stops the command before anything is written, and one that - cannot be checked or restored after the DACL was written is reported as a - warning naming the earlier value instead of failing a change that is - already live - ([specification](specs/0009-smb-share-and-active-directory-dacl-management.md#smb-share-contract)) -- Accept a Task Scheduler DACL write that restores Local System to a folder or - task whose DACL has no Local System ACE; such repairs were refused even - though every current Local System ACE was preserved. A current DACL that - is missing or null is still refused, now with its own message - ([specification](specs/0010-task-scheduler-dacl-management.md#persistence-and-safety)) -- Stop the domain-lab acceptance runner from deleting directories it did not - create: it marks the payload root, its `package` staging directory, and - the module version directory it installs, refuses an existing one that is - unmarked or contains a junction or symbolic link before deleting anything, - rejects a payload root that is not a dedicated local directory on the - management domain controller, and stops when a remote step does not - confirm its directory - ([lab guide](tests/Lab/README.md#payload-and-module-ownership)) -- Return the already-verified CNG private-key descriptor bytes after a - successful DACL write, avoiding a redundant provider read that could report - failure after permissions had already changed - ([regression](tests/Unit/Private/WindowsCngKeyMutation.Tests.ps1)) -- Release the obsolete cleanup identity in the AD name-reuse acceptance test - after successful GUID-based deletion, so suite cleanup does not abort on - an already-absent object and leave subsequent fixtures behind - ([fixture regression](tests/Unit/Lab/ADObjectReplicationFixtureSafety.Tests.ps1)) -- Require selected PowerShell editions, explicit process exit status, and fresh - evidence in domain-lab acceptance; report coverage-finalization failures as - failed evidence and handle empty console output in Windows PowerShell 5.1 - ([audit](docs/test-gap-audit-2026-09-06.md)) -- Preserve existing module installations when DSC test setup refuses to - overwrite them, and restore the module search path even when cleanup fails - ([fixture safety](tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1)) - Run release publication on Ubuntu with the standard wiki publishing task, following the DSC Community pipeline pattern and avoiding the Windows large-initial-commit timeout; module packaging and tests remain on Windows @@ -852,14 +874,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- Enforce Active Directory allowed-OU boundaries at real distinguished-name - components so escaped commas cannot impersonate an ancestor separator -- Preserve the recorded Active Directory object GUID through restore's write - path; direct descriptor writes can supply the same `ExpectedObjectGuid` - guard to refuse a reused distinguished name -- Match the complete native ACE during exact removal, preserving neighboring - conditional and object-specific ACEs with the same SID, mask, and flags - ([audit regressions](docs/test-gap-audit-2026-09-06.md#confirmed-findings)) - Reject a Task Scheduler DACL write that newly denies an identity in the Task Scheduler service token the read, write, or run access the service requires - Reject object and compound ACEs in a Task Scheduler DACL, which the store From d55897ff5e044c6305bdb597cc4df1e5649ecb11 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 23:27:59 +0000 Subject: [PATCH 18/20] ci: stop the release when the changelog pull request is missing Sampler's Create_ChangeLog_GitHub_PR wraps everything it does after creating the changelog branch in a try/catch that writes the error to the build log and returns, so Invoke-Build still exits zero and the workflow step concludes successfully. The v0.2.0 release pushed updateChangelogAfterv0.2.0 at 21:00:25 UTC, inside a step that ran from 21:00:22 to 21:00:27 and was reported as successful, yet no pull request from that branch exists in any state. Everything the task does before the pull request call only writes repository contents and demonstrably worked, so the likeliest cause is a GitHubToken secret that grants contents but not pull requests. The publish job now queries the pull requests of the changelog branch after the task has run and ends the run when there is none, naming whether the branch itself reached the remote. It runs only for a stable release tag, because UpdateChangelogOnPrerelease is false and a pre-release build of main returns before the task creates a branch. A test in tests/Unit/Build holds the step, its position, its query, its token and branch inputs, and its condition, and it runs the step's own script against a mocked API: an empty answer has to end the release. Counting that answer with '@(Invoke-RestMethod ...)' yields one element instead of none and would confirm a pull request that does not exist, so the step counts through the pipeline and the test guards that. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 61 ++++++++ CHANGELOG.md | 9 ++ .../ChangelogPullRequestWorkflow.Tests.ps1 | 144 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 tests/Unit/Build/ChangelogPullRequestWorkflow.Tests.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bbd8178..2607ce2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -228,3 +228,64 @@ jobs: run: | $ErrorActionPreference = 'Stop' ./build.ps1 -Tasks Create_ChangeLog_GitHub_PR + + # Create_ChangeLog_GitHub_PR catches everything that happens after it + # creates the changelog branch and only writes the error to the build log, + # and Invoke-Build still exits zero. The v0.2.0 release pushed + # updateChangelogAfterv0.2.0, opened no pull request, and reported the step + # above as successful. Ask GitHub instead of trusting that conclusion. Only + # a stable release tag owes a pull request: UpdateChangelogOnPrerelease is + # false in build.yaml, so a pre-release build of main returns before the + # task creates a branch. + - name: Verify the changelog pull request exists + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + env: + GitHubToken: ${{ secrets.GitHubToken }} + Repository: ${{ github.repository }} + RepositoryOwner: ${{ github.repository_owner }} + ChangelogBranch: updateChangelogAfter${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + + $header = @{ + Accept = 'application/vnd.github+json' + Authorization = "Bearer $env:GitHubToken" + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $head = '{0}:{1}' -f $env:RepositoryOwner, $env:ChangelogBranch + $pullRequestUri = 'https://api.github.com/repos/{0}/pulls?state=all&head={1}' -f $env:Repository, $head + + try { + # Counted through the pipeline on purpose: '@(Invoke-RestMethod ...)' + # wraps the empty array of a no-match response into one element and + # would report a pull request that does not exist. + $pullRequest = @( + Invoke-RestMethod -Uri $pullRequestUri -Headers $header | + Where-Object -FilterScript { $null -ne $_.number } + ) + } + catch { + throw ("Could not read the pull requests of '{0}': {1}" -f $env:Repository, $_.Exception.Message) + } + + if ($pullRequest.Count -eq 0) { + $branchUri = 'https://api.github.com/repos/{0}/branches/{1}' -f $env:Repository, $env:ChangelogBranch + $branchState = 'was never pushed, so the task stopped before it opened one' + + try { + $null = Invoke-RestMethod -Uri $branchUri -Headers $header + $branchState = 'was pushed, so the task stopped at the pull request itself' + } + catch { + # A missing branch is one of the two reported states, not an error. + } + + throw ( + "No pull request exists from '{0}'. That branch {1}. The step above reports a refused pull request only as 'Error trying to create ChangeLog Pull Request' in its log. Confirm that the GitHubToken secret grants pull-request write access to '{2}'." -f + $env:ChangelogBranch, $branchState, $env:Repository + ) + } + + '::notice::Changelog pull request #{0} is {1} for {2}.' -f $pullRequest[0].number, $pullRequest[0].state, $env:ChangelogBranch diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c9d1b..a3c372a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- End a release that published without opening its changelog pull request. + Sampler's `Create_ChangeLog_GitHub_PR` reports a refused pull request only + as a line in the build log, so the v0.2.0 run pushed + `updateChangelogAfterv0.2.0`, opened no pull request, and still concluded + successfully, which is why the 0.2.0 section never reached `main`. The + publish job now asks the GitHub API for the pull request of that branch + after the task has run and stops the release when there is none, naming + whether the branch itself was pushed + ([workflow](.github/workflows/build.yml)) - Let `Get-ADObjectCallerEffectiveAccess` answer for a caller who cannot read the object's security descriptor: resolving its targets no longer requests `nTSecurityDescriptor`, which no output property uses. A command that does diff --git a/tests/Unit/Build/ChangelogPullRequestWorkflow.Tests.ps1 b/tests/Unit/Build/ChangelogPullRequestWorkflow.Tests.ps1 new file mode 100644 index 0000000..6d1d37e --- /dev/null +++ b/tests/Unit/Build/ChangelogPullRequestWorkflow.Tests.ps1 @@ -0,0 +1,144 @@ +BeforeAll { + $script:repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path + Import-Module -Name powershell-yaml -ErrorAction Stop + $script:workflow = Get-Content (Join-Path $script:repositoryRoot '.github/workflows/build.yml') -Raw | + ConvertFrom-Yaml + + $script:publishStep = @($script:workflow.jobs.publish.steps) + $script:sendStepName = 'Send changelog pull request' + $script:verifyStepName = 'Verify the changelog pull request exists' + $script:verifyStep = @( + $script:publishStep | Where-Object -FilterScript { $_.name -eq $script:verifyStepName } + ) + + # Kept separate so that a missing step reads as an unmet expectation in each + # test below instead of an index error. + $script:verifyStepDefinition = $script:verifyStep | Select-Object -First 1 +} + +Describe 'Changelog pull request verification' { + <# + Sampler's Create_ChangeLog_GitHub_PR wraps everything after it creates the + changelog branch in a try/catch that only writes the error to the build + log, and Invoke-Build still exits zero. The v0.2.0 release pushed + 'updateChangelogAfterv0.2.0', opened no pull request, and reported the + step as successful. The publish job therefore asks GitHub itself. + #> + It 'Should verify the pull request in the publish job' { + $script:verifyStep | Should -HaveCount 1 -Because ( + "the publish job must carry a step named '{0}'" -f $script:verifyStepName + ) + + $script:verifyStepDefinition.shell | Should -Be 'pwsh' + } + + It 'Should verify after the task that opens the pull request' { + $stepName = @($script:publishStep | ForEach-Object -Process { $_.name }) + $sendIndex = [System.Array]::IndexOf($stepName, $script:sendStepName) + $verifyIndex = [System.Array]::IndexOf($stepName, $script:verifyStepName) + + $sendIndex | Should -BeGreaterOrEqual 0 -Because ( + "the release still runs '{0}'" -f $script:sendStepName + ) + + $verifyIndex | Should -BeGreaterThan $sendIndex -Because ( + 'the pull request can only be verified once the task has run' + ) + } + + It 'Should ask the GitHub API for the pull request of the changelog branch' { + $runScript = $script:verifyStepDefinition.run + + $runScript | Should -Match '/pulls\?' -Because ( + 'the pull request list of that branch is the evidence' + ) + + $runScript | Should -Match 'state=all' -Because ( + 'a pull request that was opened and closed again still counts' + ) + + $runScript | Should -Match '\bthrow\b' -Because ( + 'a missing pull request must end the release instead of passing' + ) + } + + It 'Should read the API with the release token, for the branch of this tag' { + $script:verifyStepDefinition.env.Keys | Should -Contain 'GitHubToken' + + $script:verifyStepDefinition.env.GitHubToken | Should -Match 'secrets\.GitHubToken' -Because ( + 'the publish job already holds that secret' + ) + + $script:verifyStepDefinition.env.ChangelogBranch | Should -Match '^updateChangelogAfter' -Because ( + 'Sampler names the changelog branch after the released tag' + ) + + $script:verifyStepDefinition.env.ChangelogBranch | Should -Match 'github\.ref_name' -Because ( + 'the released tag is the one this run published' + ) + } + + <# + A build of main publishes a pre-release, and UpdateChangelogOnPrerelease + is false in build.yaml, so the task returns before it creates a branch. + Only a stable release tag owes a changelog pull request. + #> + It 'Should expect a pull request only for a release tag' { + $script:verifyStepDefinition.'if' | Should -Match 'refs/tags/' -Because ( + 'a pre-release build of main creates no changelog branch' + ) + } + + <# + This step is the release's only guard against a pull request that was + never opened, so its own logic is exercised and not just its text. GitHub + answers a head filter that matches nothing with an empty JSON array, + which Invoke-RestMethod writes as a single unenumerated object: counting + it with '@(Invoke-RestMethod ...)' yields one, and the guard would then + confirm a pull request that does not exist. + #> + Context 'When the verification script runs' { + BeforeAll { + $env:Repository = 'owner/repository' + $env:RepositoryOwner = 'owner' + $env:ChangelogBranch = 'updateChangelogAfterv9.9.9' + $env:GitHubToken = 'token-used-only-by-the-mock' + } + + AfterAll { + Remove-Item -Path Env:\Repository, Env:\RepositoryOwner, Env:\ChangelogBranch, + Env:\GitHubToken -ErrorAction SilentlyContinue + } + + It 'Should end the release when the API reports no pull request' { + $script:verifyStepDefinition.run | Should -Not -BeNullOrEmpty + + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Uri -match '/pulls\?' } -MockWith { + # The comma keeps the empty array unenumerated, which is how + # Invoke-RestMethod hands back an empty JSON array. + , @() + } + + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Uri -match '/branches/' } -MockWith { + [pscustomobject] @{ name = $env:ChangelogBranch } + } + + $verificationScript = [scriptblock]::Create([string] $script:verifyStepDefinition.run) + + { & $verificationScript } | + Should -Throw -ExpectedMessage "*No pull request exists from 'updateChangelogAfterv9.9.9'*" + } + + It 'Should pass the release when the API reports a pull request' { + $script:verifyStepDefinition.run | Should -Not -BeNullOrEmpty + + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Uri -match '/pulls\?' } -MockWith { + [pscustomobject] @{ number = 42; state = 'open' } + } + + $verificationScript = [scriptblock]::Create([string] $script:verifyStepDefinition.run) + + { & $verificationScript } | Should -Not -Throw + } + } +} From 023afe9d8aaf024bece3c2c96ab77d96aab78b3e Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 23:29:59 +0000 Subject: [PATCH 19/20] style(test): end two test files with a newline FIND-002 of the 2026-09-07 security review. Each file ended with '}' and no line terminator; both now end with the CRLF that .gitattributes declares for *.ps1, and nothing else in either file changed. Their suites still pass in PowerShell 7 and Windows PowerShell 5.1. The review document now records the resolution beside the one it already carried for FIND-001. The Memory Bank records what handoff 06 changed and points at handoff 07, which needs the Hyper-V host that holds the lab; this machine has Hyper-V and AutomatedLab but no lab virtual machines. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 49 +++++++++++++------ .memory-bank/progress.md | 21 +++++--- docs/security-review-2026-09-07.md | 8 ++- tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1 | 2 +- ...owsADDistinguishedNameWithinBase.Tests.ps1 | 2 +- 5 files changed, 56 insertions(+), 26 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 12ee922..f940b06 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,19 +9,33 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Handoff 05 of the 2026-10-06 sequence is complete on the local branch -`ai/post-release-fixes`. The build workflow now pins `actions/checkout` -v7.0.1, `actions/upload-artifact` v7.0.1, and `actions/download-artifact` -v8.0.1, each by the 40-character SHA that its own tag resolves to, which ends -the Node.js 20 deprecation warning. The release notes from v4 to each target -were read and checked one by one against this workflow: no input changed -meaning here, so the commit is seven pins and nothing else. The notable -difference is that `download-artifact` v8 fails a job on a digest mismatch -instead of warning, which is kept. A new test in `tests/Unit/Build` holds +Handoff 06 of the 2026-10-06 sequence is complete on the local branch +`ai/post-release-fixes`. `CHANGELOG.md` carries the `## [0.2.0] - 2026-09-06` +section that the release pipeline never merged, in the heading format of the +unmerged branch, with every entry written since still under +`## [Unreleased]`; a multiset comparison proves that no content line moved +out or appeared twice. The release no longer reports a changelog pull request +it never opened: Sampler's `Create_ChangeLog_GitHub_PR` catches a refused +creation and only writes it to the build log, so the publish job now asks the +GitHub API for the pull request of the changelog branch after the task and +ends the run when there is none. The likeliest cause of the v0.2.0 failure is +a `GitHubToken` secret that grants contents but not pull requests; the step +log of run `34058863590` would settle it and needs an authenticated download +by the user. FIND-002 is closed: both test files end with CRLF, and the +security review records the disposition. Next is handoff 07, which needs the +Hyper-V host that holds `WindowsAccessControlLab`; this machine runs Hyper-V +with AutomatedLab but holds no lab virtual machines. No git remote was +mutated. + +Handoff 05 pinned `actions/checkout` 7.0.1, `actions/upload-artifact` 7.0.1, +and `actions/download-artifact` 8.0.1 by the SHA each tag resolves to, which +ends the Node.js 20 deprecation warning. Every release note from v4 to each +target was checked against this workflow and no input changed meaning, so the +commit is seven pins; `download-artifact` v8 now fails a job on a digest +mismatch instead of warning, which is kept. A `tests/Unit/Build` test holds every `uses:` reference to a SHA pin with a version comment and to a release line that declares Node.js 24. The three Dependabot pull requests are superseded: the seven changed lines are byte-identical to their proposals. -Next is handoff 06. No git remote was mutated. Handoff 04 settled the NTFSSecurity positioning that the user confirmed: `WindowsAccessControl` is the successor of NTFSSecurity, which is deprecated @@ -135,12 +149,15 @@ and cleanup. Only 08 touches the remote, with approval per action. report nothing when no identical entry exists, which a migrated NTFSSecurity call that subtracts rights hits. The guide documents it; no behavior changed. -5. **Changelog.** The v0.2.0 run reported "Send changelog pull request" as - successful, yet the repository has no closed pull request. Branch - `updateChangelogAfterv0.2.0` (`38cdabd`) inserts `## [0.2.0]` directly - below `[Unreleased]`, which would now mislabel seven post-0.2.0 entries; - do not merge it as-is. Find the cause before the next stable release. -6. **FIND-002**: two test files still lack a final newline. +5. **Changelog, closed by handoff 06.** `CHANGELOG.md` carries the 0.2.0 + section, and the publish job now ends a release whose changelog pull + request is missing. Branch `updateChangelogAfterv0.2.0` (`38cdabd`) is + superseded and must not be merged; handoff 08 deletes it with approval. + Left for the user: confirm that the `GitHubToken` secret grants + pull-request write access, and download the step log of run `34058863590` + if the cause needs proof rather than the best-supported hypothesis. +6. **FIND-002, closed by handoff 06**: both test files end with CRLF, and + `docs/security-review-2026-09-07.md` records the disposition. 7. **Housekeeping**: remote `ai/test-gap-audit` is merged. The thirteen lab VMs, their checkpoints, and the administrator `%TEMP%` evidence are not visible from this session's account; confirm before keeping or removing diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index f89b3b2..bbaf7cf 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -12,16 +12,25 @@ source: repository evidence `v0.3.0-preview0001` shipped from `40e364a` on 2026-09-07 with the accepted FIND-001 correction; `v0.2.0` remains the stable release. A 2026-10-06 triage found unmerged and untracked work and split it into eight handoffs on local -`ai/post-release-fixes`. Handoffs 01 to 05 fixed the lab-runner Blocker, the +`ai/post-release-fixes`. Handoffs 01 to 06 fixed the lab-runner Blocker, the Task Scheduler, SMB, and AD review defects, ported and re-measured the -performance refactor, wrote the NTFSSecurity migration guide, and moved the -build workflow to the Node 24 releases of its three pinned actions there; the -unposted issue #4 reply, an uncreated v0.2.0 changelog pull request, and the -FIND-002 newline nit remain. `activeContext.md` holds the evidence and the -order. +performance refactor, wrote the NTFSSecurity migration guide, moved the build +workflow to the Node 24 releases of its three pinned actions, restored the +0.2.0 changelog section, and closed FIND-002; the unposted issue #4 reply, +live lab acceptance, and every remote action wait for 07 and 08. +`activeContext.md` holds the evidence and the order. ## Recent milestones +- 2026-10-06: Handoff 06 restored the `## [0.2.0] - 2026-09-06` section that + the release never merged, with a multiset proof that no content line + moved, and made a missing changelog pull request end the release: + Sampler's task catches a refused creation and only logs it, which is why + the green v0.2.0 step left no pull request behind. The publish job now + asks the API for that branch's pull request; a token without pull-request + access is the likeliest cause, and the run log would settle it. FIND-002 + is closed, and QA, Build, and the two suites pass 709 tests per edition. + - 2026-10-06: Handoff 05 moved the build workflow to the Node 24 releases of `checkout` 7.0.1, `upload-artifact` 7.0.1, and `download-artifact` 8.0.1, each pinned by the SHA its own tag resolves to. Every release note from v4 diff --git a/docs/security-review-2026-09-07.md b/docs/security-review-2026-09-07.md index 1118073..ac38320 100644 --- a/docs/security-review-2026-09-07.md +++ b/docs/security-review-2026-09-07.md @@ -55,8 +55,12 @@ and Byte inspection confirms that both files lack a final newline. This violates the text-file convention but does not change test behavior. -Disposition: accepted, deferred as non-blocking formatting cleanup. No test -file was modified during the review. +Disposition: accepted during the review, deferred as non-blocking formatting +cleanup; no test file was modified then. Resolved on 2026-10-06: each file now +ends with the CRLF that `.gitattributes` declares for `*.ps1`, which is the +only change either file received. Byte inspection confirms `0D 0A` as the final +two bytes, and both suites still pass in PowerShell 7 and Windows PowerShell +5.1. ## Scope and assessment diff --git a/tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1 b/tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1 index 0fb456b..4a82a6b 100644 --- a/tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1 +++ b/tests/Unit/DSC/DscLcmFixtureSafety.Tests.ps1 @@ -95,4 +95,4 @@ Describe 'DSC LCM integration fixture ownership' -Tag 'Unit', 'WindowsOnly' { $env:PSModulePath = $originalModulePath } } -} \ No newline at end of file +} diff --git a/tests/Unit/Private/Test-WindowsADDistinguishedNameWithinBase.Tests.ps1 b/tests/Unit/Private/Test-WindowsADDistinguishedNameWithinBase.Tests.ps1 index 21190a8..c503446 100644 --- a/tests/Unit/Private/Test-WindowsADDistinguishedNameWithinBase.Tests.ps1 +++ b/tests/Unit/Private/Test-WindowsADDistinguishedNameWithinBase.Tests.ps1 @@ -69,4 +69,4 @@ Describe 'Active Directory distinguished-name containment' -Tag 'Unit', 'Windows Test-WindowsADDistinguishedNameWithinBase @parameters | Should -Be $Expected } } -} \ No newline at end of file +} From 89d9c2e0142b530624442e54bd8ac69566a71c8b Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 6 Oct 2026 23:55:49 +0000 Subject: [PATCH 20/20] docs(memory-bank): record the blocked lab acceptance and the prepared publication The lab host is not on this machine, so the live acceptance of the combined candidate is still owed before the next stable release. The publication steps are prepared up to the first remote action, and each of them waits for the user. Both full gates pass on 023afe9. Co-authored-by: AI Assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .memory-bank/activeContext.md | 41 ++++++++++++++++++++--------------- .memory-bank/progress.md | 27 +++++++++++++---------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index f940b06..4a30140 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,23 +9,30 @@ source: repository, git reflog, and public GitHub API evidence ## Current task -Handoff 06 of the 2026-10-06 sequence is complete on the local branch -`ai/post-release-fixes`. `CHANGELOG.md` carries the `## [0.2.0] - 2026-09-06` -section that the release pipeline never merged, in the heading format of the -unmerged branch, with every entry written since still under -`## [Unreleased]`; a multiset comparison proves that no content line moved -out or appeared twice. The release no longer reports a changelog pull request -it never opened: Sampler's `Create_ChangeLog_GitHub_PR` catches a refused -creation and only writes it to the build log, so the publish job now asks the -GitHub API for the pull request of the changelog branch after the task and -ends the run when there is none. The likeliest cause of the v0.2.0 failure is -a `GitHubToken` secret that grants contents but not pull requests; the step -log of run `34058863590` would settle it and needs an authenticated download -by the user. FIND-002 is closed: both test files end with CRLF, and the -security review records the disposition. Next is handoff 07, which needs the -Hyper-V host that holds `WindowsAccessControlLab`; this machine runs Hyper-V -with AutomatedLab but holds no lab virtual machines. No git remote was -mutated. +The 2026-10-06 handoff sequence has run as far as it can without the user, +who delegated its decisions overnight; every agent decision is in the +handoff ledger for review. Handoff 07 is Blocked: this machine runs Hyper-V +and AutomatedLab but holds no lab VMs and no lab store, so live acceptance of +the combined candidate waits for the lab host and should precede the next +stable release. Handoff 08 is prepared and stopped before its first remote +action. Its pull request body follows `.github/PULL_REQUEST_TEMPLATE.md`, +which the prompt wrongly said did not exist, and its report lists every +remaining remote step with the exact command. GitVersion 5.12.0 on a +simulated merge reports `0.3.0-preview0002`. On `023afe9` both full gates +pass: Core 1,931 passed, 0 failed, 2 skipped at 83.11 percent asserted +coverage; Desktop 1,886 passed, 0 failed, 2 skipped at 80.95 percent. +`ai/test-gap-audit` no longer exists on the remote. Next, the user approves +or declines each remote step of 08, works through the 06 settings checklist, +and runs 07 on the lab host. No git remote was mutated. + +Handoff 06 restored the `## [0.2.0] - 2026-09-06` changelog section that the +pipeline never merged, with every later entry still under `[Unreleased]` and +a multiset proof that no line moved, and made a stable release end with an +error when its changelog pull request is missing, because Sampler's +`Create_ChangeLog_GitHub_PR` only logs a refused creation. The likeliest +cause of the v0.2.0 gap is a `GitHubToken` secret without pull-request scope; +the step log of run `34058863590` would settle it and needs an authenticated +download by the user. FIND-002 is closed. Handoff 05 pinned `actions/checkout` 7.0.1, `actions/upload-artifact` 7.0.1, and `actions/download-artifact` 8.0.1 by the SHA each tag resolves to, which diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index bbaf7cf..2fb91dc 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -16,12 +16,19 @@ found unmerged and untracked work and split it into eight handoffs on local Task Scheduler, SMB, and AD review defects, ported and re-measured the performance refactor, wrote the NTFSSecurity migration guide, moved the build workflow to the Node 24 releases of its three pinned actions, restored the -0.2.0 changelog section, and closed FIND-002; the unposted issue #4 reply, -live lab acceptance, and every remote action wait for 07 and 08. -`activeContext.md` holds the evidence and the order. +0.2.0 changelog section, and closed FIND-002. Handoff 07 is Blocked for want +of the lab host, and handoff 08 waits for the user's approval of each remote +action. `activeContext.md` holds the evidence and the order. ## Recent milestones +- 2026-10-06: With the user's overnight delegation, handoff 07 was recorded + as Blocked (no lab VMs on this machine) and handoff 08 was prepared up to + its first remote action: a pull request body on the repository template, + and a simulated merge that GitVersion 5.12.0 versions as + `0.3.0-preview0002`. Both full gates pass on `023afe9`: Core 1,931 tests + at 83.11 percent asserted coverage, Desktop 1,886 at 80.95 percent. + - 2026-10-06: Handoff 06 restored the `## [0.2.0] - 2026-09-06` section that the release never merged, with a multiset proof that no content line moved, and made a missing changelog pull request end the release: @@ -153,17 +160,13 @@ live lab acceptance, and every remote action wait for 07 and 08. monitoring stopped. The dated lab acceptance report retains candidate hashes and the failure, recovery, and successful-run evidence. -- 2026-09-07: User requested a handoff commit and push on `ai/test-gap-audit` - after creating the remote branch. Reuse the recorded audit validation; - no runtime code changes were made in this handoff. Keep the two blocked - Desktop DSC checks, live acceptance, and independent review as open gates. - Generated packages and raw logs remain excluded from the Git transfer. - -- 2026-09-03 to 2026-09-06: OI-31, the 105-command audit, the audit-gap +- 2026-09-03 to 2026-09-07: OI-31, the 105-command audit, the audit-gap closure, the `6f7ba15` close-out, the `34026468199` wiki-publication hang (DscResource.DocGenerator#111), the `34021812398` completer fix, the - `ai/access-rights-completion` review, the script-inventory audit, and the - wiki-publisher investigation that run `34055979655` closed are in + `ai/access-rights-completion` review, the script-inventory audit, the + wiki-publisher investigation that run `34055979655` closed, and the + `ai/test-gap-audit` handoff push are in + `git show 023afe9:.memory-bank/progress.md`, `git show 4461b90:.memory-bank/progress.md`, `git show 8b67058:.memory-bank/progress.md`, and, for the oldest, `git show a48ec3d:.memory-bank/progress.md`. Lessons kept: record