All notable changes to ADPermissionsAnalyzer are documented here.
The format is based on Keep a Changelog.
- ACE records streamed to disk between Phase 3 and Phase 4 (
scripts/Invoke-ADPermissionAnalysis.ps1,scripts/lib/Phase{3,4,5,6}-*.ps1, ADR-030). The in-memory$aceRecordsList[PSObject]populated by Phase 3 and consumed sequentially by Phases 4/5/6 has been replaced with a disk-backed CLIXML batch file. Each completed runspace pipeline now appends its parsed ACE records toOutputDirectory/Phase3-AceRecords_$timestamp.clixmlvia the newWrite-AceBatchToStreamhelper and drops the in-memory reference. Phase 4 (Get-DistinctTrusteeSetFromStream), Phase 5 (New-AceIndexFromStream+Resolve-InheritanceSourceStream— two-pass index/rewriter writingPhase5-AceRecords_$timestamp.clixml), and Phase 6 (Write-DetailCsv -AceRecordsPath) consume the file via the matchingRead-AceStreamiterator. The Phase 5 index drops the full ACE PSObject payload and keeps only the two fieldsTest-InheritanceFlagsPropagateTodecodes (AceFlagsRaw,InheritedObjectTypeName), reducing the index from multi-GB to hundreds of MB at the 50k-object scale. Format choice: CLIXML per batch delimited by<!--===BATCH===-->so[guid],[byte],[uint32],[bool]round-trip without re-casting on every read. Cleanup: Phase 3 file deleted at end of Phase 5; Phase 5 file deleted in the entry script'sfinallyblock on success ($exitCode -ne 1) and kept on fatal failure for diagnostics. Resolves BUG-001 ([[bugs#BUG-001]]) — Phase C measured a 30.9 GB managed-heap ceiling at Phase 6 PhaseEnd on a 50,559-object domain; the streaming path projects a 3–5 GB ceiling.
- Per-phase memory instrumentation (
scripts/Invoke-ADPermissionAnalysis.ps1, ADR-029). NewGet-RuntimeMemorySnapshothelper returnsmanagedHeapMB,workingSetMB,privateBytesMB(1dp MB), plusgen0Collections,gen1Collections,gen2Collections. Embedded in thedata.memoryfield of everyPhaseStart/PhaseEndevent (Phases 1–6, 12 boundaries), every Phase 2EnumerationProgressevent, and newMidEnumerationDrainevents emitted on productive drain passes (≥1 handle freed). UsesGC.GetTotalMemory($false)— no forced collection, no behaviour change. Produces a runtime memory time-series in the JSONL log so the next live run against the work-computer 30k-object domain (which hit an 8+ GB working-set ceiling even after the Phase A drain/Item-pinning fixes) can be diagnosed rather than speculate-fixed.
- Phase 4 distinct-trustee streaming reader flattens array-shaped
TrusteeSid(scripts/lib/Phase4-TrusteeResolution.ps1, BUG-003).Get-DistinctTrusteeSetFromStreamassigned$ace.TrusteeSidraw to[HashSet[string]]::Add. When the column was contaminated upstream as anObject[](e.g. a multi-valuedTrusteeSidreaching Phase 4 from a malformed batch), PowerShell coerced the array to a single string via$OFS = ' 'and planted a space-joined"SID SID SID …"compound key into the set. That compound "trustee" then propagated as a single SID throughResolve-TrusteeSidand the OrphanSid emission, producing the 4–53 KB-per-event Message + Data.sid bloat captured in the Phase C diagnostic (845 events, ~57 MB JSONL — ~100× the expected size). Replaced the scalar assignment withforeach ($sid in $ace.TrusteeSid)so a scalar string contributes once,$nullcontributes zero, and a contaminated array contributes one clean SID per element. Pester regression case inTests/Phase4-TrusteeResolution.Tests.ps1asserts: feed a record whoseTrusteeSidis[Object[]] @('S-1-5-18', 'S-1-5-11', 'S-1-1-0')and the returned set has 3 individual SIDs with the space-joined compound key absent. Side-benefit: the operator's sanitization regex now matches every domain-RID SID in the log because each event carries one SID, not thousands. - Phase 3
[uint32]cast on negative-Int32 AccessMask (scripts/lib/Phase3-AceParsing.ps1, BUG-002).ConvertFrom-AdAcecast the rule's rights mask via[uint32] [int] $rights, which throwsCannot convert value "-1" to type "System.UInt32"whenever an ACE carries an AccessMask whose Int32 representation is negative (e.g.0xFFFFFFFF→ -1). The Phase C diagnostic ([[bugs#BUG-002]]) surfaced 75 such failures in a 2-second window at the Phase 3 final-drain boundary on a 50,559-object domain. Mask via Int64 to preserve the unsigned 32-bit bit pattern ([uint32] (([long] [int] $rights) -band 0xFFFFFFFFL)). Added a Pester regression case underDescribe 'ConvertFrom-AdAce'that forges a rule withAccessMask = 0xFFFFFFFFvia reflection (PowerShell's[ActiveDirectoryRights] -1coercion refuses out-of-enum values) and asserts the decoded record carries[uint32]::MaxValue. - PS 7.5.4 lazy-compile SDP type resolution (
scripts/lib/Phase{1,2,4}-*.ps1,scripts/Invoke-ADPermissionAnalysis.ps1). On PS 7.5.x dot-sourced function bodies are compiled lazily at first invocation; by then the parent file'susing namespace System.DirectoryServices.Protocolsdirective's scope has been lost and short SDP type names (SearchRequest,LdapConnection,AuthType,SearchScope,SecurityMasks,SecurityDescriptorFlagControl, etc.) fail withTypeNotFound. Replaced every[ShortName]SDP type reference with[System.DirectoryServices.Protocols.ShortName](18 sites across Phase 1/2/4 lib files plus 3 sites in the entry script body) and dropped theusing namespace System.DirectoryServices.Protocolsdirective from those files. Non-SDP namespaces (System.Collections.Generic,System.Security.Principal,System.Text,System.IO) keep theirusing namespacedirectives. Add-Type -AssemblyName System.DirectoryServices.Protocols(scripts/Invoke-ADPermissionAnalysis.ps1). Inserted between the parameter block and the lib dot-source block.using assemblydoes not reliably load the SDP assembly into the AppDomain before the dot-sourced lib files reference its types;Add-Typeis synchronous and idempotent.Connect-AdLdapLDAP session options (scripts/lib/Phase1-DiscoveryAndMaps.ps1). Three required fixes that surfaced on the first live-AD run:LdapDirectoryIdentifierconnectionlessflag flipped from$true(UDP) to$false(TCP). UDP cannot carry paged-result controls or binary attribute retrieval.SessionOptions.ProtocolVersion = 3. LDAPv2 (the default) does not support the paged-result control orSecurityDescriptorFlagControl.SessionOptions.ReferralChasing = None(was defaultAll). WithAll, the DC chased subordinate referrals intoDomainDnsZones/ForestDnsZonespartitions, inflated page-1 to ~5000 entries (expected ~1000), and corrupted the paging continuation cookie so page-2 threwLdapException.
SearchRequestattribute double-wrapping (scripts/lib/Phase1-DiscoveryAndMaps.ps1,Read-LdapEntryandGet-ADNamingContext). PowerShell binds a[string[]]argument to the constructor'sparams string[]overload, double-wrapping the array and producing a malformed attribute list. Pass$nullto the constructor and call[void] $request.Attributes.AddRange($Attributes)after.- Read-LdapEntry per-page SearchRequest rebuild (
scripts/lib/Phase1-DiscoveryAndMaps.ps1). On .NET 9 / PS 7.5.4, mutating thePageResultRequestControlcookie on a reusedSearchRequesttriggers BER re-encoding issues that corrupt the next page. Build a freshSearchRequesteach iteration and carry the cookie in a local variable. Negligible allocation cost (~1 per LDAP page, default 1000 entries/page). - Attribute-value extraction casts in Read-LdapEntry (
scripts/lib/Phase1-DiscoveryAndMaps.ps1). The if-expression form let the runtime infer attribute-value types from the first call site, producing unexpected element types downstream. Switched to anif/elseblock with explicit[object[]]and[string[]]casts per branch. - Item-pinning in
Submit-RunspaceWorkItem(scripts/lib/Phase3-AceParsing.ps1). Each runspace handle was carrying back its input batch (~250 objects with binarynTSecurityDescriptorblobs) on anItemfield that no consumer reads. Removed the field from the returned[PSCustomObject]so completed runspace pipelines can release their batch references for GC immediately. - Mid-enumeration runspace drain (
scripts/Invoke-ADPermissionAnalysis.ps1). Previously the Phase 2 enumeration loop submitted every batch and accumulated all handles in$handleswithout draining until enumeration finished, then bulk-collected results into a fresh$aceRecords. At ~30k objects this kept hundreds of completed-but-undisposed pipeline objects alive and built the entire 900k-row ACE list in one allocation burst (observed 8+ GB working set). The loop now incrementally drains: after each submit, if$handles.Count >= max(ThreadCount*2, 8)it scans backwards, EndInvokes completed handles, appends to a pre-allocated$aceRecords, captures per-handle errors asBatchErrorrecords, and disposes the pipeline. Phase 2EnumerationProgressJSONL events now carrypendingDrainsandacesCollected;Write-ProgressCurrentOperationsurfaces the same so the operator sees memory pressure live. Phase 3 final-drain replaced with a conditional that drains only the stragglers. Drain logic is sufficient to unblock the live run but did not eliminate the memory ceiling — instrumentation for the remaining bottleneck is planned in a follow-up commit.
All six categories above were applied during a debugging pass on the work computer running PS 7.5.4 / .NET 9 on Windows Server. The detailed change log lives in docs/session-changes-2025-05-15.md.
docs/session-changes-2025-05-15.md— authoritative reference for the six fix categories above.LICENSE— MIT.SECURITY.md— vulnerability disclosure policy (GitHub Security Advisories preferred, fallback email).CONTRIBUTING.md— trunk-based branching model, local setup, style rules, PR checklist.CODE_OF_CONDUCT.md— adopts Contributor Covenant 2.1 by reference..github/ISSUE_TEMPLATE/{bug,feature,config}.ymland.github/pull_request_template.md.
README.md— full public-facing rewrite. Replaces template placeholders with project description, prerequisites, quick-start, output schema overview, and links to runbook/spec/changelog.mkdocs.yml— fixed<repo-name>placeholder inrepo_url/repo_name.CLAUDE.md— prepended external-readers disclaimer; updated branching reference from "Dev branch + PR workflow" to "Trunk-based: branch per PR frommain, delete after merge."- Branching model — repo switches from permanent
devbranch withdev → mainPRs to trunk-based with short-lived branches offmain. .github/workflows/pre-commit-update.yml— checkout target changed fromdevtomainso the weekly autoupdate PR is based on the current trunk.
devbranch and.github/workflows/sync-dev.yml. Final step of the trunk-based migration recorded above. The sync workflow mergedmainintodevon every push tomain; withdevretired it has no purpose.origin/devwas verified fully merged intoorigin/mainbefore deletion (git log origin/dev --not origin/mainempty). Older commit messages still referencedevfor historical context (see the note inCONTRIBUTING.md).
v0.1.0 hardening release. Driven by a fresh-context code review run between v0.1.0 and the first work-environment test. Closes out correctness, security, performance, ergonomics, and test-coverage findings before the first live-LDAP run. Coverage moves from 91.22% to 93.22% (gated at 86% per ADR-026).
- Pre-flight checks (ADR-027). Two guards inserted between Phase 1 and Phase 2 in
scripts/Invoke-ADPermissionAnalysis.ps1(PreFlightFailedERROR events withreason = NullOrEmptySecurityDescriptor/NoNamingContextsMatched):nTSecurityDescriptorreadability probe: a single Base-scope read on the Domain NC root withSecurityDescriptorFlagControl(Owner | Dacl). Aborts before Phase 2 if the running account cannot read DACLs, eliminating the v0.1.0 "silent false success" risk where an under-privileged run produced zero-row CSVs with exit code 0.-IncludeNamingContextsnon-empty validation: aborts on a typo or empty match (data block includes both requested and discovered NC types for diagnosis).
Submit-RunspaceWorkItem+Receive-RunspaceHandle(scripts/lib/Phase3-AceParsing.ps1). Split out fromInvoke-RunspacePoolWorkso the orchestrator can dispatch Phase 2 batches into the runspace pool the moment they arrive (plan §12 interleaved pipeline), then drain at the end. Removes the inlineBeginInvoke/EndInvokeloop from the entry script.Receive-RunspaceHandlealso captures non-terminatingStreams.Errorrecords on each pipeline (previously dropped).- CrossDomain trustee classification (
scripts/lib/Phase4-TrusteeResolution.ps1). NewPrincipalTypevalue for trustees whose SID translates via LSA but does not exist in the local directory (trusted-forest / external principals). Distinct fromWellKnown(built-in / NT AUTHORITY) andOrphaned(no Translate). Expand-GroupTransitivetruncation warning (scripts/lib/Phase4-TrusteeResolution.ps1:608). Emits aWrite-Warningwhen theMaxMemberscap is hit, so operators know the expansion was incomplete (previously silent).Get-PageResultControl(scripts/lib/Phase1-DiscoveryAndMaps.ps1). Extracted fromRead-LdapEntry's paging loop so the cookie continuation can be unit-tested with a duck-typed fake response.- Tests (
Tests/Phase{1,3,4,5,6}-*.Tests.ps1): six new unit cases.- Phase 3 —
Invoke-AceParsingWorkUnitagainst a row withNTSecurityDescriptor = $nullemits aPARSE_ERRORplaceholder. - Phase 1 —
Read-LdapEntrypaging continuation across two pages (mockedGet-PageResultControl). - Phase 4 —
Expand-GroupTransitivecaps at-MaxMembersand emits the warning. - Phase 4 —
Resolve-TrusteeSidclassifies CrossDomain. - Phase 5 —
Resolve-InheritanceSourceparent walk stops at the Configuration NC root and does not cross into the Domain NC. - Phase 6 —
Write-DetailCsvProgressCallbackfires every-ProgressIntervalrows with the expected counter.
- Phase 3 —
- Phase 6 row-build inlined (ADR-028).
ConvertTo-DetailRowdeleted; its body moved directly intoWrite-DetailCsv'sforeach ($tuple in $tuples)loop with per-ACE caching of invariant fields and a reused[string[]]row buffer. Removes the per-row hashtable + values-array + escaped-array allocation triple at the ~5M-row design ceiling.New-CsvFieldEscaperis retained exactly per ADR-019. Phase 6 tests reworked to driveWrite-DetailCsvend-to-end viaImport-Csv. - Pivot CSV ships with UTF-8 BOM (ADR-018 amended).
scripts/lib/Phase6-Output.ps1:914switched to[UTF8Encoding]::new($true)so Excel on Windows renders non-ASCII DNs and trustee names correctly without a manual import step. Detail CSV remains BOM-less for batch consumers (Power BI, pandas, SIEM). - Phase 5 anomaly EventName renamed (ADR-016 amended).
InheritedAceOnProtectedDacllog events useEventName = 'InheritedAceOnProtectedDacl'(was'BatchError') so they're distinct from Phase 3 runspace-batch failures in log analysis. Thereasondata field is preserved for back-compat. Invoke-RunspacePoolWorknow delegates internally toSubmit-RunspaceWorkItem+Receive-RunspaceHandle. Public signature unchanged; existing tests pass without modification.- Phase 3 inline drain replaced with
Receive-RunspaceHandlecall inscripts/Invoke-ADPermissionAnalysis.ps1. The orchestrator iterates new ErrorBag entries post-drain to fan them out toWrite-LogEventwith Phase 3 metadata. - Test style — three lingering
ForEach-Objectinvocations replaced with.ForEach({})or pipe form (Tests/Phase3-*.ps1,Tests/Phase4-*.ps1). Read-LdapEntrydrops its[SearchResponse]cast on the response fromSendRequestso duck-typed fakes work; production type narrowing is now implicit in the property access pattern.
- Domain SID removed from Phase 4 PhaseEnd log event (
scripts/Invoke-ADPermissionAnalysis.ps1). The JSONL log no longer carries a real domain identifier — closes a_meta/security.mdpolicy violation surfaced by the v0.1.0 review. [int]→[long]elapsed-ms casts (9 sites across orchestrator +Phase6-Output.ps1). Prevents negative elapsed-time values in JSONL logs for phases running longer than ~24 days (the[int32]rollover boundary in milliseconds).$script:LogWriter.AutoFlush = $false(scripts/Invoke-ADPermissionAnalysis.ps1:251). Removes per-event syscall cost on slow storage; the finally block already flushes + disposes on exit.ThreadCountdoc / range alignment (scripts/Invoke-ADPermissionAnalysis.ps1:58-59). Docstring now explicitly states the 1-32 accepted range alongside the practical 8-16 sweet spot.
First release. Implements the six-phase orchestration from plan §18 steps 1–8 (steps 9–10 cancelled by ADR-025: no lab DC available). Ships without live-LDAP smoke validation; first operational run is exploratory. Correctness rests on 144 Pester unit cases across scripts/lib/ (91.22% command coverage, gated at 86% per ADR-026).
-
scripts/Invoke-ADPermissionAnalysis.ps1— entry-point script skeleton (plan §18.1): full parameter surface, JSONL logging primitive (Write-LogEvent), and top-level execution flow with deterministic exit codes (0 / 1 / 2). Phase bodies are stubbed pending §18.2-§18.8. -
scripts/lib/Phase1-DiscoveryAndMaps.ps1— Phase 1 helpers (plan §18.2):Connect-AdLdap,Read-LdapEntry,Invoke-PagedLdapSearch,Get-NamingContextType,Get-ADNamingContext,New-ADExtendedRightsMap,New-ADSchemaGuidMap,New-PropertySetMembersMap,New-WellKnownSidMap. Dot-sourced from the entry script. -
Phase 1 wired into
Invoke-ADPermissionAnalysis.ps1: binds anLdapConnection, enumerates naming contexts, and builds the four maps, emittingPhaseStart,NamingContextDiscovered,MapBuilt, andPhaseEndevents (plan §13). -
Tests/Phase1-DiscoveryAndMaps.Tests.ps1— Pester suite (21 cases) covering the well-known SID map, NC categorisation, and the three LDAP-backed map builders mocked at theInvoke-PagedLdapSearchboundary. -
scripts/lib/Phase2-Enumeration.ps1— Phase 2 helper (plan §18.3):Get-ADObjectAclBatchperforms a paged subtree search withSecurityDescriptorFlagControl(OWNER | DACL)attached, yielding[List[PSObject]]batches of(DistinguishedName, StructuralObjectClass, ObjectGUID, NTSecurityDescriptor)for downstream Phase 3 consumption.structuralObjectClassfalls back to the lastobjectClassvalue when unset. -
Phase 2 wired into
Invoke-ADPermissionAnalysis.ps1: filters$namingContextsby-IncludeNamingContexts, iterates batches per NC, emitsPhaseStart/EnumerationProgress(every ~5000 objects) /NamingContextComplete/EmptyNamingContext/PhaseEndevents plusWrite-Progressticks. -
Tests/Phase2-Enumeration.Tests.ps1— Pester suite (12 unit cases plus one skipped integration case gated on$env:AD_PERM_ANALYZER_INTEGRATION) covering empty-NC short-circuit, batching invariants, attribute extraction (byte[]SD passthrough, GUID conversion, structuralObjectClass fallback, DN preservation), and LDAP request shape (control mask, binary attributes, scope/filter). -
scripts/lib/Phase3-AceParsing.ps1— Phase 3 helpers (plan §18.4):ConvertFrom-NtSecurityDescriptor(Owner + DACL + IsDaclProtected fromActiveDirectorySecurity.AreAccessRulesProtected),Add-OwnerAce(synthetic Owner row withAceIndex = -1,RightsDecoded = 'OwnerImplicit',AccessMask = 0xE0000),ConvertFrom-AdAce(rights ToString comma-decompose, ObjectTypeKind classifier across the three GUID maps per plan §7, AceFlagsRaw composition from inheritance + propagation + IsInherited),Invoke-AceParsingWorkUnit(per-object SD parse withAceIndex = -2PARSE_ERROR placeholder isolation),New-RunspacePool(InitialSessionStatecarries GUID maps viaSessionStateVariableEntry- lib file via
iss.StartupScripts),Invoke-RunspacePoolWork(dispatcher with per-batchBatchErrorcapture into-ErrorBag).
- lib file via
-
Phase 3 wired into
Invoke-ADPermissionAnalysis.ps1: pool created before Phase 2 enumeration, batches dispatched as they arrive (pipelined enumeration + parsing per plan §12), drained afterPhase2EndPhaseEndemits, BatchError logged viaWrite-LogEventand aggregated into$script:ErrorBag.Phase3PhaseStart/PhaseEndevents carry batch count + ACE total (plan §13). -
Tests/Phase3-AceParsing.Tests.ps1— Pester suite (32 cases) covering Owner parsing, IsDaclProtected detection (set + unset), synthetic Owner ACE shape, GenericAll comma-decomposed RightsDecoded, all five ObjectTypeKind classifications (Property / PropertySet / ExtendedRight / ClassChild / All / Unresolved), AceType naming (AccessAllowed/AccessDenied/AccessAllowedObject/AccessDeniedObject), AceIndex preservation, AceFlagsRaw composition, IsDaclProtected propagation, work-unit owner+DACL emission, PARSE_ERROR placeholder isolation, runspace pool aggregation, BatchError capture, variable injection, and StartupScripts dot-source. -
scripts/lib/Phase4-TrusteeResolution.ps1— Phase 4 helpers (plan §18.5):Resolve-NTAccount(mockable wrapper around[SecurityIdentifier].Translate),ConvertTo-LdapBinaryFilter(escapes a SID into the\xx\xxform anobjectSidfilter expects),Get-PrincipalTypeFromObjectClass(pure classifier withmsDS-GroupManagedServiceAccount/msDS-ManagedServiceAccount/computer/group/userpriority — gMSA wins over its inherited base classes),Get-DomainSid(base-scopeobjectSidread on the domain NC root),New-WellKnownSidSkipSet(universal SIDs from plan §10 plus domain-relative RIDs-498/-513/-514/-515/-516/-521resolved against the runtime domain SID),Test-IsTerminalSid(HashSet lookup +S-1-5-32-*BUILTIN prefix match),Get-DistinctTrusteeSet(single-pass dedupe over$aceRecordscovering DACL + Synthetic.Owner rows),Resolve-DomainPrincipal,Resolve-ForeignSecurityPrincipal,Resolve-TrusteeSid(cache → Translate → WellKnownSidMap → FSP → Orphaned per plan §5;BUILTIN\*andNT AUTHORITY\*translates short-circuit toWellKnownwithout an LDAP roundtrip),Expand-GroupTransitive((memberOf:1.2.840.113556.1.4.1941:=<groupDN>)against the domain NC subtree, cached by group SID, defensive-MaxMemberscap default 100000). -
Phase 4 wired into
Invoke-ADPermissionAnalysis.ps1: runs single-threaded after the Phase 3 drain — discovers domain SID + builds the skip set, dedupes trustees, resolves all distinct SIDs into$script:TrusteeCache, expands non-terminal groups with a DN into$script:GroupExpansionCache(skipped entirely under-SkipTransitiveExpansion), emitsPhaseStart/OrphanSid(one per distinct orphan) /PhaseEndevents with distinct/resolved/orphan/expanded counts and group-expansion cache hit ratio per plan §13. -
Tests/Phase4-TrusteeResolution.Tests.ps1— Pester suite (28 cases) mocking atResolve-NTAccountandInvoke-PagedLdapSearch. Covers cache short-circuit (zero LSA + zero LDAP after first hit),NT AUTHORITY\SYSTEMtranslate-only path, well-known SID fallback when Translate throws, in-domain User resolution via Translate + LDAP-by-objectSid, sMSA vs gMSA via objectClass priority, FSP classification with FSP-container search-base filter, Orphan when all paths fail,Test-IsTerminalSidagainst Everyone / Domain Users / BUILTIN aliases,Get-DistinctTrusteeSetdedupe across DACL + Synthetic.Owner rows, group transitive expansion (nested A → B → {user1, user2}) with cache populated, repeat-call cache short-circuit onExpand-GroupTransitive, andGet-DomainSidround-trip + empty-NC throw. -
scripts/lib/Phase5-InheritanceSource.ps1— Phase 5 helpers (plan §18.6):New-AceIndex(composite-keyDictionary[ValueTuple[string, string, uint32, guid], List[PSObject]]keyed by(ObjectDN-upper, TrusteeSid, AccessMask, ObjectTypeGuid)over EXPLICIT rows only; skips inherited, Synthetic.OwnerAceIndex = -1, and PARSE_ERRORAceIndex = -2),Get-ParentDistinguishedName(char-by-char DN tokenizer respecting\,/\\/\HHLDAP escapes, returns$nullat NC root),Test-IsContainerClass(heuristic over the small set of AD container classes),Test-InheritanceFlagsPropagateTo(pure rule overAceFlagsRawbyte +InheritedObjectTypeName+ descendant class +IsDirectChild; encodes ContainerInherit / ObjectInherit container-vs-leaf gating, NoPropagateInherit level-1-only halt, InheritOnly transparent for descendants, InheritedObjectType class filter via OI string equality),Resolve-InheritanceSource(mutates$aceRecordsin place — addsInheritanceSourceDNandInheritanceSourceNotecolumns on every row; DACL_PROTECTED short-circuits toInconsistentProtectedDacland emits the anomaly into-ProtectedDaclAnomalies; otherwise walks the parent chain viaGet-ParentDistinguishedName, direct-lookup at each ancestor, first matching candidate wins;SchemaDefaultOrUnresolvedfallback; stops at NC root or beyond; returns stats record withIndexed,InheritedTotal,Resolved,Unresolved,ProtectedDacl). -
Phase 5 wired into
Invoke-ADPermissionAnalysis.ps1: runs single-threaded after Phase 4PhaseEnd. Builds the index, extracts NC DNs into aList[string], callsResolve-InheritanceSourcewith an anomaly sink, forwards eachInheritedAceOnProtectedDaclanomaly toWrite-LogEventat WARN withEventName = 'BatchError'(matches Phase 3's BatchError contract from §13) AND adds it to$script:ErrorBagso the §14 exit-code-2 path picks them up.PhaseStart/PhaseEndevents emit per plan §13 withindexed/inheritedTotal/resolved/unresolved/protectedDaclcounts. -
Tests/Phase5-InheritanceSource.Tests.ps1— Pester suite (24 cases) coveringNew-AceIndex(explicit-only indexing, inherited skip, Synthetic.Owner / PARSE_ERROR skip, composite-key collision stacking),Get-ParentDistinguishedName(standard DN, escaped-comma RDN value, NC root → null, empty input → null),Test-InheritanceFlagsPropagateTo(ContainerInherit/ObjectInherit container-vs-leaf gating in both directions, InheritOnly transparent for descendants, NoPropagateInherit level-1-only halt, InheritedObjectType class filter user/group, no inherit flags returns false), andResolve-InheritanceSourceend-to-end (direct-parent resolution, two-level walk past failing-flag level-1 candidate, DACL_PROTECTED short-circuit + anomaly emission,SchemaDefaultOrUnresolvedfallback, explicit rows un-mutated, and Synthetic.Owner / PARSE_ERROR rows still get the columns added with empty values for uniform Phase 6 schema). -
scripts/lib/Phase6-Output.ps1— Phase 6 detail-CSV writer (plan §18.7):New-CsvFieldEscaper(RFC-4180 rule — quote when value contains,/"/ CR / LF; double internal"; passthrough otherwise),Write-CsvHeader(writes the 30-column header line via the supplied[StreamWriter]; column order lives in$script:Phase6DetailColumns, the single source of truth shared withConvertTo-DetailRow),ConvertTo-DetailRow(pure transform: ACE record + AceTrustee + EffectiveTrustee + IsThroughGroup + GroupExpansionPath + NamingContext- CollectedAt →
[string[]]of escaped fields in plan-§11 order),Get-EffectiveTrusteeRecord(single-pass fan-out: cache-hit non-empty group → one tuple per cached transitive member withIsThroughGroup = $true; otherwise direct trustee withIsThroughGroup = $false; cache miss falls back to a synthetic trustee carrying the raw SID),Resolve-NamingContextLabel(longest-suffix DN match against the NC list, memoised per ObjectDN — Schema NC wins over Configuration NC for Schema-scoped objects),Update-PivotStat(per-row mutation of the$PivotStatsaccumulator; lazy-seeds each EffectiveTrusteeSid bucket on first emission),Write-DetailCsv(orchestrator: opens[StreamWriter]UTF-8 no-BOM withAutoFlush = $false, header → for each ACE expand → write/update → flush at end;-ProgressCallbackscriptblock fires every-ProgressIntervalrows so the entry script forwards toWrite-LogEventwithout coupling the lib to logging).
- CollectedAt →
-
Phase 6 wired into
Invoke-ADPermissionAnalysis.ps1: creates$script:PivotStatsand the run's$collectedAtISO-8601 stamp after Phase 5PhaseEnd, callsWrite-DetailCsvwith aWrite-LogEvent- forwarding progress callback (Phase6Progressevery 50 000 rows plusWrite-Progressticks), then emitsPhaseEndwithdetailRowCount,distinctTrustees, andelapsedMs.$script:PivotStatsis left in place for Step 8's pivot-CSV writer to consume directly with no second pass over$aceRecords. -
Tests/Phase6-Output.Tests.ps1— Pester suite (16 cases) coveringNew-CsvFieldEscaper(clean string passthrough,$null, comma trigger, embedded"doubles + quotes, embedded LF, embedded CR);ConvertTo-DetailRow(column count + order via 30-element assertions, Synthetic.Owner row passthrough with AceIndex = -1 / OwnerImplicit / Allow, PARSE_ERROR row preserves the captured exception message in ObjectTypeName, InheritanceSourceDN populated for inherited rows);Get-EffectiveTrusteeRecord(direct-trustee one-tuple withIsThroughGroup = $false, group fan-out to two cached members withGroupExpansionPath= group name, terminal-skip path emits the group as itself, cache-miss falls back to synthetic trustee);Write-DetailCsvend-to-end (writes header + N body lines and a RightsDecoded field containing comma + double-quote round-trips throughImport-Csvcorrectly;$PivotStatspopulated with expected counters per trustee — TotalAceCount / Direct vs Indirect / Allow vs Deny / Explicit vs Inherited / DistinctObjectDns / RightsBreakdown — across a 4-row fixture mixing direct and group-expanded trustees). -
scripts/lib/Phase6-Output.ps1— Phase 6 pivot CSV writer (plan §18.8):$script:Phase6PivotColumns(16-column source of truth shared by header- body),
Format-RightsSummary/Format-ObjectClassesTouched(count desc, name asc tiebreak —"GenericAll:42; WriteProperty:118; ReadProperty:980"shape; empty / null dict →''),Format-NamingContextsTouched(sorted ordinal-ignore-case, joined with;—"Configuration;Domain;Schema"),ConvertTo-PivotRow(pure transform: PivotStats bucket + CollectedAt → 16 escaped fields in plan-§11 pivot order;DistinctObjectCountis the bucket'sDistinctObjectDns.Count),Write-PivotCsv(orchestrator: opens its own[StreamWriter]UTF-8 no-BOMAutoFlush = $false, sorts buckets by TotalAceCount desc → EffectiveTrusteeName asc → SID asc, writes header + one row per bucket viaConvertTo-PivotRow, returns row count).
- body),
-
Phase 6 pivot wired into
Invoke-ADPermissionAnalysis.ps1: emits a freshPhase6 / PivotStartandPhase6 / PivotEndJSONL pair so the detail-write and pivot-write phases are distinguishable in the log;PivotEnd.datacarriespivotRowCount+elapsedMs. -
Tests/Phase6-Output.Tests.ps1— extended Pester suite (now 31 cases) with:Format-RightsSummary(empty /$null→'', count-desc sort withname-asctiebreak),Format-NamingContextsTouched(empty /$null, ordinal-ignore-case ascending join),Format-ObjectClassesTouched(sort + tiebreak),ConvertTo-PivotRow(16 columns in plan-§11 order; scalar counts plus the three formatted summaries;DistinctObjectCountderives fromDistinctObjectDns.Count), andWrite-PivotCsvend-to-end (3-bucket fixture sorted by activity desc,Import-Csvround-trip,RightsSummarywith embedded,and;round-trips correctly, and a reconciliation case asserting thatsum(stats[*].TotalAceCount)over the pivot equalsWrite-DetailCsv's returned row count).
Invoke-PagedLdapSearchis now a materialising thin wrapper over a new streamingRead-LdapEntryprimitive that supports-AdditionalControls. Phase 1 callers and their test mocks are unchanged.- Phase 5 is the FIRST phase that mutates
$aceRecords— every row gainsInheritanceSourceDN(default$null) andInheritanceSourceNote(default'') note properties so the Phase 6 detail-CSV schema is uniform across explicit / inherited / Synthetic.Owner / PARSE_ERROR rows. Earlier phases were producers or pure consumers. - Plan §17 (Validation / Smoke Tests) and §18 steps 9–10 (lab smoke run + 30k-object performance pass) removed: no lab DC is available to this project, so live-LDAP validation is out of scope. The implementation ends at §18 step 8 (Phase 6 pivot CSV writer); correctness rests on the Pester unit suites attached to each phase. See ADR-025.
build.config.psd1:CoveragePathsnarrowed from'scripts'to'scripts/lib'andCoverageThresholdraised from0to86. Coverage now scopes to the unit-testable lib surface only — entry script andInstall-GitHooks.ps1are excluded as integration-test / utility surface (see ADR-025 for the entry script's testability rationale).86is 5pp below the measured lib floor of91.22%per ADR-026 — high enough to lock in current coverage as a regression gate, low enough that a single new untested helper doesn't break CI.scripts/Invoke-ADPermissionAnalysis.ps1.DESCRIPTIONrewritten to reflect the shipped six-phase orchestration. Previous text framed the script as a "skeleton entry point" with phase bodies pending §18.2-§18.8 — true at PR #9, stale since Step 8 (PR #16). Behaviour unchanged.scripts/lib/Phase6-Output.ps1.SYNOPSIS/.DESCRIPTIONandWrite-DetailCsvper-function help: replaced "Step 8 will serialise" / "Step 8 needs no second pass" / "Step 8's Pivot CSV writer consumes this" withWrite-PivotCsvreferences. Behaviour unchanged.scripts/lib/Phase4-TrusteeResolution.ps1.DESCRIPTION: dropped the stale "(Step 7)" parenthetical pointing at Phase 6's consumer role.docs/index.md: dropped the "(pre-refinement)" qualifier on the plan link — the plan was refined in PR #8 and again in PR #17.Export-ScriptDocumentation.ps1example splitter: track brace/paren depth and a sticky multi-line flag so multi-line splat hashtables (the house-style 3+ parameter idiom) round-trip correctly into the generated## Examplessection. Previously the splat opener was treated as the only command line and every subsequent line collapsed into the description, producing a malformed Example 2 block onInvoke-ADPermissionAnalysis.md.