modules/shared/Sanitize.ps1

#Requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Remove-Credentials {
    [CmdletBinding()]
    param (
        [AllowNull()]
        [string] $Text
    )

    if ([string]::IsNullOrEmpty($Text)) {
        return $Text
    }

    $sanitized = $Text
    $rules = @(
        @{ Pattern = 'ghp_[A-Za-z0-9]{36}'; Replacement = '[GITHUB-PAT-REDACTED]' },
        @{ Pattern = 'gho_[A-Za-z0-9]{36}'; Replacement = '[GITHUB-OAUTH-REDACTED]' },
        @{ Pattern = 'ghs_[A-Za-z0-9]{36}'; Replacement = '[GITHUB-TOKEN-REDACTED]' },
        @{ Pattern = 'ghr_[A-Za-z0-9]{36}'; Replacement = '[GITHUB-REFRESH-REDACTED]' },
        @{ Pattern = 'github_pat_[A-Za-z0-9_]{82}'; Replacement = '[GITHUB-PAT-REDACTED]' },
        @{ Pattern = '(?im)Authorization:\s*Basic\s+[A-Za-z0-9+/=]{16,}'; Replacement = 'Authorization: [ADO-PAT-REDACTED]' },
        @{ Pattern = '(?im)Authorization:\s*(Bearer|Basic)\s+\S+'; Replacement = 'Authorization: [REDACTED]' },
        @{ Pattern = '(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*'; Replacement = 'Bearer [REDACTED]' },
        # JWT (header.payload.signature, base64url, each segment >=10 chars)
        @{ Pattern = '\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b'; Replacement = '[JWT-REDACTED]' },
        # OpenAI-style secrets (sk-... and sk-proj-...)
        @{ Pattern = '\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}'; Replacement = '[OPENAI-KEY-REDACTED]' },
        # Slack bot/user/app tokens
        @{ Pattern = '\bxox[baprs]-[A-Za-z0-9-]{10,}'; Replacement = '[SLACK-TOKEN-REDACTED]' },
        # Azure OpenAI / generic API key env-var style
        @{ Pattern = '(?i)\bAZURE_OPENAI_API_KEY\s*=\s*[^\s;,&"'']+'; Replacement = 'AZURE_OPENAI_API_KEY=[REDACTED]' },
        @{ Pattern = '(?i)\b(AccountKey|SharedAccessKey|Password)=[^;]+'; Replacement = '$1=[REDACTED]' },
        # SAS query params
        @{ Pattern = '(?i)([?&])sig=[A-Za-z0-9%+/=]{10,}'; Replacement = '$1sig=[REDACTED]' },
        @{ Pattern = '(?i)([?&])sv=[0-9]{4}-[0-9]{2}-[0-9]{2}'; Replacement = '$1sv=[REDACTED]' },
        @{ Pattern = '(?i)\bsig=[A-Za-z0-9%+/=]{10,}'; Replacement = 'sig=[REDACTED]' },
        @{ Pattern = '(?i)\bclient_secret=[^&\s]+'; Replacement = 'client_secret=[REDACTED]' },
        @{ Pattern = '(?i)\bSharedAccessSignature=[^;]+'; Replacement = 'SharedAccessSignature=[REDACTED]' },
        # Shodan API key (env-var or query-string form). Shodan keys are 32 chars
        # of [A-Za-z0-9] but we widen the boundary to catch quoted variants.
        @{ Pattern = '(?i)\bSHODAN_API_KEY\s*=\s*[^\s;,&"'']+'; Replacement = 'SHODAN_API_KEY=[REDACTED]' },
        @{ Pattern = '(?i)([?&])key=[A-Za-z0-9]{20,}'; Replacement = '$1key=[REDACTED]' },
        # Censys API ID + secret (env-var form). Both are UUID-shaped on the
        # vendor side; we redact whatever is on the right of `=` defensively.
        @{ Pattern = '(?i)\bCENSYS_API_ID\s*=\s*[^\s;,&"'']+'; Replacement = 'CENSYS_API_ID=[REDACTED]' },
        @{ Pattern = '(?i)\bCENSYS_API_SECRET\s*=\s*[^\s;,&"'']+'; Replacement = 'CENSYS_API_SECRET=[REDACTED]' }
    )

    foreach ($rule in $rules) {
        $sanitized = [regex]::Replace($sanitized, $rule.Pattern, $rule.Replacement)
    }

    return $sanitized
}