Tests/Pester.PSSecRules.Tests.ps1

BeforeAll {
    function RunRuleForCommand
    {
        param([String] $Command)

        $outputPath = Join-Path $env:TEMP ([IO.Path]::GetRandomFileName() + ".ps1")
        try
        {
            Set-Content -Path $outputPath -Value $Command

            Invoke-ScriptAnalyzer -Path $outputPath `
                -CustomizedRulePath (Resolve-Path $PSScriptRoot\..\PSSecRules.psm1) `
                -ExcludeRule PS*
        }
        finally
        {
            Remove-Item $outputPath -ErrorAction SilentlyContinue
        }
    }
}

Describe "Tests for PSSec.HardcodedCredential" {

    It "Should detect hardcoded password assignment" {
        $result = RunRuleForCommand {
            function Test-HardcodedCredential
            {
                $password = "sRbHG$a%"
                return $password.Length
            }
        }

        $result.RuleName | Should -Be "PSSec.HardcodedCredential"
    }

    It "Should detect hardcoded default credential parameter" {
        $result = RunRuleForCommand {
            function Test-HardcodedCredential
            {
                param([string]$ClientSecret = "my-fixed-secret")
                return $ClientSecret
            }
        }

        $result.RuleName | Should -Be "PSSec.HardcodedCredential"
    }

    It "Should detect hardcoded credential comparison" {
        $result = RunRuleForCommand {
            function Test-Login
            {
                param($userName, $pwdInput)
                if($userName -eq "admin" -and $pwdInput -eq "sRbHG$a%")
                {
                    return $true
                }
                return $false
            }
        }

        $result.RuleName | Should -Be "PSSec.HardcodedCredential"
    }
}

Describe "Tests for PSSec.SqlInjection" {

    It "Should detect dynamic SQL with string interpolation" {
        $result = RunRuleForCommand {
            function Invoke-UnsafeSql
            {
                param($userInput)
                $query = "SELECT * FROM Users WHERE UserName = '$userInput'"
                Invoke-Sqlcmd -Query $query
            }
        }

        $result.RuleName | Should -Be "PSSec.SqlInjection"
    }

    It "Should detect CommandText assignment with concatenation" {
        $result = RunRuleForCommand {
            function Invoke-UnsafeSql
            {
                param($userInput)
                $cmd = New-Object psobject
                $cmd.CommandText = "SELECT * FROM Users WHERE UserName='" + $userInput + "'"
                return $cmd
            }
        }

        $result.RuleName | Should -Be "PSSec.SqlInjection"
    }
}

Describe "Tests for PSSec.PathTraversal" {

    It "Should detect tainted variable used as Get-Content path" {
        $result = RunRuleForCommand {
            function Get-UnsafeFileContent
            {
                param($pathInput)
                Get-Content -Path $pathInput
            }
        }

        $result.RuleName | Should -Be "PSSec.PathTraversal"
    }

    It "Should detect tainted variable in .NET file API" {
        $result = RunRuleForCommand {
            function Test-EngineSchema
            {
                param($pathInput)

                if($null -ne $pathInput)
                {
                    $json = [System.IO.File]::ReadAllText($pathInput)
                    return $json
                }
            }
        }

        $result.RuleName | Should -Be "PSSec.PathTraversal"
    }
}

