Export-Permission.ps1

<#PSScriptInfo
 
.VERSION 0.0.105
 
.GUID fd2d03cf-4d29-4843-bb1c-0fba86b0220a
 
.AUTHOR Jeremy La Camera
 
.COMPANYNAME Jeremy La Camera
 
.COPYRIGHT (c) Jeremy La Camera. All rights reserved.
 
.TAGS adsi ntfs acl
 
.LICENSEURI https://github.com/IMJLA/Export-Permission/blob/main/LICENSE
 
.PROJECTURI https://github.com/IMJLA/Export-Permission
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES Adsi,SimplePrtg,PsNtfs,PsLogMessage,PsRunspace,PsDfs,PsBootstrapCss,Permission
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
Added blank lines to multiline param descriptions in comment-based help to workaround bug in New-MarkdownHelp
 
.PRIVATEDATA
 
#>
 

#Requires -Module Adsi
#Requires -Module SimplePrtg
#Requires -Module PsNtfs
#Requires -Module PsLogMessage
#Requires -Module PsRunspace
#Requires -Module PsDfs
#Requires -Module PsBootstrapCss
#Requires -Module Permission



<#
.SYNOPSIS
    Create CSV, HTML, and XML reports of permissions
.DESCRIPTION
    Gets all permissions for the target folder
    Gets non-inherited permissions for subfolders (if specified)
    Exports the permissions to CSV
    Uses ADSI to get information about the accounts and groups listed in the permissions
    Exports information about the accounts and groups to CSV
    Uses ADSI to recursively retrieve the members of nested groups
    Creates an HTML report showing the resultant access of individual accounts
    Exports information about all accounts with NTFS access to CSV
    Creates an HTML report of all accounts with NTFS access
    Outputs an XML-formatted list of common misconfigurations for use win Paessler PRTG Network Monitor as a custom XML sensor
.INPUTS
    None. Pipeline input is not accepted.
.OUTPUTS
    [System.String] XML PRTG sensor output
.NOTES
    TODO: Bug - Logic Flaw for Owner.
                Currently we search folders for non-inherited access rules, then we manually add a FullControl access rule for the Owner.
                This misses folders with only inherited access rules but a different owner.
    TODO: Bug - Doesn't work for AD users' default group/primary group (which is typically Domain Users).
                The user's default group is not listed in their memberOf attribute so I need to fix the LDAP search filter to include the primary group attribute.
    TODO: Bug - For a fake group created by New-FakeDirectoryEntry in the Adsi module, in the report its name will end up as an NT Account (CONTOSO\User123).
                If it is a fake user, its name will correctly appear without the domain prefix (User123)
    TODO: Bug - Fix bug in PlatyPS New-MarkdownHelp with multi-line param descriptions (?and example help maybe affected also?).
                When provided the same comment-based help as input, Get-Help respects the line breaks but New-MarkdownHelp does not.
                New-MarkdownHelp generates an inaccurate markdown representation by converting multiple lines to a single line.
                Declared as wontfix https://github.com/PowerShell/platyPS/issues/314
                Need to fix it myself because that makes no sense
                recommended workaround is to include markdown syntax in PowerShell comment-based help
                That will not work because:
                    Tables or code blocks are not what is being attempted here; just paragraphs.
                    Markdown syntax would be a blank line between the paragraphs but that is not valid for PowerShell comment-based help.
    TODO: Feature - List any excluded accounts at the end
    TODO: Feature - Remove all usage of Add-Member to improve performance (create new pscustomobjects instead, nest original object inside)
    TODO: Feature - Parameter to specify properties to include in report
    TODO: Feature - This script does NOT account for individual file permissions. Only folder permissions are considered.
    TODO: Feature - This script does NOT account for file share permissions. Only NTFS permissions are considered.
    TODO: Feature - Support ACLs from Registry or AD objects
    TODO: Feature - psake task to update Release Notes in the script metadata to the github commit message
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test
 
    Generate reports on the NTFS permissions for the folder C:\Test and all subfolders
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -AccountsToSkip 'BUILTIN\\Administrator'
 
    Generate reports on the NTFS permissions for the folder C:\Test and all subfolders
    Exclude the built-in Administrator account from the HTML report
    The AccountsToSkip parameter uses RegEx, so the \ in BUILTIN\Administrator needed to be escaped.
    The RegEx escape character is \ so that is why the regular expression needed for the parameter is 'BUILTIN\\Administrator'
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -ExcludeEmptyGroups
 
    Generate reports on the NTFS permissions for the folder C:\Test and all subfolders
    Exclude empty groups from the HTML report (leaving accounts only)
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -DomainToIgnore 'CONTOSO'
 
    Generate reports on the NTFS permissions for the folder C:\Test and all subfolders
    Remove the CONTOSO domain prefix from associated accounts and groups
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -LogDir C:\Logs
 
    Generate reports on the NTFS permissions for the folder C:\Test and all subfolders
    Redirect logs and output files to C:\Logs instead of the default location in AppData
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -LevelsOfSubfolders 0
 
    Generate reports on the NTFS permissions for the folder C:\Test only (no subfolders)
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -LevelsOfSubfolders 2
 
    Generate reports on the NTFS permissions for the folder C:\Test
    Only include subfolders to a maximum of 2 levels deep (C:\Test\Level1\Level2)
