IISSecurity.psm1

Write-Verbose 'Importing from [C:\MyProjects\IISSecurity\IISSecurity\private]'
# .\IISSecurity\private\CheckPathExists.ps1
function CheckPathExists([string] $Path) {
    Set-StrictMode -Version Latest
    
    if ([string]::IsNullOrWhiteSpace($Path)) {
        return $true
    }

    if (-not(Test-Path $Path)) {
        throw "Path '$Path' not found"
    }
    $true
}
# .\IISSecurity\private\Start-Executable.ps1
function Start-Executable
{
    <#
    .SYNOPSIS
    Execute executable and pipe the output to the powershell pipeline
      
    .DESCRIPTION
    executable and pipe the output to the powershell pipeline
      
    .PARAMETER FilePath
    The path to the executable
      
    .PARAMETER ArgumentList
    The arguments to pass to the executable
      
    .EXAMPLE
    $params = @(
      '`"C:\Some Path\node_modules\gulp\bin\gulp.js`"'
      '--gulpfile'
      "`"$somePath`""
    )
    Start-Executable node $params

    Description
    -----------
    Runs nodeJS passing the JS file (gulp.js) to execute and the arguments that this JS file requires
      
    .NOTES
    $LASTEXITCODE PS variable will be assigned the exit returned by the invocation of the executable
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [String] $FilePath,
        
        [String[]] $ArgumentList
    )
    begin
    {
        Set-StrictMode -Version 'Latest'
        $callerEA = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'
    }
    process
    {
        try
        {
            $OFS = " "
            $process = New-Object System.Diagnostics.Process
            $process.StartInfo.FileName = $FilePath
            $process.StartInfo.Arguments = $ArgumentList
            $process.StartInfo.UseShellExecute = $false
            $process.StartInfo.RedirectStandardOutput = $true
            $process.StartInfo.RedirectStandardError = $true

            if ($PSCmdlet.ShouldProcess("$FilePath $ArgumentList", 'Execute command line') -and $process.Start() )
            {
                $output = $process.StandardOutput.ReadToEnd() -replace "\r\n$", ""
                $pipelineOutput = if ( $output )
                {
                    if ( $output.Contains("`r`n") )
                    {
                        $output -split "`r`n"
                    }
                    elseif ( $output.Contains("`n") )
                    {
                        $output -split "`n"
                    }
                    else
                    {
                        $output
                    }
                }
                $pipelineOutput
                $process.WaitForExit()
                & "$Env:SystemRoot\system32\cmd.exe" `
                    /c exit $process.ExitCode
                if ($process.ExitCode -gt 0)
                {
                    $errorOutput = $process.StandardError.ReadToEnd()
                    if ([string]::IsNullOrWhiteSpace($errorOutput)) {
                        $errorOutput = $pipelineOutput | Where-Object { $_ -Like '*error*' } | Select-Object -Last 5 |
                        Out-String
                    }
                    $errorMsg = "Error executing '$FilePath'$([System.Environment]::NewLine)"
                    $errorMsg += "Command parameters '$ArgumentList'$([System.Environment]::NewLine)"
                    $errorMsg += "Exit code: $($process.ExitCode)$([System.Environment]::NewLine)"
                    if (![string]::IsNullOrWhiteSpace($errorOutput)) {
                        $errorMsg += "Error details:$([System.Environment]::NewLine)"
                        $errorMsg += $errorOutput
                    }
                    throw [System.Exception]::new($errorMsg)
                }
            }
        }
        catch
        {
            Write-Error -ErrorRecord $_ -EA $callerEA
        }
    }
}

# .\IISSecurity\private\Test-SID.ps1
function Test-SID {
    param(
        [Parameter(Mandatory)]
        [string] $Name
    )

    try {
        [System.Security.Principal.SecurityIdentifier]::new($Name)
        $true
    }
    catch [System.ArgumentException] {
        $false
    }
}
# .\IISSecurity\private\ValidateAclPaths.ps1
function ValidateAclPaths {
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [PsCustomObject[]] $Permissions,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $ErrorMessage
    )
    Set-StrictMode -Version Latest

    $Permissions | Select-Object -Exp Path | 
        Where-Object { -not(Test-Path $_ ) } -OutVariable missingPaths | 
        ForEach-Object { Write-Warning "Path not found: '$_'" }
    if ($missingPaths) {
        throw $ErrorMessage
    }
}
Write-Verbose 'Importing from [C:\MyProjects\IISSecurity\IISSecurity\public]'
# .\IISSecurity\public\Get-IISSiteDesiredAcl.ps1
function Get-IISSiteDesiredAcl
{
    <#
    .SYNOPSIS
    Returns the least privilege file/folder permissions that should be granted to an IIS AppPool useracount

    .DESCRIPTION
    Returns the least privilege file/folder permissions that should be granted to an IIS AppPool useracount

    .PARAMETER SitePath
    The physical Website path. Omit this path when configuring the permissions of a child web application only

    .PARAMETER AppPath
    The physical Web application path. A path relative to SitePath can be supplied. Defaults to SitePath

    .PARAMETER ModifyPaths
    Additional paths to remove permissions. Path(s) relative to AppPath can be supplied

    .PARAMETER ExecutePaths
    Additional paths to remove permissions. Path(s) relative to AppPath can be supplied
    
    .PARAMETER SiteShellOnly
    Permissions used for 'SitePath' should only be to that folder and it's files but NOT subfolders
    
    .PARAMETER SkipTempAspNetFiles
    Permissions should not be granted to Temporary ASP.NET Files folder(s)

    .EXAMPLE
    Get-CaccaIISSiteDesiredAcl -SitePath 'C:\inetpub\wwwroot'

    Description
    -----------
    Return file permissions for a site

    .EXAMPLE
    Get-CaccaIISSiteDesiredAcl -SitePath 'C:\inetpub\wwwroot' -AppPath 'MyWebApp1'

    Description
    -----------
    Return file permissions for a site and child web application

    .EXAMPLE
    Get-CaccaIISSiteDesiredAcl -AppPath 'C:\Apps\MyWebApp1' -ModifyPaths 'App_Data'

    Description
    -----------
    Return file permissions for a child web application only

    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline)]
        [string] $SitePath,

        [Parameter(ValueFromPipeline)]
        [string] $AppPath,

        [Parameter(ValueFromPipeline)]
        [ValidateNotNull()]
        [string[]] $ModifyPaths = @(),

        [Parameter(ValueFromPipeline)]
        [ValidateNotNull()]
        [string[]] $ExecutePaths = @(),

        [switch] $SiteShellOnly,

        [switch] $SkipTempAspNetFiles
    )
    begin
    {
        Set-StrictMode -Version Latest
        Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        $callerEA = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'

        function ToIcaclsPermission ([string] $Path, [string] $Permission, [string] $Description)
        {
            [PsCustomObject] @{
                Path        = $Path
                Permission  = $Permission
                Description = $Description
            }
        }

        function Add([System.Collections.Specialized.OrderedDictionary]$Results, [PSCustomObject] $Permission)
        {
            $key = Join-Path $Permission.Path '\'
            if ($Results.Contains($key))
            {
                $original = $Results[$key]
                Write-Warning "Path '$($Permission.Path)' is the target of multiple permission assignments; was: '$($original.Description)'; now: '$($Permission.Description)'"
            }
            $Results[$key] = $Permission
        }
    }

    process
    {
        try
        {
            if ([string]::IsNullOrWhiteSpace($SitePath) -and ![System.IO.Path]::IsPathRooted($AppPath))
            {
                throw "AppPath must be a full path if SitePath is omitted"
            }

            if ([string]::IsNullOrWhiteSpace($SitePath) -and [string]::IsNullOrWhiteSpace($AppPath))
            {
                throw "SitePath and/or AppPath must be supplied"
            }

            if ([string]::IsNullOrWhiteSpace($SitePath) -and $SiteShellOnly.IsPresent)
            {
                throw "SiteShellOnly must be used in conjunction with the SitePath parameter"
            }

            # ensure consistent trailing backslashs
            if (![string]::IsNullOrWhiteSpace($SitePath))
            {
                $SitePath = Join-Path $SitePath '\'
            }
            if (![string]::IsNullOrWhiteSpace($AppPath))
            {
                $AppPath = Join-Path $AppPath '\'
            }

            $appFullPath = if ([string]::IsNullOrWhiteSpace($SitePath) -or [System.IO.Path]::IsPathRooted($AppPath))
            {
                $AppPath
            }
            else
            {
                Join-Path $SitePath $AppPath
            }

            $getAppSubPath = {
                if ([System.IO.Path]::IsPathRooted($_))
                {
                    $_
                }
                else
                {
                    Join-Path $appFullPath $_
                }
            }

            $permissions = [ordered]@{}
            if ([string]::IsNullOrWhiteSpace($AppPath) -or $SitePath -eq $appFullPath)
            {
                # Site only...

                if ($SiteShellOnly)
                {
                    Add $permissions (ToIcaclsPermission $SitePath '(OI)(NP)R' 'read permission to this folder and files (no inherit)')
                }
                else
                {
                    Add $permissions (ToIcaclsPermission $appFullPath '(OI)(CI)R' 'read permission (inherit)')
                }                
            }
            elseif ([string]::IsNullOrWhiteSpace($SitePath))
            {

                # App only...

                Add $permissions (ToIcaclsPermission $appFullPath '(OI)(CI)R' 'read permission (inherit)')
            }
            else
            {
                # Site and app...

                if ($PSBoundParameters.ContainsKey('SiteShellOnly') -and !$SiteShellOnly)
                {
                    Add $permissions (ToIcaclsPermission $SitePath '(OI)(CI)R' 'read permission (inherit)')
                }
                else
                {
                    Add $permissions (ToIcaclsPermission $SitePath '(OI)(NP)R' 'read permission to this folder and files (no inherit)')
                }
                Add $permissions (ToIcaclsPermission $appFullPath '(OI)(CI)R' 'read permission (inherit)')
            }
            $ExecutePaths | ForEach-Object $getAppSubPath | ForEach-Object {
                Add $permissions (ToIcaclsPermission $_ '(OI)(CI)(RX)' 'read+execute permission (inherit)')
            }
            $ModifyPaths | ForEach-Object $getAppSubPath | ForEach-Object {
                Add $permissions (ToIcaclsPermission $_ '(OI)(CI)M' 'modify permission (inherit)')
            }
            if (!$SkipTempAspNetFiles)
            {
                Get-TempAspNetFilesPath | ForEach-Object {
                    Add $permissions (ToIcaclsPermission $_ '(OI)(CI)R' 'read permission (inherit)')
                }
            }
            $permissions.Values
        }
        catch
        {
            Write-Error -ErrorRecord $_ -EA $callerEA
        }
    }
}
# .\IISSecurity\public\Get-TempAspNetFilesPath.ps1
function Get-TempAspNetFilesPath
{
    <#
    .SYNOPSIS
    Return path(s) to every 'Temporary ASP.NET Files' folder
    
    .DESCRIPTION
    Return path(s) to every 'Temporary ASP.NET Files' folder
    
    .EXAMPLE
    Get-CaccaTempAspNetFilesPaths

    #>
  
    [CmdletBinding()]
    param()
    Set-StrictMode -Version Latest
    Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
    
    $aspNetTempFolder = 'C:\Windows\Microsoft.NET\Framework*\v*\Temporary ASP.NET Files'
    Get-ChildItem $aspNetTempFolder | Select-Object -Exp FullName
}
# .\IISSecurity\public\Remove-IISSiteAcl.ps1
function Remove-IISSiteAcl
{
    <#
    .SYNOPSIS
    Remove the permissions that Set-IISSiteAcl grants to the AppPool Identity

    .DESCRIPTION
    Remove the permissions that Set-IISSiteAcl grants to the AppPool Identity

    .PARAMETER SitePath
    The physical Website path. Omit this path when configuring the permissions of a child web application only

    .PARAMETER AppPath
    The physical Web application path. A path relative to SitePath can be supplied. Defaults to SitePath

    .PARAMETER AppPoolIdentity
    The name of the User account whose permissions are to be removed

    .PARAMETER ModifyPaths
    Additional paths to remove permissions. Path(s) relative to AppPath can be supplied

    .PARAMETER ExecutePaths
    Additional paths to remove permissions. Path(s) relative to AppPath can be supplied

    .PARAMETER SiteShellOnly
    Ignored
    
    .PARAMETER SkipMissingPaths
    Skip check that the file path(s) supplied must exist?
    
    .PARAMETER SkipTempAspNetFiles
    Don't remove permissions from 'Temporary ASP.NET Files'?

    .EXAMPLE
    Remove-CaccaIISSiteAcl -SitePath 'C:\inetpub\wwwroot' -AppPoolIdentity 'IIS AppPool\MyWebApp1-AppPool'

    Description
    -----------
    Remove AppPool Identity file permissions from a site

    .EXAMPLE
    Remove-CaccaIISSiteAcl -SitePath 'C:\inetpub\wwwroot' -AppPath 'MyWebApp1' -AppPoolIdentity 'IIS AppPool\MyWebApp1-AppPool'

    Description
    -----------
    Remove AppPool Identity file permissions from site and a child web application

    .EXAMPLE
    Remove-CaccaIISSiteAcl -AppPath 'C:\Apps\MyWebApp1' -AppPoolIdentity 'mydomain\myuser' -ModifyPaths 'App_Data'

    Description
    -----------
    Remove AppPool Identity file permissions from a child web application only

    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string] $AppPoolIdentity,

        [Parameter(ValueFromPipeline)]
        [ValidateScript( {CheckPathExists $_})]
        [string] $SitePath,

        [Parameter(ValueFromPipeline)]
        [string] $AppPath,

        [Parameter(ValueFromPipeline)]
        [ValidateNotNull()]
        [string[]] $ModifyPaths = @(),

        [Parameter(ValueFromPipeline)]
        [ValidateNotNull()]
        [string[]] $ExecutePaths = @(),
        
        [switch] $SiteShellOnly,
        
        [switch] $SkipMissingPaths,

        [switch] $SkipTempAspNetFiles
    )
    begin
    {
        Set-StrictMode -Version Latest
        Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        $callerEA = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'
    }

    process
    {
        try
        {

            $paths = @{
                SitePath            = $SitePath
                AppPath             = $AppPath
                ModifyPaths         = $ModifyPaths
                ExecutePaths        = $ExecutePaths
                SiteShellOnly       = $SiteShellOnly
                SkipTempAspNetFiles = $SkipTempAspNetFiles
            }
            $permissions = Get-IISSiteDesiredAcl @paths | Where-Object { $SkipMissingPaths -eq $false -or (Test-Path $_.Path) }

            ValidateAclPaths $permissions 'Cannot remove permissions; missing paths detected'

            $permissions | Remove-UserFromAcl -IdentityReference $AppPoolIdentity
        }
        catch
        {
            Write-Error -ErrorRecord $_ -EA $callerEA
        }
    }
}
# .\IISSecurity\public\Remove-UserFromAcl.ps1
#Requires -RunAsAdministrator

function Remove-UserFromAcl
{
    <#
    .SYNOPSIS
    Remove a Windows account from the ACL of a specified file path
    
    .DESCRIPTION
    Remove a Windows account from the ACL of a specified file path.

    *IMPORTANT* Any ACL permissions inherited from paths higher in the tree will NOT be removed
    
    .PARAMETER IdentityReference
    The Windows account to remove
    
    .PARAMETER Path
    The target file path
    
    .EXAMPLE
    Remove-CaccaUserFromAcl 'mydomain\myuser' C:\Some\Path

    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        $IdentityReference,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [string] $Path
    )
    
    begin
    {
        Set-StrictMode -Version 'Latest'
        Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        $callerEA = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'
    }
    
    process
    {
        try
        {

            if ($PSCmdlet.ShouldProcess($Path, "Removing '$IdentityReference'"))
            {
                
                # note: Where-Object we're ignoring errors. In essence we are skipping any user object
                # (IdentityReference) that can no longer be translated to a string, probably because it is "unknown"
                $acl = (Get-Item $_.Path).GetAccessControl('Access')
                $acl.Access | 
                    Where-Object { $_.IsInherited -eq $false -and $_.IdentityReference -eq $IdentityReference } -EA Ignore |
                    ForEach-Object { $acl.RemoveAccessRuleAll($_) }
                Set-Acl -Path ($_.Path) -AclObject $acl
            }

        }
        catch
        {
            Write-Error -ErrorRecord $_ -EA $callerEA
        }
    }
}
# .\IISSecurity\public\Set-IISSiteAcl.ps1
function Set-IISSiteAcl
{
    <#
    .SYNOPSIS
    Set least privilege file/folder permissions to an IIS AppPool Useracount

    .DESCRIPTION
    Set least privilege file folder permissions on site and/or application file path
    to the useraccount that is configured as the identity of an IIS AppPool.

    These bare minium permissions include:
    - SitePath: Read 'This folder', file and subfolder permissions (inherited)
        - Note: use 'SiteShellOnly' to reduce these permissions to just the folder and files but NOT subfolders
    - AppPath: Read 'This folder', file and subfolder permissions (inherited)
    - Temporary ASP.NET Files: Read 'This folder', file and subfolder permissions (inherited)
    - ModifyPaths: modify 'This folder', file and subfolder permissions (inherited)
    - ExecutePaths: read+execute file (no inherit)

    .PARAMETER SitePath
    The physical Website path. Omit this path when configuring the permissions of a child web application only

    .PARAMETER AppPath
    The physical Web application path. A path relative to SitePath can be supplied. Defaults to SitePath

    .PARAMETER AppPoolIdentity
    The name of the User account whose permissions are to be granted

    .PARAMETER ModifyPaths
    Additional paths to grant modify (inherited) permissions. Path(s) relative to AppPath can be supplied

    .PARAMETER ExecutePaths
    Additional paths to grant read+excute permissions. Path(s) relative to AppPath can be supplied

    .PARAMETER SiteShellOnly
    Grant permissions used for 'SitePath' to only that folder and it's files but NOT subfolders

    .EXAMPLE
    Set-CaccaIISSiteAcl -SitePath 'C:\inetpub\wwwroot' -AppPoolIdentity 'MyWebApp1-AppPool'

    Description
    -----------
    Grant site file permissions to AppPoolIdentity

    .EXAMPLE
    Set-CaccaIISSiteAcl -SitePath 'C:\inetpub\wwwroot' -AppPath 'MyWebApp1' -AppPoolIdentity 'IIS AppPool\MyWebApp1-AppPool'

    Description
    -----------
    Grant site and chid application file permissions to AppPoolIdentity

    .EXAMPLE
    Set-CaccaIISSiteAcl -AppPath 'C:\Apps\MyWebApp1' -AppPoolIdentity 'mydomain\myuser' -ModifyPaths 'App_Data'

    Description
    -----------
    Grant child application only file permissions to a specific user. Include folders that require modify permissions

    #>
    
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string] $AppPoolIdentity,

        [Parameter(ValueFromPipeline)]
        [ValidateScript( {CheckPathExists $_})]
        [string] $SitePath,
    
        [Parameter(ValueFromPipeline)]
        [string] $AppPath,
    
        [Parameter(ValueFromPipeline)]
        [ValidateNotNull()]
        [string[]] $ModifyPaths = @(),
    
        [Parameter(ValueFromPipeline)]
        [ValidateNotNull()]
        [string[]] $ExecutePaths = @(),

        [switch] $SiteShellOnly
    )
    begin
    {
        Set-StrictMode -Version Latest
        Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        $callerEA = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'
    }
    
    process
    {
        try
        {
    
            $paths = @{
                SitePath      = $SitePath
                AppPath       = $AppPath
                ModifyPaths   = $ModifyPaths
                ExecutePaths  = $ExecutePaths
                SiteShellOnly = $SiteShellOnly
            }
            $permissions = Get-IISSiteDesiredAcl @paths

            ValidateAclPaths $permissions 'Cannot grant permissions; missing paths detected'

            $identity = if (Test-SID $AppPoolIdentity) {
                "*$AppPoolIdentity"
            } else {
                "`"$AppPoolIdentity`""
            }

            $permissions | ForEach-Object {
                if ($PSCmdlet.ShouldProcess($_.Path, "Granting '$AppPoolIdentity' $($_.Description)"))
                {
                    $sanitisedPath = $_.Path.TrimEnd('\')
                    $params = @(
                        "`"$sanitisedPath`""
                        '/grant:r'
                        "$identity`:$($_.Permission)"
                    )
                    Start-Executable icacls $params | Out-Null
                }
            }
        }
        catch
        {
            Write-Error -ErrorRecord $_ -EA $callerEA
        }
    }
}
# .\IISSecurity\public\Set-WebHardenedAcl.ps1
function Set-WebHardenedAcl
{
    <#
    .SYNOPSIS
    Remove default user and group file permissions added by windows

    .DESCRIPTION
    Remove from 'Path' supplied, the default user and group file permissions added by windows

    Users/groups file permissions removed:
    * Authenticated Users
    * Users
    * IIS_IUSRS
    * NETWORK SERVICE

    .PARAMETER Path
    The path to target permission removal

    .PARAMETER SiteAdminsGroup
    Optional user/group name to assign full permissions (inherited) to 'Path'

    .EXAMPLE
    Set-CaccaWebHardenedAcl -Path C:\inetpub -SiteAdminsGroup 'mydomain\mygroup'

    .NOTES
    This script must be run with administrator privileges.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateScript( {CheckPathExists $_})]
        [string] $Path,
        
        [Parameter(ValueFromPipeline)]
        [string] $SiteAdminsGroup
    )

    begin
    {
        Set-StrictMode -Version Latest
        Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
        $callerEA = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'
    }

    process
    {
        try
        {

            # make sure the right people can administer the web server (before we start removing permissions below)
            if (![string]::IsNullOrWhiteSpace($SiteAdminsGroup))
            {
                if ($PSCmdlet.ShouldProcess($Path, "Granting '$SiteAdminsGroup' full permission (inherit)"))
                {
                    icacls ("$Path") /grant ("$SiteAdminsGroup" + ':(OI)(CI)F') | Out-Null
                }
            }

            # harden web server ACL's...
            
            $usersToRemove = 'NT AUTHORITY\Authenticated Users', 'BUILTIN\Users', 'BUILTIN\IIS_IUSRS', 'NT AUTHORITY\NETWORK SERVICE'

            if ($PSCmdlet.ShouldProcess($Path, 'Disabling permission inheritance'))
            {
                icacls ("$Path") /inheritance:d | Out-Null
            }
            $usersToRemove | ForEach-Object {
                if ($PSCmdlet.ShouldProcess($Path, "Removing user '$_'"))
                {
                    icacls ("$Path") /remove:g ("$_") /remove:d ("$_") | Out-Null
                }
            }       
        }
        catch
        {
            Write-Error -ErrorRecord $_ -EA $callerEA
        }
    }
}
Write-Verbose 'Importing from [C:\MyProjects\IISSecurity\IISSecurity\classes]'