PSSecretScanner.psm1

#region AssertParameter

function AssertParameter {
    <#
    .SYNOPSIS
        Simplifies custom error messages for ValidateScript
     
    .DESCRIPTION
        Windows PowerShell implementation of the ErrorMessage functionality available
        for ValidateScript in PowerShell core
     
    .EXAMPLE
        [ValidateScript({ Assert-Parameter -ScriptBlock {Test-Path $_} -ErrorMessage "Path not found." })]
    #>

    param(
        [Parameter(Position = 0)]
        [scriptblock] $ScriptBlock
        ,
        [Parameter(Position = 1)]
        [string] $ErrorMessage = 'Failed parameter assertion'
    )

    if (& $ScriptBlock) {
        $true
    } else {
        throw $ErrorMessage
    }
}
#endregion AssertParameter

#region ConvertToHashtable

function ConvertToHashtable {
    <#
    .SYNOPSIS
        Converts PowerShell object to hashtable
 
    .DESCRIPTION
        Converts PowerShell objects, including nested objets, arrays etc. to a hashtable
 
    .PARAMETER InputObject
        The object that you want to convert to a hashtable
 
    .EXAMPLE
        Get-Content -Raw -Path C:\Path\To\file.json | ConvertFrom-Json | ConvertTo-Hashtable
 
    .NOTES
        Based on function by Dave Wyatt found on Stack Overflow
        https://stackoverflow.com/questions/3740128/pscustomobject-to-hashtable
    #>

    param (
        [Parameter(ValueFromPipeline)]
        $InputObject
    )

    process {
        if ($null -eq $InputObject) { return $null }

        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
            $collection = @(
                foreach ($object in $InputObject) { ConvertToHashtable -InputObject $object }
            )

            Write-Output -NoEnumerate $collection
        } elseif ($InputObject -is [psobject]) {
            $hash = @{}

            foreach ($property in $InputObject.PSObject.Properties) {
                $hash[$property.Name] = ConvertToHashtable -InputObject $property.Value
            }

            $hash
        } else {
            $InputObject
        }
    }
}
#endregion ConvertToHashtable

#region GetConfig

function GetConfig {
    param (
        $ConfigPath
    )
    
    try {
        if ($PSVersionTable.PSEdition -eq 'Core') {
            $Config = Get-Content $ConfigPath -ErrorAction Stop | ConvertFrom-Json -AsHashtable
        } 
        else {
            $Config = Get-Content $ConfigPath -ErrorAction Stop -Raw | ConvertFrom-Json | ConvertToHashtable
        }
    }
    catch {
        Throw "Failed to get config. $_"
    }

    $Config
}
#endregion GetConfig

#region GetExclusions

function GetExclusions {
    param (
        $Excludelist
    )
    [string[]]$Exclusions = Get-Content $Excludelist
    $Exclusions
}
#endregion GetExclusions

$script:PSSSConfigPath = "$PSScriptRoot\config.json"


#region Find-Secret

