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 | Where-Object {$_ -and $_ -notlike "#*"}

    [System.Collections.Generic.List[HashTable]]$ExcludeResults = @()

    foreach ($e in $Exclusions) {
        $eObj = ConvertFrom-Csv -InputObject $e -Delimiter ';' -Header 'Path', 'LineNumber', 'Line'

        # Normalize path
        $eObj.Path = $eObj.Path -replace '[\\\/]', [IO.Path]::DirectorySeparatorChar
        
        if ($eObj.Path -match '^\..*') {
            # Path starts with '.', is relative. Replace with root folder
            $BasePath = split-path (Resolve-Path $Excludelist).Path 
            $eobj.Path = $eobj.Path -replace '^\.', $BasePath
        }

        if ([string]::IsNullOrEmpty($eObj.LineNumber) -and [string]::IsNullOrEmpty($eObj.Line)) {
            # Path or fileexclusion
            if ($eObj.Path -match '.*\\\*$') {
                # Full path excluded
                Get-ChildItem -Path $eObj.Path -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { 
                    $ExcludeResults.Add(@{
                        StringValue = $_.FullName
                        Type = 'File'
                    })
                }
            }
            else {
                # Full filename excluded
                $ExcludeResults.Add(@{
                    StringValue = $eObj.Path
                    Type = 'File'
                })
            }
        }
        else {
            # File, line, and pattern excluded
            $ExcludeResults.Add(@{
                StringValue = "$($eObj.Path);$($eObj.LineNumber);$($eObj.Line)"
                Type = 'LinePattern'
            })
        }
    }

    $ExcludeResults
}
#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')]
        [switch]$NoRecurse,
        
        [Parameter(ParameterSetName = 'File', Position = 0)]
        [ValidateScript({ AssertParameter -ScriptBlock {Test-Path $_} -ErrorMessage "File not found." })]
        [string]$File,
        
        [Parameter()]
        [string]$ConfigPath = $script:PSSSConfigPath,

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

    $Config = GetConfig -ConfigPath $ConfigPath

    [bool]$Recursive = -not $NoRecurse

    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 (-not [string]::IsNullOrEmpty($Excludelist)) {
        # Remove the excludelist from scanfiles. Otherwise patternmatches will be found here...
        $ScanFiles = $ScanFiles.Where({
            $_.FullName -ne (Resolve-Path $Excludelist).Path 
        })

        $Exclusions = GetExclusions $Excludelist
        $FileExclusions = $Exclusions.Where({$_.Type -eq 'File'}).StringValue
        $LinePatternExclusions = $Exclusions.Where({$_.Type -eq 'LinePattern'}).StringValue
        Write-Verbose "Using excludelist $Excludelist. Found $($Exclusions.Count) exlude strings."

        if ($FileExclusions.count -ge 1) {
            Write-Verbose "Excluding files from scan:`n$($FileExclusions -join ""`n"")"
            $ScanFiles = $ScanFiles.Where({
                $_.FullName -notin $FileExclusions
            })
        }
    }

    $scanStart = [DateTime]::Now

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

        $Res = foreach ($key in $Config['regexes'].Keys) {         
            $RegexName = $key
            $Pattern = ($Config['regexes'])."$RegexName"

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

            $ScanFiles | 
                Select-String -Pattern $Pattern |
                Add-Member NoteProperty PatternName (
                    $key -replace '_', ' ' -replace '^\s{0,}'
                ) -Force -PassThru |
                & { process {
                    $_.pstypenames.clear()
                    $_.pstypenames.add('PSSecretScanner.Result')
                    $_
                } }
        }
        
        if (-not [string]::IsNullOrEmpty($Excludelist)) {
            if ($LinePatternExclusions.count -ge 1) {
                $Res = $Res | Where-Object {
                    "$($_.Path);$($_.LineNumber);$($_.Line)" -notin $LinePatternExclusions
                }
            }
        }

        $resultSet = [Ordered]@{
            Results       = $res
            ScanFiles     = $ScanFiles
            ScanStart     = $scanStart
        }
    }
    else {
        $resultSet = [Ordered]@{
            Results       = @()
            ScanFiles     = @()
            ScanStart     = $scanStart
        }
    }
    
    
    $scanEnd = [DateTime]::Now
    $scanTook = $scanEnd - $scanStart

    $resultSet.Add('PSTypeName','PSSecretScanner.ResultSet')
    $resultSet.Add('ScanEnd', $scanEnd)
    $resultSet.Add('ScanTimespan', $scanTook)
    
    $result = [PSCustomObject]$resultSet
    
    $Result
}
#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 = @{
                NoRecurse = $true
            }

            $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