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 |