.EXAMPLE
    Export-Permission.ps1 -TargetPath C:\Test -Title 'New Custom Report Title'
 
    Generate reports on the NTFS permissions for the folder C:\Test and all subfolders
    Change the title of the HTML report to 'New Custom Report Title'
#>

param (

    # Path to the folder whose permissions to report (only tested with local paths, UNC may work, unknown)
    [string]$TargetPath = 'C:\Test',
    #[string]$TargetPath = '\\ad.contoso.com\coh\Test2\FolderWithoutTarget\FolderWithTarget\',

    # Regular expressions that will identify Users or Groups you do not want included in the Html report
    [string[]]$AccountsToSkip<# = @(
        'BUILTIN\\Administrators',
        'BUILTIN\\Administrator',
        'CREATOR OWNER',
        'NT AUTHORITY\\SYSTEM'
    )#>
,

    # Exclude empty groups from the HTML report
    [switch]$ExcludeEmptyGroups,

    <#
    Domains to ignore (they will be removed from the username)
 
    Intended when a user has matching SamAccountNames in multiple domains but you only want them to appear once on the report.
    #>

    [string[]]$DomainToIgnore, # = @('CONTOSO1\\','CONTOSO2\\'),

    # Path to save the logs and reports generated by this script
    [string]$LogDir = "$env:AppData\Export-Permission\Logs",

    <#
    Path containing the required modules for this script
 
    Each module must match proper PowerShell module folder structure (module folder name matches the name of the .psm1 file)
    #>

    [string]$ModulesDir = '$PSScriptRoot\Modules',

    # Get group members
    [switch]$NoGroupMembers,

    <#
    How many levels of subfolder to enumerate
 
        Set to 0 to ignore all subfolders
 
        Set to -1 (default) to recurse infinitely
 
        Set to any whole number to enumerate that many levels
    #>

    [int]$LevelsOfSubfolders = -1,

    # Title at the top of the HTML report
    [string]$Title = "Folder Permissions Report",

    <#
    Valid group names that are allowed to appear in ACEs
 
    Specify as a ScriptBlock meant for the FilterScript parameter of Where-Object
 
    In the scriptblock, use string comparisons on the Name property
 
    e.g. {$_.Name -like 'CONTOSO\Group1*' -or $_.Name -eq 'CONTOSO\Group23'}
 
    The naming format that will be used for the groups is CONTOSO\Group1
 
    where CONTOSO is the NetBIOS name of the domain, and Group1 is the samAccountName of the group
 
    By default, this is a scriptblock that always evaluates to $true so it doesn't evaluate any naming convention compliance
    #>

    [scriptblock]$GroupNamingConvention = { $true },

    # Open the HTML report at the end using Invoke-Item (useful only interactively)
    [switch]$OpenReportAtEnd,

    <#
    If all four of the PRTG parameters are specified,
 
    the results will be XML-formatted and pushed to the specified PRTG probe for a push sensor
    #>

    [string]$PrtgProbe,

    <#
    If all four of the PRTG parameters are specified,
 
    the results will be XML-formatted and pushed to the specified PRTG probe for a push sensor
    #>

    [string]$PrtgSensorProtocol,

    <#
    If all four of the PRTG parameters are specified,
 
    the results will be XML-formatted and pushed to the specified PRTG probe for a push sensor
    #>

    [int]$PrtgSensorPort,

    <#
    If all four of the PRTG parameters are specified,
 
    the results will be XML-formatted and pushed to the specified PRTG probe for a push sensor
    #>

    [string]$PrtgSensorToken

)