function Find-Secret {
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param (
        [Parameter(ParameterSetName = 'Path', Position = 0)]
        [ValidateScript({ AssertParameter -ScriptBlock {Test-Path $_} -ErrorMessage "Path not found." })]
        [string[]]$Path = "$PWD",

        [Parameter(ParameterSetName = 'Path')]
        [string[]]$Filetype,

        [Parameter(ParameterSetName = 'Path')]
        [bool]$Recursive = $true,
        
        [Parameter(ParameterSetName = 'File', Position = 0)]
        [ValidateScript({ AssertParameter -ScriptBlock {Test-Path $_} -ErrorMessage "File not found." })]
        [string]$File,

        [Parameter()]
        [ValidateSet('Output','Warning','Error','Object','IgnoreSecrets')]
        [string]$OutputPreference = 'Error',

        [Parameter()]
        [string]$ConfigPath = $script:PSSSConfigPath,

        [Parameter()]
        [ValidateScript({ AssertParameter -ScriptBlock {Test-Path $_} -ErrorMessage "Excludelist path not found." })]
        [string]$Excludelist
    )

    $Config = GetConfig -ConfigPath $ConfigPath

    switch ($PSCmdLet.ParameterSetName) {
        'Path' { 
            if ( ($Path.Count -eq 1) -and ((Get-Item $Path[0]) -is [System.IO.FileInfo]) ) {
                [Array]$ScanFiles = Get-ChildItem $Path[0] -File 
            }
            else {
                if ($Filetype -and $Filetype.Contains('*')) {
                    [Array]$ScanFiles = Get-ChildItem $Path -File -Recurse:$Recursive
                }
                elseif ($Filetype) {
                    $ScanExtensions = $Filetype | ForEach-Object {
                        if (-not $_.StartsWith('.')) {
                            ".$_"
                        }
                        else {
                            $_
                        }
                    }
                    [Array]$ScanFiles = Get-ChildItem $Path -File -Recurse:$Recursive | Where-Object -Property Extension -in $ScanExtensions
                
                }
                else {
                    [Array]$ScanFiles = Get-ChildItem $Path -File -Recurse:$Recursive | Where-Object -Property Extension -in $Config['fileextensions']
                }
            }
         }
        'File' {
            [Array]$ScanFiles = Get-ChildItem $File -File 
        }
    }

    if ($ScanFiles.Count -ge 1) {
        Write-Verbose "Scanning files:`n$($ScanFiles.FullName -join ""`n"")"

        $Res = $Config['regexes'].Keys | ForEach-Object {
            $RegexName = $_
            $Pattern = ($Config['regexes'])."$RegexName"

            Write-Verbose "Performing $RegexName scan`nPattern '$Pattern'`n"

            Get-Item $ScanFiles.FullName | Select-String -Pattern $Pattern
        }
        
        if (-not [string]::IsNullOrEmpty($Excludelist)) {
            [string[]]$Exclusions = GetExclusions $Excludelist
            Write-Verbose "Using excludelist $Excludelist. Found $($Exclusions.Count) exlude strings."

            $Res = $Res | Where-Object {
                "$($_.Path);$($_.LineNumber);$($_.Line)" -notin $Exclusions
            }
        }
        
        $Result = "Found $($Res.Count) strings.`n"

        if ($res.Count -gt 0) {
            if ($OutputPreference -eq 'IgnoreSecrets') {
                $Result = [string]::Empty
                foreach ($line in $res) {
                    $Result += "$($line.Path);$($line.LineNumber);$($line.Line)`n"
                }
            }
            else {
                $Result += "Path`tLine`tLineNumber`tPattern`n"
                foreach ($line in $res) {
                    $Result += "$($line.Path)`t$($line.Line)`t$($line.LineNumber)`t$($line.Pattern)`n"
                }
            }
        }
    }
    else {
        $Result = 'Found no files to scan'
        $res = @()
    }
        switch ($OutputPreference) {
            'Output'  { Write-Output $Result }
            'IgnoreSecrets'  { Write-Output $Result }
            'Warning' { Write-Warning $Result }
            'Error'   { Write-Error $Result }
            'Object'  { $res }
        }
}
#endregion Find-Secret

#region New-PSSSConfig

function New-PSSSConfig {
    param (
        [Parameter(Mandatory)]
        [string]$Path
    )

    $ConfigFileName = Split-Path $script:PSSSConfigPath -leaf

    $InvokeSplat = @{
        Path = $script:PSSSConfigPath
        Destination = $Path 
    }

    if (Test-Path (Join-Path -Path $Path -ChildPath $ConfigFileName)) {
        Write-Warning 'Config file already exists!'
        $InvokeSplat.Add('Confirm',$true)
    }

    Copy-Item @InvokeSplat
}
#endregion New-PSSSConfig

#region Write-SecretStatus

function Write-SecretStatus {
    param ()
    
    try {
        [array]$IsGit = (git status *>&1).ToString()
        if ( $IsGit[0] -eq 'fatal: not a git repository (or any of the parent directories): .git' ) {
            break
        }
        else {
            $FindSplat = @{
                Recursive = $false
                OutputPreference = 'Object'
            }

            $ExcludePath = Join-Path -Path  (git rev-parse --show-toplevel) -ChildPath '.ignoresecrets'
            if (Test-Path $ExcludePath) {
                $FindSplat.Add('Excludelist',$ExcludePath)
            }

            $Secrets = Find-Secret @FindSplat
            $SecretsCount = $Secrets.Count

            if ((Get-Command Prompt).ModuleName -eq 'posh-git') {
                if ($SecretsCount -ge 1) {
                    $GitPromptSettings.DefaultPromptBeforeSuffix.ForegroundColor = 'Red'
                }
                else {
                    $GitPromptSettings.DefaultPromptBeforeSuffix.ForegroundColor = 'LightBlue'
                }
            }
            
            Write-Output "[$SecretsCount]" 
        }
    }
    catch {}
}
#endregion Write-SecretStatus