<# .SYNOPSIS Fails if any tracked file contains data that must never be committed (SC-013). .DESCRIPTION Constitution: every artifact must be free of organization names, real domains, tenant or subscription IDs, real UPNs or Object IDs, and any secret. The scan is intentionally blunt. A false positive costs a placeholder rewrite; a false negative commits tenant data to history, where deleting it later does not undo the disclosure. Placeholder GUIDs are permitted: any GUID built only from zeros plus a short hex suffix (00000000-0000-0000-0000-0000000000a0) is obviously synthetic. .EXAMPLE ./tests/Test-Sanitization.ps1 ./tests/Test-Sanitization.ps1 -Path ./src #> [CmdletBinding()] param( [string] $Path = (Split-Path $PSScriptRoot -Parent), [switch] $PassThru ) $ErrorActionPreference = 'Stop' # A GUID is synthetic when every character before the final short suffix is 0. $placeholderGuid = '^0{8}-0{4}-0{4}-0{4}-0{8}[0-9a-f]{4}$' $patterns = @( @{ Name = 'Real GUID (tenant, subscription, object, or group ID)' Regex = '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' Exclude = $placeholderGuid # A PowerShell module manifest must carry a genuine, unique GUID as its # identity - it is what distinguishes this module from another of the same # name. It identifies the module, not a tenant, and cannot be replaced with a # placeholder without breaking module resolution. ExcludeLine = '^\s*GUID\s*=' } @{ Name = 'Email address or UPN' # Placeholders such as @ contain angle brackets and # are excluded by requiring word characters on both sides. Regex = '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b' # Reserved, permanently unresolvable domains from RFC 2606 and RFC 6761. # These exist precisely so documentation and fixtures can use an address that # is guaranteed never to reach a real mailbox. Rejecting them would push # fixtures toward something that merely looks fake, which is worse: the # difference between "obviously synthetic" and "probably nobody's" is the # whole point of the reserved list. Exclude = '@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|net|org)|invalid|test|localhost)$' } @{ Name = 'onmicrosoft.com domain' Regex = '[A-Za-z0-9-]+\.onmicrosoft\.com' } @{ Name = 'Bearer token or JWT' Regex = 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' } @{ Name = 'Assigned secret, password, or key literal' Regex = '(?i)\b(client_?secret|password|api_?key|access_?token)\b\s*[:=]\s*["\x27][^"\x27<][^"\x27]*["\x27]' } @{ Name = 'PEM private key block' Regex = '-----BEGIN [A-Z ]*PRIVATE KEY-----' } ) Push-Location $Path try { # Tracked files AND untracked files that are not gitignored. # # Tracked-only would make this gate useless where it matters most: a leaked # identifier in a file that has not been committed yet is precisely the one worth # catching, and scanning only what is already in history means the scan passes # right up until the commit that makes it too late. # # --exclude-standard keeps gitignored build output and local scratch files out, # so the scan covers exactly what a commit would add. $files = git ls-files --cached --others --exclude-standard 2>$null | Sort-Object -Unique if (-not $files) { throw "Not a git repository or no files to scan under '$Path'." } # This scanner necessarily contains the patterns it searches for. $selfName = 'tests/Test-Sanitization.ps1' $findings = [System.Collections.Generic.List[object]]::new() foreach ($file in $files) { if ($file -eq $selfName) { continue } if (-not (Test-Path $file -PathType Leaf)) { continue } # Skip binaries. if ($file -match '\.(png|jpg|jpeg|gif|ico|pdf|zip|dll|exe|pfx|cer)$') { continue } $lineNumber = 0 foreach ($line in (Get-Content -LiteralPath $file -ErrorAction SilentlyContinue)) { $lineNumber++ foreach ($pattern in $patterns) { # A line-level exemption is narrower than a file-level one on purpose: # exempting a whole file would let a real identifier land anywhere in # it, and the files that need an exemption at all are exactly the ones # worth keeping under scrutiny. if ($pattern.ExcludeLine -and $line -match $pattern.ExcludeLine) { continue } foreach ($match in [regex]::Matches($line, $pattern.Regex)) { if ($pattern.Exclude -and $match.Value -match $pattern.Exclude) { continue } $findings.Add([pscustomobject]@{ File = $file Line = $lineNumber Pattern = $pattern.Name Match = $match.Value }) } } } } } finally { Pop-Location } if ($findings.Count -gt 0) { Write-Host "Sanitization scan FAILED - $($findings.Count) finding(s):" -ForegroundColor Red $findings | Format-Table -AutoSize | Out-String | Write-Host if ($PassThru) { return $findings } exit 1 } Write-Host 'Sanitization scan passed: no tenant data, credentials, or real identifiers found.' -ForegroundColor Green if ($PassThru) { return @() } exit 0