#----------------[ Initialization ]----------------

# $PSScriptRoot is usually null inside the param block so I can't use the double-quotes up there to expand it
# doing it this way allows comment-based help to accurately reflect the default values of these parameters
if ($ModulesDir -eq '$PSScriptRoot\Modules') {
    $ModulesDir = "$PSScriptRoot\Modules"
}

#----------------[ Functions ]------------------

# This is where the function definitions will be inserted in the portable version of this script

#----------------[ Logging ]----------------

$LogDir = New-DatedSubfolder -Root $LogDir
$TranscriptFile = "$LogDir\Transcript.log"
Start-Transcript $TranscriptFile *>$null
Write-Information $TranscriptFile

#----------------[ Declarations ]----------------

$DirectoryEntryCache = [hashtable]::Synchronized(@{})
$IdentityReferenceCache = [hashtable]::Synchronized(@{})
$AdsiServerCache = [hashtable]::Synchronized(@{})
$Permissions = $null
$FolderTargets = $null
$SecurityPrincipals = $null
$FormattedSecurityPrincipals = $null
$DedupedUserPermissions = $null
$FolderPermissions = $null

#----------------[ Main Execution ]---------------

$ReportDescription = Get-ReportDescription -LevelsOfSubfolders $LevelsOfSubfolders
$FolderTableHeader = Get-FolderTableHeader -LevelsOfSubfolders $LevelsOfSubfolders
Write-Verbose "$(Get-Date -Format s)`t$(hostname)`tExport-Permission`tTarget Folder: '$TargetPath'"
$FolderTargets = Get-FolderTarget -FolderPath $TargetPath
$Permissions = Get-FolderAccessList -FolderTargets $FolderTargets -LevelsOfSubfolders $LevelsOfSubfolders

# If $TargetPath was on a local disk such as C:\
# The Get-FolderTarget cmdlet has replaced that local disk path with the corresponding UNC path \\$(hostname)\C$
# Unfortunately if it is the root of that local disk, Get-Item is unable to retrieve a DirectoryInfo object for the root of the share
# (error: "Could not find item")
# As a workaround here we will instead get the folder ACL for the original $TargetPath
# But I don't think this solves it since it won't work for actual remote paths at the root of the share: \\server\share
if ($null -eq $Permissions) {
    $Permissions = Get-FolderAccessList -FolderTargets $TargetPath -LevelsOfSubfolders $LevelsOfSubfolders
}

# Save a CSV of the raw NTFS ACEs, showing non-inherited ACEs only except for the root folder $TargetPath
$CsvFilePath = "$LogDir\1-AccessControlEntries.csv"

$Permissions |
Select-Object -Property @{
    Label      = 'Path'
    Expression = { $_.SourceAccessList.Path }
}, IdentityReference, AccessControlType, FileSystemRights, IsInherited, PropagationFlags, InheritanceFlags |
Export-Csv -NoTypeInformation -LiteralPath $CsvFilePath