Describe "Tests for PSSec.Xss" {

    It "Should detect tainted URL passed to Navigate" {
        $result = RunRuleForCommand {
            function Open-HtmlPage
            {
                param($urlQuery)

                $urlRequest = "http://mysite.com?" + $urlQuery
                $browser = New-Object psobject
                $browser.Navigate($urlRequest)
            }
        }

        $result.RuleName | Should -Be "PSSec.Xss"
    }

    It "Should detect tainted comment rendered via Write" {
        $result = RunRuleForCommand {
            function Show-Comment
            {
                param($userComment)
                $response = New-Object psobject
                $response.Write($userComment)
            }
        }

        $result.RuleName | Should -Be "PSSec.Xss"
    }

    It "Should allow encoded output before sink" {
        $result = RunRuleForCommand {
            function Open-HtmlPage
            {
                param($urlQuery)

                $encoded = [System.Net.WebUtility]::HtmlEncode($urlQuery)
                $browser = New-Object psobject
                $browser.Navigate($encoded)
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.InsecureDeserialization" {

    It "Should detect tainted json passed to JsonSerializer.Deserialize" {
        $result = RunRuleForCommand {
            function Test-EngineSchema
            {
                param($pathInput)

                if($null -ne $pathInput)
                {
                    $json = [System.IO.File]::ReadAllText($pathInput)
                    $schema = [System.Text.Json.JsonSerializer]::Deserialize($json)
                    return $schema
                }
            }
        }

        $result.RuleName | Should -Be "PSSec.InsecureDeserialization"
    }

    It "Should detect tainted input in ConvertFrom-Json" {
        $result = RunRuleForCommand {
            function Read-Config
            {
                param($jsonInput)
                $obj = ConvertFrom-Json -InputObject $jsonInput
                return $obj
            }
        }

        $result.RuleName | Should -Be "PSSec.InsecureDeserialization"
    }

    It "Should allow constant json deserialization" {
        $result = RunRuleForCommand {
            function Read-Config
            {
                $json = '{"name":"safe"}'
                $obj = ConvertFrom-Json -InputObject $json
                return $obj
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.OldTlsProtocol" {

    It "Should detect old TLS protocol enum" {
        $result = RunRuleForCommand {
            function Invoke-ClientAuth
            {
                $protocol = [System.Security.Authentication.SslProtocols]::Tls
                return $protocol
            }
        }

        $result.RuleName | Should -Be "PSSec.OldTlsProtocol"
    }

    It "Should allow TLS 1.2" {
        $result = RunRuleForCommand {
            function Invoke-ClientAuth
            {
                $protocol = [System.Security.Authentication.SslProtocols]::Tls12
                return $protocol
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.OutdatedCrypto" {

    It "Should detect SHA1 provider" {
        $result = RunRuleForCommand {
            function Get-HashValue
            {
                param($text)
                $enc = [System.Text.Encoding]::UTF8
                $buffer = $enc.GetBytes($text)
                $sha1 = New-Object System.Security.Cryptography.SHA1CryptoServiceProvider
                return $sha1.ComputeHash($buffer)
            }
        }

        $result.RuleName | Should -Be "PSSec.OutdatedCrypto"
    }

    It "Should allow SHA256 provider" {
        $result = RunRuleForCommand {
            function Get-HashValue
            {
                param($text)
                $enc = [System.Text.Encoding]::UTF8
                $buffer = $enc.GetBytes($text)
                $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider
                return $sha256.ComputeHash($buffer)
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.Xxe" {

    It "Should detect insecure XmlDocument settings with tainted input" {
        $result = RunRuleForCommand {
            function Read-XmlUnsafe
            {
                param($xmlPathInput)

                $doc = New-Object System.Xml.XmlDocument
                $doc.XmlResolver = New-Object System.Xml.XmlUrlResolver
                $doc.Load($xmlPathInput)
                return $doc.InnerText
            }
        }

        $result.RuleName | Should -Be "PSSec.Xxe"
    }

    It "Should detect insecure XmlReaderSettings with DTD parse" {
        $result = RunRuleForCommand {
            function Read-XmlUnsafe
            {
                param($xmlPathInput)

                $settings = New-Object System.Xml.XmlReaderSettings
                $settings.XmlResolver = New-Object System.Xml.XmlUrlResolver
                $settings.DtdProcessing = [System.Xml.DtdProcessing]::Parse
                $reader = [System.Xml.XmlReader]::Create($xmlPathInput, $settings)
                return $reader
            }
        }

        $result.RuleName | Should -Be "PSSec.Xxe"
    }

    It "Should allow secure XmlReaderSettings" {
        $result = RunRuleForCommand {
            function Read-XmlSafe
            {
                param($xmlPathInput)

                $settings = New-Object System.Xml.XmlReaderSettings
                $settings.XmlResolver = $null
                $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
                $reader = [System.Xml.XmlReader]::Create($xmlPathInput, $settings)
                return $reader
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.Xee" {

    It "Should detect XML entity expansion risk with unbounded settings" {
        $result = RunRuleForCommand {
            function Read-XmlUnsafe
            {
                param($xmlInput)

                $settings = New-Object System.Xml.XmlReaderSettings
                $settings.DtdProcessing = [System.Xml.DtdProcessing]::Parse
                $settings.MaxCharactersFromEntities = 0
                $reader = [System.Xml.XmlReader]::Create($xmlInput, $settings)
                return $reader
            }
        }

        $result.RuleName | Should -Be "PSSec.Xee"
    }

    It "Should allow bounded and non-parse settings" {
        $result = RunRuleForCommand {
            function Read-XmlSafe
            {
                param($xmlInput)

                $settings = New-Object System.Xml.XmlReaderSettings
                $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
                $settings.MaxCharactersFromEntities = 1024
                $reader = [System.Xml.XmlReader]::Create($xmlInput, $settings)
                return $reader
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.SessionTimeout" {

    It "Should detect negative timeout assignment" {
        $result = RunRuleForCommand {
            function Set-Session
            {
                $session = [PSCustomObject]@{}
                $session.Timeout = -1
                return $session
            }
        }

        $result.RuleName | Should -Be "PSSec.SessionTimeout"
    }

    It "Should detect excessive timeout assignment" {
        $result = RunRuleForCommand {
            function Set-Session
            {
                $session = [PSCustomObject]@{}
                $session.Timeout = 120
                return $session
            }
        }

        $result.RuleName | Should -Be "PSSec.SessionTimeout"
    }

    It "Should allow normal timeout assignment" {
        $result = RunRuleForCommand {
            function Set-Session
            {
                $session = [PSCustomObject]@{}
                $session.Timeout = 30
                return $session
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.Ssrf" {

    It "Should detect tainted URL in Invoke-WebRequest" {
        $result = RunRuleForCommand {
            function Invoke-Remote
            {
                param($url)
                Invoke-WebRequest -Uri $url
            }
        }

        $result.RuleName | Should -Be "PSSec.Ssrf"
    }

    It "Should detect tainted endpoint in Invoke-RestMethod" {
        $result = RunRuleForCommand {
            function Invoke-Remote
            {
                param($endpoint)
                $target = "http://$endpoint/api"
                Invoke-RestMethod -Uri $target
            }
        }

        $result.RuleName | Should -Be "PSSec.Ssrf"
    }

    It "Should allow constant URL request" {
        $result = RunRuleForCommand {
            function Invoke-Remote
            {
                $uri = "https://api.example.com/status"
                Invoke-RestMethod -Uri $uri
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.LogInjection" {

    It "Should detect tainted message in Write-EventLog" {
        $result = RunRuleForCommand {
            function Write-AppEvent
            {
                param($message)
                Write-EventLog -LogName Application -Source Demo -EntryType Information -EventId 1 -Message $message
            }
        }

        $result.RuleName | Should -Be "PSSec.LogInjection"
    }

    It "Should detect tainted message in Add-Content" {
        $result = RunRuleForCommand {
            function Write-AppLog
            {
                param($userInput)
                $line = "user=$userInput"
                Add-Content -Path .\app.log -Value $line
            }
        }

        $result.RuleName | Should -Be "PSSec.LogInjection"
    }

    It "Should allow escaped message before logging" {
        $result = RunRuleForCommand {
            function Write-AppLog
            {
                param($message)
                $safe = [System.Security.SecurityElement]::Escape($message)
                Add-Content -Path .\app.log -Value $safe
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.LdapInjection" {

    It "Should detect tainted LDAP filter assignment" {
        $result = RunRuleForCommand {
            function Search-LdapUnsafe
            {
                param($userName, $password)
                $filter = "(&(userId=$userName)(UserPassword=$password))"
                $searcher = New-Object psobject
                $searcher.Filter = $filter
            }
        }

        $result.RuleName | Should -Be "PSSec.LdapInjection"
    }

    It "Should detect tainted LDAPFilter command parameter" {
        $result = RunRuleForCommand {
            function Search-LdapUnsafe2
            {
                param($employeeName)
                $ldap = "(&(objectClass=user)(employeename=$employeeName))"
                Get-ADUser -LDAPFilter $ldap
            }
        }

        $result.RuleName | Should -Be "PSSec.LdapInjection"
    }

    It "Should allow escaped LDAP values" {
        $result = RunRuleForCommand {
            function Search-LdapSafe
            {
                param($userName, $password)
                $safeUser = [Microsoft.Security.Application.Encoder]::LdapFilterEncode($userName)
                $safePwd = [Microsoft.Security.Application.Encoder]::LdapFilterEncode($password)
                $filter = "(&(userId=$safeUser)(UserPassword=$safePwd))"
                $searcher = New-Object psobject
                $searcher.Filter = $filter
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.SensitiveErrorExposure" {

    It "Should detect stack trace exposure in catch" {
        $result = RunRuleForCommand {
            function Show-ErrorUnsafe
            {
                param($value)
                try
                {
                    [int]::Parse($value) | Out-Null
                }
                catch
                {
                    Write-Output $_.ScriptStackTrace
                }
            }
        }

        $result.RuleName | Should -Be "PSSec.SensitiveErrorExposure"
    }

    It "Should detect raw exception output in catch" {
        $result = RunRuleForCommand {
            function Show-ErrorUnsafe2
            {
                param($value)
                try
                {
                    [int]::Parse($value) | Out-Null
                }
                catch
                {
                    Write-Host $_
                }
            }
        }

        $result.RuleName | Should -Be "PSSec.SensitiveErrorExposure"
    }

    It "Should allow generic error message" {
        $result = RunRuleForCommand {
            function Show-ErrorSafe
            {
                param($value)
                try
                {
                    [int]::Parse($value) | Out-Null
                }
                catch
                {
                    Write-Output "An error has occurred."
                }
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.XPathInjection" {

    It "Should detect tainted XPath expression in Evaluate" {
        $result = RunRuleForCommand {
            function Query-XmlUnsafe
            {
                param($username, $passwordHash)
                $query = "//users/user[username/text()='$username' and passwordHash/text()='$passwordHash']/data/text()"
                $navigator = New-Object psobject
                $navigator.Evaluate($query)
            }
        }

        $result.RuleName | Should -Be "PSSec.XPathInjection"
    }

    It "Should detect tainted XPath in Select-Xml" {
        $result = RunRuleForCommand {
            function Query-XmlUnsafe2
            {
                param($xpathExpr)
                Select-Xml -Path .\users.xml -XPath $xpathExpr
            }
        }

        $result.RuleName | Should -Be "PSSec.XPathInjection"
    }

    It "Should allow escaped XPath values" {
        $result = RunRuleForCommand {
            function Query-XmlSafe
            {
                param($username, $passwordHash)
                $safeUser = [System.Security.SecurityElement]::Escape($username)
                $safeHash = [System.Security.SecurityElement]::Escape($passwordHash)
                $query = "//users/user[username/text()='$safeUser' and passwordHash/text()='$safeHash']/data/text()"
                $navigator = New-Object psobject
                $navigator.Evaluate($query)
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.OpenRedirect" {

    It "Should detect tainted URL in Redirect" {
        $result = RunRuleForCommand {
            function Redirect-Unsafe
            {
                param($redirectUrl)
                $response = New-Object psobject
                $response.Redirect($redirectUrl)
            }
        }

        $result.RuleName | Should -Be "PSSec.OpenRedirect"
    }

    It "Should detect tainted URL in RedirectPermanent" {
        $result = RunRuleForCommand {
            function Redirect-Unsafe2
            {
                param($next)
                $url = $next
                $response = New-Object psobject
                $response.RedirectPermanent($url)
            }
        }

        $result.RuleName | Should -Be "PSSec.OpenRedirect"
    }

    It "Should allow IsLocalUrl validated redirect" {
        $result = RunRuleForCommand {
            function Redirect-Safe
            {
                param($redirectUrl)
                if([Microsoft.AspNet.Membership.OpenAuth.OpenAuth]::IsLocalUrl($redirectUrl))
                {
                    $response = New-Object psobject
                    $response.Redirect($redirectUrl)
                }
            }
        }

        $result | Should -Be $null
    }
}

Describe "Tests for PSSec.TaintedConfig" {

    It "Should detect tainted config in connection string" {
        $result = RunRuleForCommand {
            function Set-DbConfigUnsafe
            {
                param($catalog)
                $dbConnection = New-Object psobject
                $dbConnection.ConnectionString = "Data Source=.;Initial Catalog=$catalog;User ID=sa;Password=pass;"
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.TaintedConfig"
    }

    It "Should detect tainted config command argument" {
        $result = RunRuleForCommand {
            function Set-AppConfigUnsafe
            {
                param($tenant)
                Set-ItemProperty -Path HKLM:\Software\Demo -Name Config -Configuration $tenant
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.TaintedConfig"
    }

    It "Should allow allowlisted configuration value" {
        $result = RunRuleForCommand {
            function Set-DbConfigSafe
            {
                param($catalog, [System.Collections.Generic.HashSet[string]]$validCatalogNames)
                if(-not $validCatalogNames.Contains($catalog))
                {
                    return
                }

                $dbConnection = New-Object psobject
                $dbConnection.ConnectionString = "Data Source=.;Initial Catalog=$catalog;User ID=sa;Password=pass;"
            }
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.VulnerablePackage" {

    It "Should detect vulnerable Google.Protobuf version" {
        $result = RunRuleForCommand {
            $pkg = "Google.Protobuf 3.11.4"
            Write-Output $pkg
        }

        @($result.RuleName) | Should -Contain "PSSec.VulnerablePackage"
    }

    It "Should detect vulnerable SSH.NET version" {
        $result = RunRuleForCommand {
            $pkg = "SSH.NET 2016.1.0"
            Write-Output $pkg
        }

        @($result.RuleName) | Should -Contain "PSSec.VulnerablePackage"
    }

    It "Should allow non-vulnerable package version" {
        $result = RunRuleForCommand {
            $pkg = "Google.Protobuf 3.21.12"
            Write-Output $pkg
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.ReDoS" {

    It "Should detect unsafe regex with tainted input" {
        $result = RunRuleForCommand {
            function Test-ReDoSUnsafe
            {
                param($name)
                $pattern = '(x+)+y'
                [regex]::IsMatch($name, $pattern)
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.ReDoS"
    }

    It "Should detect inline unsafe regex pattern" {
        $result = RunRuleForCommand {
            function Test-ReDoSUnsafe2
            {
                param($line)
                [regex]::Match($line, '^(-?\d+)*$').Success
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.ReDoS"
    }

    It "Should allow safe regex pattern" {
        $result = RunRuleForCommand {
            function Test-ReDoSSafe
            {
                param($line)
                [regex]::IsMatch($line, '^(\d{2}-\d{2}-\d{4})$')
            }
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.NoSqlInjection" {

    It "Should detect tainted NoSQL filter string" {
        $result = RunRuleForCommand {
            function Find-UserUnsafe
            {
                param($login, $password)
                $filter = '{"$where":"function(){return this.login==''' + $login + ''' && this.password==''' + $password + ''';}"}'
                $collection = New-Object psobject
                $collection.Find($filter)
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.NoSqlInjection"
    }

    It "Should detect tainted NoSQL command filter" {
        $result = RunRuleForCommand {
            function Delete-Unsafe
            {
                param($count)
                $query = '{"$where":"function(){return this.count == ' + $count + ';}"}'
                Remove-MongoItem -Filter $query
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.NoSqlInjection"
    }

    It "Should allow typed NoSQL filter object" {
        $result = RunRuleForCommand {
            function Find-UserSafe
            {
                param($login, $password)
                $filter = @{ login = $login; password = $password }
                $collection = New-Object psobject
                $collection.Find($filter)
            }
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.ZipSlip" {

    It "Should detect tainted entry name used in ExtractToFile path" {
        $result = RunRuleForCommand {
            function Expand-ZipUnsafe
            {
                param($destinationDirectory, $entryName)
                $extractPath = [System.IO.Path]::Combine($destinationDirectory, $entryName)
                $entry = New-Object psobject
                $entry.ExtractToFile($extractPath, $true)
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.ZipSlip"
    }

    It "Should allow validated extraction full path" {
        $result = RunRuleForCommand {
            function Expand-ZipSafe
            {
                param($destinationDirectory, $entryName)
                $extractPath = [System.IO.Path]::Combine($destinationDirectory, $entryName)
                $extractFullPath = [System.IO.Path]::GetFullPath($extractPath)
                $destinationDirectoryFullPath = [System.IO.Path]::GetFullPath($destinationDirectory)
                if(-not $extractFullPath.StartsWith($destinationDirectoryFullPath))
                {
                    throw "Zip Slip"
                }

                $entry = New-Object psobject
                $entry.ExtractToFile($extractFullPath, $true)
            }
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.InvisibleCharacter" {

    It "Should detect zero-width space in code" {
        $result = RunRuleForCommand "`$value = 'ab`u{200B}cd'"

        @($result.RuleName) | Should -Contain "PSSec.InvisibleCharacter"
    }

    It "Should allow normal visible text" {
        $result = RunRuleForCommand {
            $value = 'abcd'
            Write-Output $value
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.CookieInjection" {

    It "Should detect tainted value used for HttpCookie" {
        $result = RunRuleForCommand {
            function Set-CookieUnsafe
            {
                param($userRole)
                $cookie = New-Object System.Web.HttpCookie 'role', $userRole
                return $cookie
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.CookieInjection"
    }

    It "Should allow validated cookie value" {
        $result = RunRuleForCommand {
            function Set-CookieSafe
            {
                param($cultureValue)
                if(-not [regex]::IsMatch($cultureValue, '^[a-zA-Z0-9_-]{2,20}$'))
                {
                    return
                }

                $cookie = New-Object System.Web.HttpCookie 'culture', $cultureValue
                return $cookie
            }
        }

        @($result).Count | Should -Be 0
    }
}

Describe "Tests for PSSec.ExternallyControlledFormatString" {

    It "Should detect tainted format string in string.Format" {
        $result = RunRuleForCommand {
            function Apply-UnsafeFormat
            {
                param($format, $name)
                [string]::Format($format, $name)
            }
        }

        @($result.RuleName) | Should -Contain "PSSec.ExternallyControlledFormatString"
    }

    It "Should allow constant format string" {
        $result = RunRuleForCommand {
            function Apply-SafeFormat
            {
                param($name)
                $format = "Hello {0}"
                [string]::Format($format, $name)
            }
        }

        @($result).Count | Should -Be 0
    }
}