Private/Find-SensitiveValue.ps1

# Compiled once at module load - matched case-insensitively on every leaf key name
$script:SensitiveKeyPattern = [System.Text.RegularExpressions.Regex]::new(
    '^.*(password|secret|token|key|apikey|api_key|auth|credential|private).*$',
    ([System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Compiled)
)

function Find-SensitiveValue {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyString()]
        [object] $Value,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $KeyName
    )

    if ($null -eq $Value -or $Value -is [bool]) {
        return $null
    }

    $isStringValue = $Value -is [string]
    $stringVal     = if ($isStringValue) { $Value } else { [string]$Value }

    # --- Key-name heuristic (highest priority) ---
    if ($script:SensitiveKeyPattern.IsMatch($KeyName)) {
        return [PSCustomObject]@{
            DetectedType = 'KeyNameMatch'
            MatchedValue = $stringVal
        }
    }

    # --- Only apply pattern/entropy checks to string values ---
    if (-not $isStringValue) {
        return $null
    }

    if ([string]::IsNullOrWhiteSpace($stringVal)) {
        return $null
    }

    # JWT / Bearer token
    if ($stringVal -match '^Bearer\s+\S+' -or $stringVal -match '^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+') {
        return [PSCustomObject]@{
            DetectedType = 'BearerToken'
            MatchedValue = $stringVal
        }
    }

    # Email address
    if ($stringVal -match '^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$') {
        return [PSCustomObject]@{
            DetectedType = 'Email'
            MatchedValue = $stringVal
        }
    }

    # URL with embedded credentials (scheme://user:pass@host)
    if ($stringVal -match '^[a-zA-Z][a-zA-Z0-9+\-.]*://[^:@/\s]+:[^@/\s]+@') {
        return [PSCustomObject]@{
            DetectedType = 'CredentialUrl'
            MatchedValue = $stringVal
        }
    }

    # IPv4 address
    if ($stringVal -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
        return [PSCustomObject]@{
            DetectedType = 'IpAddress'
            MatchedValue = $stringVal
        }
    }

    # US phone number
    if ($stringVal -match '^\+?1?[\s.\-]?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$') {
        return [PSCustomObject]@{
            DetectedType = 'PhoneNumber'
            MatchedValue = $stringVal
        }
    }

    # Social Security Number
    if ($stringVal -match '^\d{3}-\d{2}-\d{4}$') {
        return [PSCustomObject]@{
            DetectedType = 'SSN'
            MatchedValue = $stringVal
        }
    }

    # Credit card (Luhn-validated, 13-19 digits, optional spaces/dashes)
    $ccDigits = $stringVal -replace '[\s\-]', ''
    if ($ccDigits -match '^\d{13,19}$' -and (Test-LuhnChecksum -Number $ccDigits)) {
        return [PSCustomObject]@{
            DetectedType = 'CreditCard'
            MatchedValue = $stringVal
        }
    }

    # Multi-label hostname / internal FQDN (e.g. server01.corp.contoso.local)
    # Must have at least two labels, no spaces, no slashes, and not already matched as IP
    if ($stringVal -match '^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?){1,}$' `
        -and $stringVal -notmatch '^\d{1,3}(\.\d{1,3}){3}$') {
        return [PSCustomObject]@{
            DetectedType = 'Hostname'
            MatchedValue = $stringVal
        }
    }

    # High-entropy string (potential API key / secret)
    if ($stringVal.Length -ge 20 -and (Get-ShannonEntropy -InputString $stringVal) -gt 3.5) {
        return [PSCustomObject]@{
            DetectedType = 'HighEntropyString'
            MatchedValue = $stringVal
        }
    }

    return $null
}

function Get-ShannonEntropy {
    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Mandatory)]
        [string] $InputString
    )

    $length = $InputString.Length
    if ($length -eq 0) { return 0.0 }

    $freq = @{}
    foreach ($char in $InputString.ToCharArray()) {
        $key = [string]$char
        if ($freq.ContainsKey($key)) { $freq[$key]++ } else { $freq[$key] = 1 }
    }

    $entropy = 0.0
    foreach ($count in $freq.Values) {
        $p = $count / $length
        $entropy -= $p * [System.Math]::Log($p, 2)
    }

    return $entropy
}

function Test-LuhnChecksum {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string] $Number
    )

    $digits  = $Number.ToCharArray() | ForEach-Object { [int]::Parse($_) }
    $sum     = 0
    $doubled = $false

    for ($i = $digits.Length - 1; $i -ge 0; $i--) {
        $d = $digits[$i]
        if ($doubled) {
            $d *= 2
            if ($d -gt 9) { $d -= 9 }
        }
        $sum    += $d
        $doubled = -not $doubled
    }

    return ($sum % 10 -eq 0)
}