Write-Information $CsvFilePath

# Identify unique directory servers to populate into the AdsiServerCache
# This prevents threads that start near the same time from finding the cache empty and attempting costly operations to populate it
# This prevents repetitive queries to the same directory servers
$UniqueServerNames = $Permissions.SourceAccessList.Path |
Sort-Object -Unique |
ForEach-Object { Find-ServerNameInPath -LiteralPath $_ } |
Sort-Object -Unique

# Populate the AdsiServerCache
$GetAdsiServer = @{
    Command        = 'Get-AdsiServer'
    InputObject    = $UniqueServerNames
    InputParameter = 'AdsiServer'
    AddParam       = @{
        KnownServers = $AdsiServerCache
    }
}
$null = Split-Thread @GetAdsiServer

# Resolve the IdentityReference in each Access Control Entry (e.g. CONTOSO\user1, or a SID) to their associated SIDs/Names
# The resolved name includes the domain name (or local computer name for local accounts)
$ResolveAce = @{
    Command              = 'Resolve-Ace'
    InputObject          = $Permissions
    InputParameter       = 'InputObject'
    ObjectStringProperty = 'IdentityReference'
    AddParam             = @{
        KnownServers = $AdsiServerCache
    }
}
$PermissionsWithResolvedIdentityReferences = Split-Thread @ResolveAce

# Save a CSV report of the resolved identity references
$CsvFilePath = "$LogDir\2-AccessControlEntriesWithResolvedIdentityReferences.csv"

$PermissionsWithResolvedIdentityReferences |
Select-Object -Property @{
    Label      = 'Path'
    Expression = { $_.SourceAccessList.Path }
}, * |
Export-Csv -NoTypeInformation -LiteralPath $CsvFilePath

Write-Information $CsvFilePath

# Group the Access Control Entries by their resolved identity references
# This avoids repeat ADSI lookups for the same security principal
$GroupedIdentities = $PermissionsWithResolvedIdentityReferences |
Group-Object -Property IdentityReferenceResolved

# Use ADSI to collect more information about each resolved identity reference
$ExpandIdentityReference = @{
    Command              = 'Expand-IdentityReference'
    InputObject          = $GroupedIdentities
    InputParameter       = 'AccessControlEntry'
    AddParam             = @{
        DirectoryEntryCache    = $DirectoryEntryCache
        IdentityReferenceCache = $IdentityReferenceCache
    }
    ObjectStringProperty = 'Name'
}
if ($NoGroupMembers) {
    $ExpandIdentityReference['AddSwitch'] = 'NoGroupMembers'
}
$SecurityPrincipals = Split-Thread @ExpandIdentityReference

# Format Security Principals (distinguish group members from users directly listed in the NTFS DACLs)
# Filter out groups (their members have already been retrieved)
$FormatSecurityPrincipal = @{
    Command              = 'Format-SecurityPrincipal'
    InputObject          = $SecurityPrincipals
    InputParameter       = 'SecurityPrincipal'
    Timeout              = 1200
    ObjectStringProperty = 'Name'
}
$FormattedSecurityPrincipals = Split-Thread @FormatSecurityPrincipal

# Expand the collection of security principals from Format-SecurityPrincipal
# back into a collection of access control entries (one per ACE per principal)
# This operation is a bunch simple type conversions, no queries are being performed
# That makes it fast enough that it is not worth multi-threading
$ExpandedAccountPermissions = Expand-AccountPermission -AccountPermission $FormattedSecurityPrincipals

# Save a CSV report of the expanded account permissions
#TODO: Expand DirectoryEntry objects in the DirectoryEntry and Members properties
$CsvFilePath = "$LogDir\3-AccessControlEntriesWithResolvedAndExpandedIdentityReferences.csv"

$ExpandedAccountPermissions |
Select-Object -Property @{
    Label      = 'SourceAclPath'
    Expression = { $_.ACESourceAccessList.Path }
}, * |
Export-Csv -NoTypeInformation -LiteralPath $CsvFilePath

