Invoke-FvAdOuAceRemediation.psm1
|
#Region './Public/Backup-FvAdOuAcl.ps1' -1 function Backup-FvAdOuAcl { <# .SYNOPSIS Serialises an OU's current DACL to a manifest file, sufficient to restore it exactly. .DESCRIPTION Reads the target OU's nTSecurityDescriptor, extracts the DACL-only portion in SDDL form (owner, primary group and the SACL are deliberately excluded - out of scope for this suite), and writes it to a JSON manifest alongside a SHA-256 integrity hash of that SDDL string, the OU's distinguished name, and a UTC timestamp. Restore-FvAdOuAcl is the only function that consumes this manifest format. .PARAMETER OrganizationalUnit One or more OU distinguished names to back up. .PARAMETER BackupPath Directory to write the manifest into. Created if it does not already exist. Defaults to a 'FvAdOuAclBackups' folder under the current working directory. .PARAMETER Server The domain controller to query. Defaults to runtime discovery. .PARAMETER Credential Alternate credential to use. Defaults to the caller's current security context. .EXAMPLE Backup-FvAdOuAcl -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' -BackupPath 'C:\Backups' Writes a timestamped manifest for the named OU into C:\Backups. .EXAMPLE 'OU=Example,DC=contoso,DC=local' | Backup-FvAdOuAcl -BackupPath 'C:\Backups' -WhatIf Previews the manifest that would be written without creating it. .OUTPUTS PSCustomObject (PSTypeName 'Fv.AdOuAclBackup') describing the manifest written: path, OU distinguished name, UTC timestamp, and the SHA-256 integrity hash. .NOTES Required permissions: read access to the target OU's nTSecurityDescriptor attribute, and write access to -BackupPath. No AD write permission is required or used. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')] [Alias('DistinguishedName')] [string[]] $OrganizationalUnit, [Parameter()] [ValidateNotNullOrEmpty()] [string] $BackupPath = (Join-Path -Path $PWD -ChildPath 'FvAdOuAclBackups'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $Server, [Parameter()] [PSCredential] [System.Management.Automation.Credential()] $Credential = [PSCredential]::Empty ) begin { $ErrorActionPreference = 'Stop' $adParams = @{} if ($PSBoundParameters.ContainsKey('Server')) { $adParams['Server'] = $Server } if ($PSBoundParameters.ContainsKey('Credential')) { $adParams['Credential'] = $Credential } try { if (-not (Test-Path -Path $BackupPath)) { New-Item -ItemType Directory -Path $BackupPath -Force -ErrorAction Stop | Out-Null } } catch { throw "Backup-FvAdOuAcl: unable to create backup directory '$BackupPath': $($_.Exception.Message)" } $accessOnly = [System.Security.AccessControl.AccessControlSections]::Access } process { foreach ($dn in $OrganizationalUnit) { try { $adObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop $sddl = $adObject.nTSecurityDescriptor.GetSecurityDescriptorSddlForm($accessOnly) $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sddl)) } finally { $sha256.Dispose() } $hashHex = -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) $timestampUtc = [datetime]::UtcNow $sanitizedDn = $dn -replace '[\\/:*?"<>|,=]', '_' $fileName = "FvAdOuAcl_${sanitizedDn}_$($timestampUtc.ToString('yyyyMMddTHHmmssZ')).json" $manifestPath = Join-Path -Path $BackupPath -ChildPath $fileName $manifest = [PSCustomObject]@{ OrganizationalUnit = $dn TimestampUtc = $timestampUtc.ToString('o') Sddl = $sddl Sha256 = $hashHex CapturedBy = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } if ($PSCmdlet.ShouldProcess($dn, "Write DACL backup manifest to '$manifestPath'")) { $manifest | ConvertTo-Json -Depth 4 | Out-File -FilePath $manifestPath -Encoding UTF8 -ErrorAction Stop [PSCustomObject]@{ PSTypeName = 'Fv.AdOuAclBackup' OrganizationalUnit = $dn ManifestPath = $manifestPath TimestampUtc = $manifest.TimestampUtc Sha256 = $hashHex Success = $true ErrorReason = $null } } } catch { Write-Error -Message "Backup-FvAdOuAcl: backup failed for '$dn': $($_.Exception.Message)" -ErrorAction Continue [PSCustomObject]@{ PSTypeName = 'Fv.AdOuAclBackup' OrganizationalUnit = $dn ManifestPath = $null TimestampUtc = [datetime]::UtcNow.ToString('o') Sha256 = $null Success = $false ErrorReason = $_.Exception.Message } } } } end { Write-Verbose 'Backup-FvAdOuAcl: complete.' } } #EndRegion './Public/Backup-FvAdOuAcl.ps1' 151 #Region './Public/Get-FvAdOuAce.ps1' -1 function Get-FvAdOuAce { <# .SYNOPSIS Reads the ACEs on one or more Active Directory Organizational Units and flags any that match the identity and rights watchlists. .DESCRIPTION Strictly read-only. For each supplied OU distinguished name, reads the nTSecurityDescriptor attribute via Get-ADObject and emits one object per ACE found, annotated with whether it matches the identity/rights watchlists, whether it is inherited, and (where the identity reference cannot be resolved to a SID) an Unresolved status. This function accepts no parameter, and performs no call, capable of writing to the directory. .PARAMETER OrganizationalUnit One or more OU distinguished names to assess, e.g. 'OU=Finance,DC=contoso,DC=local'. .PARAMETER Server The domain controller to query. Defaults to runtime discovery (Get-ADObject's own default). .PARAMETER Credential Alternate credential to use. Defaults to the caller's current security context. .PARAMETER IdentityWatchlist Security principals considered over-permissive, as well-known/domain-relative SID strings. When omitted, defaults to S-1-1-0 (Everyone), S-1-5-11 (Authenticated Users), S-1-5-7 (Anonymous Logon), S-1-5-32-545 (BUILTIN\Users), and the domain-relative Domain Users (<domain SID>-513) and Pre-Windows 2000 Compatible Access (<domain SID>-554) principals, with the domain SID discovered at runtime. .PARAMETER RightsWatchlist The ActiveDirectoryRights values considered over-permissive. Defaults to GenericAll, GenericWrite, WriteDacl, WriteOwner, WriteProperty, Delete, DeleteTree, ExtendedRight. .EXAMPLE Get-FvAdOuAce -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' Lists every ACE on the named OU, flagging any that match the default watchlists. .EXAMPLE 'OU=Example,DC=contoso,DC=local' | Get-FvAdOuAce | Where-Object IsOverPermissive Pipes a single OU DN in and returns only the ACEs flagged as over-permissive. .OUTPUTS PSCustomObject (PSTypeName 'Fv.AdOuAce') - one per ACE, or one Status='Error' row per OU that could not be read. .NOTES Required permissions: read access to the target OU's nTSecurityDescriptor attribute (generally granted by default AD read access). No write permission is required or used. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')] [Alias('DistinguishedName')] [string[]] $OrganizationalUnit, [Parameter()] [ValidateNotNullOrEmpty()] [string] $Server, [Parameter()] [PSCredential] [System.Management.Automation.Credential()] $Credential = [PSCredential]::Empty, [Parameter()] [ValidateNotNullOrEmpty()] [string[]] $IdentityWatchlist, [Parameter()] [ValidateNotNullOrEmpty()] [System.DirectoryServices.ActiveDirectoryRights[]] $RightsWatchlist = @( 'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner', 'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight' ) ) begin { $ErrorActionPreference = 'Stop' Write-Verbose 'Get-FvAdOuAce: resolving connection parameters and watchlists.' $adParams = @{} if ($PSBoundParameters.ContainsKey('Server')) { $adParams['Server'] = $Server } if ($PSBoundParameters.ContainsKey('Credential')) { $adParams['Credential'] = $Credential } if (-not $PSBoundParameters.ContainsKey('IdentityWatchlist')) { try { $domainSid = (Get-ADDomain @adParams -ErrorAction Stop).DomainSID.Value } catch { throw "Get-FvAdOuAce: unable to discover the domain SID to build the default identity watchlist: $($_.Exception.Message)" } $IdentityWatchlist = @( 'S-1-1-0', 'S-1-5-11', 'S-1-5-7', 'S-1-5-32-545', "$domainSid-513", "$domainSid-554" ) } $watchlistSidValues = [System.Collections.Generic.List[string]]::new() foreach ($entry in $IdentityWatchlist) { try { $watchlistSidValues.Add(([System.Security.Principal.SecurityIdentifier]::new($entry)).Value) } catch { Write-Warning "Get-FvAdOuAce: '$entry' in the identity watchlist is not a valid SID and will be ignored." } } # A right "matches the watchlist" when the ACE's rights fully contain at least one # watchlisted right's bit pattern - not merely when any bit overlaps. GenericAll's # numeric value (983551 / 0xF01FF) is a combination of many lower-privilege bits # (including ReadProperty), so a naive combined-mask "-band -ne 0" test would flag an # ordinary ReadProperty-only ACE as a GenericAll match purely by bit coincidence - # confirmed during this build. Testing full containment per watchlisted right avoids that. $rightsMatchTest = { param($CandidateRights) foreach ($watchedRight in $RightsWatchlist) { if (($CandidateRights -band $watchedRight) -eq $watchedRight) { return $true } } return $false } $ouCount = 0 $aceCount = 0 } process { foreach ($dn in $OrganizationalUnit) { $ouCount++ try { $adObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop } catch { Write-Error -Message "Get-FvAdOuAce: could not read '$dn': $($_.Exception.Message)" -ErrorAction Continue [PSCustomObject]@{ PSTypeName = 'Fv.AdOuAce' OrganizationalUnit = $dn IdentityReference = $null Sid = $null ActiveDirectoryRights = $null AccessControlType = $null IsInherited = $null InheritanceType = $null ObjectType = $null InheritedObjectType = $null IsOverPermissive = $false MatchedRule = $null Status = 'Error' ErrorReason = $_.Exception.Message } continue } $securityDescriptor = $adObject.nTSecurityDescriptor if (-not $securityDescriptor) { Write-Warning "Get-FvAdOuAce: '$dn' returned no nTSecurityDescriptor." continue } # ActiveDirectorySecurity.Access returns only explicit rules by default; GetAccessRules # with includeInherited:$true is required to see inherited ACEs too (confirmed by direct # comparison against a live OU's descriptor during this build: .Access returned 1 entry # where GetAccessRules($true,$true,...) returned 33 for the same object). $allAces = $securityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) foreach ($ace in $allAces) { $aceCount++ $status = 'Ok' $sidValue = $null $errorReason = $null try { $sidValue = $ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $status = 'Unresolved' $errorReason = "Identity reference '$($ace.IdentityReference)' could not be translated to a SID." } $isOverPermissive = $false $matchedRule = $null if ($status -eq 'Ok') { $identityMatch = $sidValue -in $watchlistSidValues $rightsMatch = & $rightsMatchTest $ace.ActiveDirectoryRights if ($identityMatch -and $rightsMatch -and $ace.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow) { $isOverPermissive = $true $matchedRule = "Identity:$sidValue;Rights:$($ace.ActiveDirectoryRights)" $status = 'OverPermissive' } } [PSCustomObject]@{ PSTypeName = 'Fv.AdOuAce' OrganizationalUnit = $dn IdentityReference = $ace.IdentityReference.Value Sid = $sidValue ActiveDirectoryRights = $ace.ActiveDirectoryRights AccessControlType = $ace.AccessControlType IsInherited = $ace.IsInherited InheritanceType = $ace.InheritanceType ObjectType = $ace.ObjectType InheritedObjectType = $ace.InheritedObjectType IsOverPermissive = $isOverPermissive MatchedRule = $matchedRule Status = $status ErrorReason = $errorReason } } } } end { Write-Verbose "Get-FvAdOuAce: processed $ouCount OU(s), $aceCount ACE(s)." } } #EndRegion './Public/Get-FvAdOuAce.ps1' 226 #Region './Public/Get-FvAdOuAceReport.ps1' -1 function Get-FvAdOuAceReport { <# .SYNOPSIS Domain-wide (or search-base-scoped) sweep for over-permissive ACEs. Must be invoked deliberately - nothing else in this suite calls it implicitly. .DESCRIPTION Enumerates Organizational Units under the supplied search base(s), evaluates each one's ACEs against the identity and rights watchlists (by composing Get-FvAdOuAce), and returns the combined object set. When -OutputPath is supplied, also exports the report in the requested format(s). Read-only against the directory; writing the export file is the only state change this function makes, and is itself guarded by ShouldProcess. .PARAMETER SearchBase One or more distinguished names to sweep. Defaults to the current domain's distinguished name, discovered at runtime via Get-ADDomain. .PARAMETER SearchScope The search scope to use under each search base: Base, OneLevel or Subtree. Default: Subtree. .PARAMETER Server The domain controller to query. Defaults to runtime discovery. .PARAMETER Credential Alternate credential to use. Defaults to the caller's current security context. .PARAMETER IdentityWatchlist See Get-FvAdOuAce. Forwarded unchanged; same default-resolution behaviour applies. .PARAMETER RightsWatchlist See Get-FvAdOuAce. Forwarded unchanged. .PARAMETER OutputPath Directory to write the exported report into. Created if it does not already exist. When omitted, no file is written - only pipeline objects are returned. .PARAMETER OutputFormat One or more of 'Csv', 'Html', 'Json'. Only meaningful when -OutputPath is supplied. Default: Csv. .EXAMPLE Get-FvAdOuAceReport -SearchBase 'DC=contoso,DC=local' Sweeps every OU in the domain and returns the flagged/unflagged ACE objects to the pipeline. .EXAMPLE Get-FvAdOuAceReport -SearchBase 'DC=contoso,DC=local' -OutputPath 'C:\Reports' -OutputFormat Csv, Html Sweeps the domain and additionally writes a timestamped CSV and HTML report to C:\Reports. .OUTPUTS PSCustomObject (PSTypeName 'Fv.AdOuAce') - the same shape as Get-FvAdOuAce. .NOTES Required permissions: read access to enumerate OUs under the search base and to each target OU's nTSecurityDescriptor attribute. Write access to -OutputPath's filesystem location when an export is requested. No AD write permission is required or used. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] [OutputType([PSCustomObject])] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string[]] $SearchBase, [Parameter()] [ValidateSet('Base', 'OneLevel', 'Subtree')] [string] $SearchScope = 'Subtree', [Parameter()] [ValidateNotNullOrEmpty()] [string] $Server, [Parameter()] [PSCredential] [System.Management.Automation.Credential()] $Credential = [PSCredential]::Empty, [Parameter()] [ValidateNotNullOrEmpty()] [string[]] $IdentityWatchlist, [Parameter()] [ValidateNotNullOrEmpty()] [System.DirectoryServices.ActiveDirectoryRights[]] $RightsWatchlist = @( 'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner', 'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight' ), [Parameter()] [ValidateNotNullOrEmpty()] [string] $OutputPath, [Parameter()] [ValidateSet('Csv', 'Html', 'Json')] [string[]] $OutputFormat = @('Csv') ) begin { $ErrorActionPreference = 'Stop' Write-Verbose 'Get-FvAdOuAceReport: resolving connection parameters.' $adParams = @{} if ($PSBoundParameters.ContainsKey('Server')) { $adParams['Server'] = $Server } if ($PSBoundParameters.ContainsKey('Credential')) { $adParams['Credential'] = $Credential } $assessParams = @{} + $adParams if ($PSBoundParameters.ContainsKey('IdentityWatchlist')) { $assessParams['IdentityWatchlist'] = $IdentityWatchlist } $assessParams['RightsWatchlist'] = $RightsWatchlist $searchBaseResolved = $SearchBase if (-not $PSBoundParameters.ContainsKey('SearchBase') -or -not $searchBaseResolved) { try { $searchBaseResolved = @((Get-ADDomain @adParams -ErrorAction Stop).DistinguishedName) } catch { throw "Get-FvAdOuAceReport: unable to discover the default search base: $($_.Exception.Message)" } } if ($OutputPath -and -not (Test-Path -Path $OutputPath)) { try { New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction Stop | Out-Null } catch { throw "Get-FvAdOuAceReport: unable to create output directory '$OutputPath': $($_.Exception.Message)" } } $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() $searchBaseCount = 0 } process { foreach ($base in $searchBaseResolved) { $searchBaseCount++ try { $ous = Get-ADOrganizationalUnit -SearchBase $base -Filter '*' -SearchScope $SearchScope @adParams -ErrorAction Stop } catch { Write-Error -Message "Get-FvAdOuAceReport: could not enumerate OUs under '$base': $($_.Exception.Message)" -ErrorAction Continue continue } foreach ($ou in $ous) { foreach ($result in (Get-FvAdOuAce -OrganizationalUnit $ou.DistinguishedName @assessParams)) { $allResults.Add($result) } } } } end { Write-Verbose "Get-FvAdOuAceReport: swept $searchBaseCount search base(s), returning $($allResults.Count) ACE record(s)." if ($OutputPath) { $generatedUtc = [datetime]::UtcNow $generatedDisplay = $generatedUtc.ToString('dd/MM/yyyy HH:mm', [System.Globalization.CultureInfo]::InvariantCulture) + ' UTC' $stamp = $generatedUtc.ToString('yyyyMMddTHHmmssZ') if ($PSCmdlet.ShouldProcess($OutputPath, "Write Fv AD OU ACE report ($($OutputFormat -join ', '))")) { foreach ($format in $OutputFormat) { $reportFile = Join-Path -Path $OutputPath -ChildPath "FvAdOuAceReport_$stamp.$($format.ToLowerInvariant())" switch ($format) { 'Csv' { $allResults | Export-Csv -Path $reportFile -NoTypeInformation -Encoding UTF8 } 'Html' { $allResults | ConvertTo-Html -Title 'Active Directory OU ACE Report' ` -PreContent "<h1>Active Directory OU ACE Report</h1><p>Generated: $generatedDisplay</p>" | Out-File -FilePath $reportFile -Encoding UTF8 } 'Json' { [PSCustomObject]@{ GeneratedUtc = $generatedUtc.ToString('o') Findings = $allResults } | ConvertTo-Json -Depth 6 | Out-File -FilePath $reportFile -Encoding UTF8 } } Write-Verbose "Get-FvAdOuAceReport: wrote '$reportFile'." } } } $allResults.ToArray() } } #EndRegion './Public/Get-FvAdOuAceReport.ps1' 199 #Region './Public/Invoke-FvAdOuAceRemediation.ps1' -1 function Invoke-FvAdOuAceRemediation { <# .SYNOPSIS Single entry point for assessment, reporting, remediation and rollback of over-permissive OU ACEs, selected by mutually exclusive switch parameter. .DESCRIPTION Dispatches to Get-FvAdOuAce (-Assess, the default when no switch is given), Get-FvAdOuAceReport (-Report), Reset-FvAdOuAce (-Remediate) or Restore-FvAdOuAcl (-Rollback). The four modes are implemented as distinct PowerShell parameter sets, so the shell itself rejects specifying more than one switch, and each set surfaces only the parameters relevant to that mode. -Remediate and -Rollback carry SupportsShouldProcess; this function makes one batch-level ShouldProcess call covering the whole invocation, then forwards -WhatIf and calls the worker function with -Confirm:$false so the operator is prompted once, not twice, while the worker's own per-OU idempotence and verification logic still runs unchanged. .PARAMETER Assess Read-only assessment (Get-FvAdOuAce). This is the default parameter set - if no switch is given at all, this is what runs. .PARAMETER Report Domain-wide reporting sweep (Get-FvAdOuAceReport). Must be selected explicitly. .PARAMETER Remediate Remediation (Reset-FvAdOuAce). State-changing; SupportsShouldProcess. .PARAMETER Rollback Rollback (Restore-FvAdOuAcl). State-changing; SupportsShouldProcess. .PARAMETER OrganizationalUnit OU distinguished name(s). Used by -Assess and -Remediate; optional cross-check for -Rollback. .PARAMETER SearchBase Search base(s) for -Report. See Get-FvAdOuAceReport. .PARAMETER SearchScope Search scope for -Report. See Get-FvAdOuAceReport. .PARAMETER OutputPath Export directory for -Report. See Get-FvAdOuAceReport. .PARAMETER OutputFormat Export format(s) for -Report. See Get-FvAdOuAceReport. .PARAMETER RestoreMode Restore mode for -Remediate. See Reset-FvAdOuAce. Default: RemoveAce. .PARAMETER BackupPath Backup manifest directory for -Remediate. See Reset-FvAdOuAce / Backup-FvAdOuAcl. .PARAMETER ManifestPath Manifest path(s) for -Rollback. See Restore-FvAdOuAcl. .PARAMETER IdentityWatchlist Identity watchlist for -Assess, -Report and -Remediate. See Get-FvAdOuAce. .PARAMETER RightsWatchlist Rights watchlist for -Assess, -Report and -Remediate. See Get-FvAdOuAce. .PARAMETER Server The domain controller to query. Defaults to runtime discovery. Available in every mode. .PARAMETER Credential Alternate credential. Defaults to the caller's current security context. Available in every mode. .EXAMPLE Invoke-FvAdOuAceRemediation -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' Read-only assessment of the named OU (the default mode - equivalent to -Assess). .EXAMPLE Invoke-FvAdOuAceRemediation -Remediate -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' -BackupPath 'C:\Backups' -WhatIf Previews remediation of the named OU with no changes made. .OUTPUTS PSCustomObject - the shape returned by whichever worker function the selected mode dispatches to (Fv.AdOuAce for -Assess/-Report, Fv.AdOuAceResult for -Remediate/-Rollback). .NOTES Required permissions: as the dispatched worker function - read-only for -Assess/-Report, WriteDacl for -Remediate/-Rollback (see their own .NOTES). The default parameter set is -Assess (read-only); a write path is only ever reached when the caller explicitly selects -Remediate or -Rollback. #> [CmdletBinding(DefaultParameterSetName = 'Assess', SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter(ParameterSetName = 'Assess')] [switch] $Assess, [Parameter(ParameterSetName = 'Report')] [switch] $Report, [Parameter(ParameterSetName = 'Remediate')] [switch] $Remediate, [Parameter(ParameterSetName = 'Rollback')] [switch] $Rollback, [Parameter(ParameterSetName = 'Assess', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Parameter(ParameterSetName = 'Remediate', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Parameter(ParameterSetName = 'Rollback', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')] [string[]] $OrganizationalUnit, [Parameter(ParameterSetName = 'Report', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string[]] $SearchBase, [Parameter(ParameterSetName = 'Report')] [ValidateSet('Base', 'OneLevel', 'Subtree')] [string] $SearchScope = 'Subtree', [Parameter(ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutputPath, [Parameter(ParameterSetName = 'Report')] [ValidateSet('Csv', 'Html', 'Json')] [string[]] $OutputFormat = @('Csv'), [Parameter(ParameterSetName = 'Remediate')] [ValidateSet('RemoveAce', 'SchemaDefaultDacl')] [string] $RestoreMode = 'RemoveAce', [Parameter(ParameterSetName = 'Remediate')] [ValidateNotNullOrEmpty()] [string] $BackupPath = (Join-Path -Path $PWD -ChildPath 'FvAdOuAclBackups'), [Parameter(ParameterSetName = 'Rollback', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [string[]] $ManifestPath, [Parameter(ParameterSetName = 'Assess')] [Parameter(ParameterSetName = 'Report')] [Parameter(ParameterSetName = 'Remediate')] [ValidateNotNullOrEmpty()] [string[]] $IdentityWatchlist, [Parameter(ParameterSetName = 'Assess')] [Parameter(ParameterSetName = 'Report')] [Parameter(ParameterSetName = 'Remediate')] [ValidateNotNullOrEmpty()] [System.DirectoryServices.ActiveDirectoryRights[]] $RightsWatchlist = @( 'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner', 'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight' ), [Parameter()] [ValidateNotNullOrEmpty()] [string] $Server, [Parameter()] [PSCredential] [System.Management.Automation.Credential()] $Credential = [PSCredential]::Empty ) begin { $forwardKeys = 'Server', 'Credential', 'IdentityWatchlist', 'RightsWatchlist' $forward = @{} foreach ($key in $forwardKeys) { if ($PSBoundParameters.ContainsKey($key)) { $forward[$key] = $PSBoundParameters[$key] } } $ouBuffer = [System.Collections.Generic.List[string]]::new() $manifestBuffer = [System.Collections.Generic.List[string]]::new() } process { switch ($PSCmdlet.ParameterSetName) { 'Assess' { foreach ($dn in $OrganizationalUnit) { $ouBuffer.Add($dn) } } 'Rollback' { foreach ($m in $ManifestPath) { $manifestBuffer.Add($m) } } default { if ($OrganizationalUnit) { foreach ($dn in $OrganizationalUnit) { $ouBuffer.Add($dn) } } } } } end { switch ($PSCmdlet.ParameterSetName) { 'Assess' { Get-FvAdOuAce -OrganizationalUnit $ouBuffer.ToArray() @forward } 'Report' { $reportForward = $forward.Clone() if ($PSBoundParameters.ContainsKey('SearchBase')) { $reportForward['SearchBase'] = $SearchBase } if ($PSBoundParameters.ContainsKey('OutputPath')) { $reportForward['OutputPath'] = $OutputPath } $reportForward['SearchScope'] = $SearchScope $reportForward['OutputFormat'] = $OutputFormat Get-FvAdOuAceReport @reportForward } 'Remediate' { if ($PSCmdlet.ShouldProcess("$($ouBuffer.Count) organizational unit(s)", "Remediate (RestoreMode=$RestoreMode)")) { $remediateForward = $forward.Clone() $remediateForward['RestoreMode'] = $RestoreMode $remediateForward['BackupPath'] = $BackupPath Reset-FvAdOuAce -OrganizationalUnit $ouBuffer.ToArray() @remediateForward -Confirm:$false } } 'Rollback' { if ($PSCmdlet.ShouldProcess("$($manifestBuffer.Count) manifest(s)", 'Roll back DACL')) { $rollbackForward = @{} if ($PSBoundParameters.ContainsKey('Server')) { $rollbackForward['Server'] = $Server } if ($PSBoundParameters.ContainsKey('Credential')) { $rollbackForward['Credential'] = $Credential } if ($OrganizationalUnit) { $rollbackForward['OrganizationalUnit'] = $OrganizationalUnit } Restore-FvAdOuAcl -ManifestPath $manifestBuffer.ToArray() @rollbackForward -Confirm:$false } } } } } #EndRegion './Public/Invoke-FvAdOuAceRemediation.ps1' 242 #Region './Public/Reset-FvAdOuAce.ps1' -1 function Reset-FvAdOuAce { <# .SYNOPSIS Removes over-permissive explicit ACEs from an OU's DACL (remediation). .DESCRIPTION For each target OU: takes a fresh Backup-FvAdOuAcl snapshot first and aborts that OU if the backup fails; determines which explicit, Allow ACEs match the identity and rights watchlists; if none match, reports NoChangeRequired and performs no write (this idempotence check runs before ShouldProcess, so -WhatIf output is honest); otherwise applies the selected -RestoreMode, guarded by $PSCmdlet.ShouldProcess(); then re-reads the OU and verifies the targeted ACE(s) are gone. Two restore modes are implemented: RemoveAce (default) - removes only the explicit Allow ACEs matching the identity and rights watchlists. Every other ACE, including all inherited ACEs and all legitimate delegations, is left untouched. This is the minimal, reversible change. SchemaDefaultDacl - rebuilds the OU's entire DACL from the live defaultSecurityDescriptor of the organizationalUnit classSchema object, read from the schema naming context at runtime (never a literal SDDL string). This discards ALL custom delegations on the OU, not just the offending ACE, and inherited ACEs will re-merge per the standard DACL-inheritance rules for directory objects - it is not equivalent to a pristine OU. A Write-Warning is emitted every time this mode runs. It is never the default. Only explicit (IsInherited -eq $false) Allow ACEs are ever removed. Inherited ACEs matching the watchlists are left in place - remediating them requires a change at their parent. .PARAMETER OrganizationalUnit One or more OU distinguished names to remediate. .PARAMETER RestoreMode 'RemoveAce' (default) or 'SchemaDefaultDacl'. See DESCRIPTION. .PARAMETER BackupPath Directory for the pre-change backup manifest. Forwarded to Backup-FvAdOuAcl. .PARAMETER IdentityWatchlist See Get-FvAdOuAce. Used only by RemoveAce mode. .PARAMETER RightsWatchlist See Get-FvAdOuAce. Used only by RemoveAce mode. .PARAMETER Server The domain controller to query. Defaults to runtime discovery. .PARAMETER Credential Alternate credential to use. Defaults to the caller's current security context. .EXAMPLE Reset-FvAdOuAce -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' -WhatIf Previews the explicit over-permissive ACE(s) that would be removed, with no changes made. .EXAMPLE 'OU=Example,DC=contoso,DC=local' | Reset-FvAdOuAce -BackupPath 'C:\Backups' -Confirm:$false Backs up then removes the offending ACE(s) from the named OU without an interactive prompt. .OUTPUTS PSCustomObject (PSTypeName 'Fv.AdOuAceResult'): OrganizationalUnit, Action (Removed, Skipped, NoChangeRequired or Failed), AcesAffected, BackupManifestPath, VerificationResult, TimestampUtc, RanAs. .NOTES Required permissions: WriteDacl on each target OU; read access to the schema naming context when -RestoreMode SchemaDefaultDacl is used. This function changes directory state - SupportsShouldProcess/-WhatIf/-Confirm are fully honoured, and ConfirmImpact is 'High'. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')] [Alias('DistinguishedName')] [string[]] $OrganizationalUnit, [Parameter()] [ValidateSet('RemoveAce', 'SchemaDefaultDacl')] [string] $RestoreMode = 'RemoveAce', [Parameter()] [ValidateNotNullOrEmpty()] [string] $BackupPath = (Join-Path -Path $PWD -ChildPath 'FvAdOuAclBackups'), [Parameter()] [ValidateNotNullOrEmpty()] [string[]] $IdentityWatchlist, [Parameter()] [ValidateNotNullOrEmpty()] [System.DirectoryServices.ActiveDirectoryRights[]] $RightsWatchlist = @( 'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner', 'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight' ), [Parameter()] [ValidateNotNullOrEmpty()] [string] $Server, [Parameter()] [PSCredential] [System.Management.Automation.Credential()] $Credential = [PSCredential]::Empty ) begin { $ErrorActionPreference = 'Stop' $adParams = @{} if ($PSBoundParameters.ContainsKey('Server')) { $adParams['Server'] = $Server } if ($PSBoundParameters.ContainsKey('Credential')) { $adParams['Credential'] = $Credential } $backupParams = @{} + $adParams $backupParams['BackupPath'] = $BackupPath if (-not $PSBoundParameters.ContainsKey('IdentityWatchlist')) { try { $domainSid = (Get-ADDomain @adParams -ErrorAction Stop).DomainSID.Value } catch { throw "Reset-FvAdOuAce: unable to discover the domain SID to build the default identity watchlist: $($_.Exception.Message)" } $IdentityWatchlist = @( 'S-1-1-0', 'S-1-5-11', 'S-1-5-7', 'S-1-5-32-545', "$domainSid-513", "$domainSid-554" ) } $watchlistSidValues = [System.Collections.Generic.List[string]]::new() foreach ($entry in $IdentityWatchlist) { try { $watchlistSidValues.Add(([System.Security.Principal.SecurityIdentifier]::new($entry)).Value) } catch { Write-Warning "Reset-FvAdOuAce: '$entry' in the identity watchlist is not a valid SID and will be ignored." } } # See the matching identical comment in Get-FvAdOuAce: full containment per watchlisted # right, not a combined-mask "any bit overlaps" test. $rightsMatchTest = { param($CandidateRights) foreach ($watchedRight in $RightsWatchlist) { if (($CandidateRights -band $watchedRight) -eq $watchedRight) { return $true } } return $false } $accessOnly = [System.Security.AccessControl.AccessControlSections]::Access $runAsIdentity = if ($PSBoundParameters.ContainsKey('Credential')) { $Credential.UserName } else { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } $schemaDefaultDaclSddl = $null if ($RestoreMode -eq 'SchemaDefaultDacl') { Write-Warning 'Reset-FvAdOuAce: -RestoreMode SchemaDefaultDacl discards ALL custom delegations on each target OU, not only the offending ACE, and inherited ACEs will re-merge per standard DACL-inheritance rules. This is not equivalent to a pristine OU.' try { $schemaNc = (Get-ADRootDSE @adParams -ErrorAction Stop).schemaNamingContext $classSchema = Get-ADObject -SearchBase $schemaNc -Filter "lDAPDisplayName -eq 'organizationalUnit' -and objectClass -eq 'classSchema'" -Properties defaultSecurityDescriptor @adParams -ErrorAction Stop if (-not $classSchema -or -not $classSchema.defaultSecurityDescriptor) { throw 'the organizationalUnit classSchema object returned no defaultSecurityDescriptor value.' } $schemaSd = [System.DirectoryServices.ActiveDirectorySecurity]::new() $schemaSd.SetSecurityDescriptorSddlForm($classSchema.defaultSecurityDescriptor) $schemaDefaultDaclSddl = $schemaSd.GetSecurityDescriptorSddlForm($accessOnly) } catch { throw "Reset-FvAdOuAce: unable to read the live schema default security descriptor: $($_.Exception.Message)" } } } process { foreach ($dn in $OrganizationalUnit) { $result = [ordered]@{ PSTypeName = 'Fv.AdOuAceResult' OrganizationalUnit = $dn Action = 'Failed' AcesAffected = @() BackupManifestPath = $null VerificationResult = $false VerificationDetail = $null TimestampUtc = [datetime]::UtcNow.ToString('o') RanAs = $runAsIdentity ErrorReason = $null } try { $adObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop $securityDescriptor = $adObject.nTSecurityDescriptor $matchingAces = [System.Collections.Generic.List[System.DirectoryServices.ActiveDirectoryAccessRule]]::new() # See the note in Get-FvAdOuAce: use GetAccessRules(includeInherited:$true), not .Access. foreach ($ace in @($securityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))) { if ($ace.IsInherited) { continue } if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } $aceSid = $null try { $aceSid = $ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { continue } if ($aceSid -notin $watchlistSidValues) { continue } if (-not (& $rightsMatchTest $ace.ActiveDirectoryRights)) { continue } $matchingAces.Add($ace) } if ($RestoreMode -eq 'RemoveAce' -and $matchingAces.Count -eq 0) { $result.Action = 'NoChangeRequired' $result.ErrorReason = $null [PSCustomObject]$result continue } $target = if ($RestoreMode -eq 'RemoveAce') { "remove $($matchingAces.Count) over-permissive explicit ACE(s)" } else { 'rebuild the entire DACL from the live schema default' } if (-not $PSCmdlet.ShouldProcess($dn, $target)) { $result.Action = 'Skipped' $result.ErrorReason = 'Declined by operator or -WhatIf specified.' [PSCustomObject]$result continue } $backupResult = Backup-FvAdOuAcl -OrganizationalUnit $dn @backupParams -Confirm:$false -ErrorAction Stop if (-not $backupResult -or -not $backupResult.Success) { $result.Action = 'Failed' $result.ErrorReason = 'Pre-change backup failed; no write was attempted.' [PSCustomObject]$result continue } $result.BackupManifestPath = $backupResult.ManifestPath $affected = [System.Collections.Generic.List[string]]::new() if ($RestoreMode -eq 'RemoveAce') { foreach ($ace in $matchingAces) { $null = $securityDescriptor.RemoveAccessRuleSpecific($ace) $affected.Add("$($ace.IdentityReference.Value): $($ace.ActiveDirectoryRights) (Allow)") } Set-ADObject -Identity $dn -Replace @{ ntSecurityDescriptor = $securityDescriptor } @adParams -Confirm:$false -ErrorAction Stop } else { $newSecurityDescriptor = [System.DirectoryServices.ActiveDirectorySecurity]::new() $newSecurityDescriptor.SetSecurityDescriptorSddlForm($schemaDefaultDaclSddl, $accessOnly) $affected.Add("DACL rebuilt from schema default; $($matchingAces.Count) previously-matching explicit ACE(s) superseded.") Set-ADObject -Identity $dn -Replace @{ ntSecurityDescriptor = $newSecurityDescriptor } @adParams -Confirm:$false -ErrorAction Stop } $result.AcesAffected = $affected.ToArray() $result.Action = 'Removed' $postAdObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop $postAllAces = $postAdObject.nTSecurityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) $remaining = @($postAllAces | Where-Object { $candidateAce = $_ if ($candidateAce.IsInherited) { return $false } if ($candidateAce.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { return $false } if (-not (& $rightsMatchTest $candidateAce.ActiveDirectoryRights)) { return $false } try { $candidateAce.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -in $watchlistSidValues } catch { $false } }) if ($remaining.Count -eq 0) { $result.VerificationResult = $true $result.VerificationDetail = 'Re-read after write: no watchlisted explicit Allow ACE remains.' } else { $result.VerificationResult = $false $result.VerificationDetail = "Re-read after write: $($remaining.Count) watchlisted explicit Allow ACE(s) still present." } } catch { $result.Action = 'Failed' $result.ErrorReason = $_.Exception.Message Write-Error -Message "Reset-FvAdOuAce: remediation failed for '$dn': $($_.Exception.Message)" -ErrorAction Continue } [PSCustomObject]$result } } end { Write-Verbose 'Reset-FvAdOuAce: complete.' } } #EndRegion './Public/Reset-FvAdOuAce.ps1' 301 #Region './Public/Restore-FvAdOuAcl.ps1' -1 function Restore-FvAdOuAcl { <# .SYNOPSIS Restores an OU's DACL from a manifest produced by Backup-FvAdOuAcl (rollback). .DESCRIPTION Reads a Backup-FvAdOuAcl manifest, recomputes the SHA-256 hash of its stored SDDL and refuses to proceed if it does not match the manifest's recorded hash (integrity check). When -OrganizationalUnit is supplied, the manifest's own OrganizationalUnit value must match it exactly, or the manifest is refused for that target. Applies the manifest's DACL-only SDDL to the target OU's nTSecurityDescriptor, guarded by $PSCmdlet.ShouldProcess(), then re-reads the OU and verifies the restored DACL matches the manifest. .PARAMETER ManifestPath One or more paths to manifest files written by Backup-FvAdOuAcl. .PARAMETER OrganizationalUnit Optional cross-check: the OU distinguished name(s) expected to be restored. When supplied, every manifest processed in this invocation must have this DN embedded, or it is refused. When omitted, the manifest's own embedded DN is trusted as the target (with a warning). .PARAMETER Server The domain controller to query. Defaults to runtime discovery. .PARAMETER Credential Alternate credential to use. Defaults to the caller's current security context. .EXAMPLE Restore-FvAdOuAcl -ManifestPath 'C:\Backups\FvAdOuAcl_OU_Example_20260101T000000Z.json' -WhatIf Previews restoring the named OU's DACL from the manifest, with no changes made. .EXAMPLE Get-ChildItem 'C:\Backups\*.json' | Restore-FvAdOuAcl -Confirm:$false Restores every manifest in the backup folder without an interactive prompt. .OUTPUTS PSCustomObject (PSTypeName 'Fv.AdOuAceResult'): OrganizationalUnit, Action (Restored, Skipped or Failed), AcesAffected, BackupManifestPath, VerificationResult, TimestampUtc, RanAs. .NOTES Required permissions: WriteDacl on each target OU. This function changes directory state - SupportsShouldProcess/-WhatIf/-Confirm are fully honoured, and ConfirmImpact is 'High'. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [Alias('FullName', 'Path')] [string[]] $ManifestPath, [Parameter(ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')] [string[]] $OrganizationalUnit, [Parameter()] [ValidateNotNullOrEmpty()] [string] $Server, [Parameter()] [PSCredential] [System.Management.Automation.Credential()] $Credential = [PSCredential]::Empty ) begin { $ErrorActionPreference = 'Stop' $adParams = @{} if ($PSBoundParameters.ContainsKey('Server')) { $adParams['Server'] = $Server } if ($PSBoundParameters.ContainsKey('Credential')) { $adParams['Credential'] = $Credential } $accessOnly = [System.Security.AccessControl.AccessControlSections]::Access $runAsIdentity = if ($PSBoundParameters.ContainsKey('Credential')) { $Credential.UserName } else { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } $expectedOuList = $OrganizationalUnit } process { foreach ($path in $ManifestPath) { $result = [ordered]@{ PSTypeName = 'Fv.AdOuAceResult' OrganizationalUnit = $null Action = 'Failed' AcesAffected = @() BackupManifestPath = $path VerificationResult = $false VerificationDetail = $null TimestampUtc = [datetime]::UtcNow.ToString('o') RanAs = $runAsIdentity ErrorReason = $null } try { $manifest = Get-Content -Path $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $result.OrganizationalUnit = $manifest.OrganizationalUnit $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $recomputedBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($manifest.Sddl)) } finally { $sha256.Dispose() } $recomputedHex = -join ($recomputedBytes | ForEach-Object { $_.ToString('x2') }) if ($recomputedHex -ne $manifest.Sha256) { throw "manifest integrity check failed for '$path' (SHA-256 mismatch)." } if ($expectedOuList -and $manifest.OrganizationalUnit -notin $expectedOuList) { throw "manifest OU '$($manifest.OrganizationalUnit)' does not match the requested target(s) ($($expectedOuList -join ', '))." } if (-not $expectedOuList) { Write-Warning "Restore-FvAdOuAcl: no -OrganizationalUnit cross-check was supplied for '$path'; trusting the manifest's own embedded DN '$($manifest.OrganizationalUnit)'." } $dn = $manifest.OrganizationalUnit if (-not $PSCmdlet.ShouldProcess($dn, "Restore DACL from manifest '$path'")) { $result.Action = 'Skipped' $result.ErrorReason = 'Declined by operator or -WhatIf specified.' [PSCustomObject]$result continue } $restoredSecurityDescriptor = [System.DirectoryServices.ActiveDirectorySecurity]::new() $restoredSecurityDescriptor.SetSecurityDescriptorSddlForm($manifest.Sddl, $accessOnly) Set-ADObject -Identity $dn -Replace @{ ntSecurityDescriptor = $restoredSecurityDescriptor } @adParams -Confirm:$false -ErrorAction Stop $result.Action = 'Restored' $result.AcesAffected = @("DACL replaced from manifest '$path' (captured $($manifest.TimestampUtc)).") $postAdObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop $postSddl = $postAdObject.nTSecurityDescriptor.GetSecurityDescriptorSddlForm($accessOnly) if ($postSddl -eq $manifest.Sddl) { $result.VerificationResult = $true $result.VerificationDetail = 'Re-read after write: DACL matches the manifest exactly.' } else { $result.VerificationResult = $false $result.VerificationDetail = 'Re-read after write: DACL does not match the manifest (this can legitimately occur if inherited ACEs re-merged differently).' } } catch { $result.Action = 'Failed' $result.ErrorReason = $_.Exception.Message Write-Error -Message "Restore-FvAdOuAcl: rollback failed for manifest '$path': $($_.Exception.Message)" -ErrorAction Continue } [PSCustomObject]$result } } end { Write-Verbose 'Restore-FvAdOuAcl: complete.' } } #EndRegion './Public/Restore-FvAdOuAcl.ps1' 176 |