Write-Information $CsvFilePath

$Accounts = $FormattedSecurityPrincipals |
Group-Object -Property User |
Sort-Object -Property Name

# Ensure accounts only appear once on the report if they exist in multiple domains
$DedupedUserPermissions = $Accounts |
Remove-DuplicatesAcrossIgnoredDomains -DomainToIgnore $DomainToIgnore

# Group the user permissions back into folder permissions for the report
$FolderPermissions = Format-FolderPermission -UserPermission $DedupedUserPermissions |
Group-Object -Property Folder |
Sort-Object -Property Name

$HtmlTableOfFolders = Select-FolderTableProperty -InputObject $FolderPermissions |
ConvertTo-Html -Fragment |
New-BootstrapTable

$GetFolderPermissionsBlock = @{
    FolderPermissions  = $FolderPermissions
    AccountsToSkip     = $AccountsToSkip
    ExcludeEmptyGroups = $ExcludeEmptyGroups
    DomainToIgnore     = $DomainToIgnore
}
$HtmlFolderPermissions = Get-FolderPermissionsBlock @GetFolderPermissionsBlock

##Commented the two lines below because actually keeping semicolons means it copy/pastes better into Excel
### Convert-ToHtml will not expand in-line HTML, so we had to use semicolons as placeholders and will now replace them with line breaks.
##$HtmlFolderPermissions = $HtmlFolderPermissions -replace ' ; ','<br>'

$ReportDescription = "$(New-BootstrapAlert -Class Dark -Text $TargetPath) $ReportDescription"
$FolderList = Get-HtmlFolderList -FolderTableHeader $FolderTableHeader -HtmlTableOfFolders $HtmlTableOfFolders
[string]$Body = Get-HtmlBody -FolderList $FolderList -HtmlFolderPermissions $HtmlFolderPermissions

$ReportParameters = @{
    Title       = $Title
    Description = $ReportDescription
    Body        = $Body
}
$Report = New-BootstrapReport @ReportParameters

# Save the Html report
$ReportFile = "$LogDir\FolderPermissionsReport.html"
$Report | Set-Content -LiteralPath $ReportFile

# Output the name of the report file to the Information stream
Write-Information $ReportFile

# Report common issues with NTFS permissions (formatted as XML for PRTG)
# TODO: Users with ownership
$NtfsIssueParams = @{
    FolderPermissions     = $FolderPermissions
    UserPermissions       = $Accounts
    GroupNamingConvention = $GroupNamingConvention
}
$NtfsIssues = New-NtfsAclIssueReport @NtfsIssueParams

# Format the information as a custom XML sensor for Paessler PRTG Network Monitor
$XMLOutput = Get-PrtgXmlSensorOutput -NtfsIssues $NtfsIssues

# Save the result of the custom XML sensor for Paessler PRTG Network Monitor
$XmlFile = "$LogDir\PrtgSensorResult.xml"
$XMLOutput | Set-Content -LiteralPath $XmlFile

# Output the name of the report file to the Information stream
Write-Information $XmlFile

# Send the XML to a PRTG Custom XML Push sensor for tracking
$PrtgSensorParams = @{
    XmlOutput          = $XMLOutput
    PrtgProbe          = $PrtgProbe
    PrtgSensorProtocol = $PrtgSensorProtocol
    PrtgSensorPort     = $PrtgSensorPort
    PrtgSensorToken    = $PrtgSensorToken
}
Send-PrtgXmlSensorOutput @PrtgSensorParams

# Open the HTML report file (useful only interactively)
if ($OpenReportAtEnd) {
    Invoke-Item $ReportFile
}

Stop-Transcript  *>$null

# Output the XML so the script can be directly used as a PRTG sensor
# Caution: This use may be a problem for a PRTG probe because of how long the script can run on large folders/domains
# Recommendation: Specify the appropriate parameters to run this as a PRTG push sensor instead
return $XMLOutput