|
| 1 | +#Requires -Module Pester |
| 2 | +<# |
| 3 | +.SYNOPSIS |
| 4 | + Repo-hygiene guard: fail the build if any file under Tests\ contains |
| 5 | + accidentally-added PII (real GUIDs in identity contexts, real public |
| 6 | + IPv4 addresses, real-looking email addresses, or AAD UPNs). |
| 7 | +
|
| 8 | +.DESCRIPTION |
| 9 | + The Tests\ folder ships in the public GitHub repo. This guard scans |
| 10 | + every file under Tests\ for patterns that would leak customer or |
| 11 | + corporate identifiers, and asserts every hit is on the known-safe |
| 12 | + allow-list. Add new fixture values to the allow-list ONLY after |
| 13 | + confirming they are synthetic. |
| 14 | +
|
| 15 | + What the guard checks (per file, all hits aggregated): |
| 16 | + - Email addresses (anything matching local@domain.tld where the |
| 17 | + local part is at least 3 chars). |
| 18 | + - Public IPv4 addresses (anything NOT in RFC1918, loopback, |
| 19 | + link-local, RFC5737 doc ranges, multicast, or the well-known |
| 20 | + public-DNS allow-list). Tokens that look like four-part |
| 21 | + version strings (e.g. SbeVersion 4.5.6.7-RegressionMarker) |
| 22 | + are excluded by the trailing word-boundary. |
| 23 | + - GUIDs appearing in identity contexts (tenantId, clientId, |
| 24 | + objectId, principalId, applicationId), excluding the zero-GUID |
| 25 | + and the explicit allow-list below. |
| 26 | + - AAD UPNs / domain names (anything ending in .onmicrosoft.com). |
| 27 | +
|
| 28 | + What the guard DOES NOT check: |
| 29 | + - Subscription IDs in resource paths (these are not credentials |
| 30 | + and the project owner has confirmed they are acceptable in |
| 31 | + public fixtures). |
| 32 | + - Run IDs / action-plan IDs in error-message strings (same |
| 33 | + rationale; already on the allow-list below). |
| 34 | +
|
| 35 | + To extend the allow-list, add the literal token to the matching |
| 36 | + $allowed* collection in BeforeAll. To extend the BLOCK list, add |
| 37 | + a new aggregator loop + Should assertion in the matching It below. |
| 38 | +
|
| 39 | +.NOTES |
| 40 | + Author: Neil Bird, Microsoft. |
| 41 | + Added: v0.7.99 |
| 42 | +#> |
| 43 | + |
| 44 | +Describe 'PII Guard: Tests folder' { |
| 45 | + |
| 46 | + BeforeAll { |
| 47 | + $script:TestsRoot = $PSScriptRoot |
| 48 | + $script:GuardFileName = 'Pii-Guard.Tests.ps1' |
| 49 | + |
| 50 | + $script:AllowedGuids = @( |
| 51 | + '00000000-0000-0000-0000-000000000000', |
| 52 | + 'fbaf508b-cb61-4383-9cda-a42bfa0c7bc9', |
| 53 | + 'add1f87d-4174-4997-ae39-d9d41088be27', |
| 54 | + '1084e062-5d0b-48c0-b4d6-c1693b575bc1' |
| 55 | + ) |
| 56 | + |
| 57 | + $script:AllowedEmailDomains = @( |
| 58 | + 'example.com', 'example.org', 'example.net', |
| 59 | + 'contoso.com', 'fabrikam.com', |
| 60 | + 'noreply.github.com', 'users.noreply.github.com' |
| 61 | + ) |
| 62 | + |
| 63 | + $script:AllowedPublicIPs = @( |
| 64 | + '8.8.8.8', '8.8.4.4', |
| 65 | + '1.1.1.1', '1.0.0.1', |
| 66 | + '9.9.9.9', '149.112.112.112', |
| 67 | + '208.67.222.222', '208.67.220.220' |
| 68 | + ) |
| 69 | + |
| 70 | + $script:TargetFiles = Get-ChildItem -Path $script:TestsRoot -File -Recurse | |
| 71 | + Where-Object { $_.Name -ne $script:GuardFileName -and $_.Extension -in @('.ps1','.psm1','.psd1','.json','.md','.xml','.yml','.yaml','.csv') } |
| 72 | + } |
| 73 | + |
| 74 | + It 'enumerates at least one target file' { |
| 75 | + ($script:TargetFiles | Measure-Object).Count | Should -BeGreaterThan 0 |
| 76 | + } |
| 77 | + |
| 78 | + It 'allow-listed GUID list contains zero-GUID sentinel' { |
| 79 | + $script:AllowedGuids | Should -Contain '00000000-0000-0000-0000-000000000000' |
| 80 | + } |
| 81 | + |
| 82 | + It 'finds no real-looking email addresses' { |
| 83 | + $emailRx = '(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{3,}@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b' |
| 84 | + $unsafe = New-Object 'System.Collections.Generic.List[string]' |
| 85 | + foreach ($f in $script:TargetFiles) { |
| 86 | + $text = [System.IO.File]::ReadAllText($f.FullName) |
| 87 | + foreach ($m in [regex]::Matches($text, $emailRx)) { |
| 88 | + $addr = $m.Value |
| 89 | + $dom = ($addr -split '@')[1].ToLowerInvariant() |
| 90 | + $ok = $false |
| 91 | + foreach ($d in $script:AllowedEmailDomains) { |
| 92 | + if ($dom -eq $d -or $dom.EndsWith(".$d")) { $ok = $true; break } |
| 93 | + } |
| 94 | + if (-not $ok) { $unsafe.Add("$($f.Name): $addr") } |
| 95 | + } |
| 96 | + } |
| 97 | + $unsafe | Should -BeNullOrEmpty -Because "found email-shaped tokens not on the allow-list: $($unsafe -join '; ')" |
| 98 | + } |
| 99 | + |
| 100 | + It 'finds no real-looking AAD tenant UPN domains (.onmicrosoft.com)' { |
| 101 | + $unsafe = New-Object 'System.Collections.Generic.List[string]' |
| 102 | + foreach ($f in $script:TargetFiles) { |
| 103 | + $text = [System.IO.File]::ReadAllText($f.FullName) |
| 104 | + foreach ($m in [regex]::Matches($text, '\b[A-Za-z0-9-]+\.onmicrosoft\.com\b')) { |
| 105 | + $upnDomain = $m.Value |
| 106 | + if ($upnDomain -notmatch '^(contoso|fabrikam|example)\.onmicrosoft\.com$') { |
| 107 | + $unsafe.Add("$($f.Name): $upnDomain") |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + $unsafe | Should -BeNullOrEmpty -Because "found tenant UPN domains not on the allow-list: $($unsafe -join '; ')" |
| 112 | + } |
| 113 | + |
| 114 | + It 'finds no GUIDs in identity contexts (tenantId/clientId/objectId/principalId/applicationId)' { |
| 115 | + $idCtxRx = '(?i)\b(?:tenant|client|object|principal|application)[-_ ]?id\b\s*[:=]\s*["'']?(?<g>[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})' |
| 116 | + $unsafe = New-Object 'System.Collections.Generic.List[string]' |
| 117 | + foreach ($f in $script:TargetFiles) { |
| 118 | + $text = [System.IO.File]::ReadAllText($f.FullName) |
| 119 | + foreach ($m in [regex]::Matches($text, $idCtxRx)) { |
| 120 | + $g = $m.Groups['g'].Value.ToLowerInvariant() |
| 121 | + if ($script:AllowedGuids -notcontains $g) { |
| 122 | + $unsafe.Add("$($f.Name): $g") |
| 123 | + } |
| 124 | + } |
| 125 | + } |
| 126 | + $unsafe | Should -BeNullOrEmpty -Because "found GUIDs in identity contexts not on the allow-list: $($unsafe -join '; ')" |
| 127 | + } |
| 128 | + |
| 129 | + It 'finds no real-looking public IPv4 addresses' { |
| 130 | + # Dotted-quad with word-boundaries. The trailing lookahead excludes |
| 131 | + # tokens immediately followed by another digit, dot, hyphen, or |
| 132 | + # letter, which removes version-string false-positives like |
| 133 | + # '4.5.6.7-RegressionMarker' or SemVer 'X.Y.Z.W-suffix'. |
| 134 | + $ipRx = '(?<![\w.])(?:25[0-5]|2[0-4]\d|1\d\d|\d{1,2})(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|\d{1,2})){3}(?![\d.\-A-Za-z])' |
| 135 | + |
| 136 | + $unsafe = New-Object 'System.Collections.Generic.List[string]' |
| 137 | + foreach ($f in $script:TargetFiles) { |
| 138 | + $text = [System.IO.File]::ReadAllText($f.FullName) |
| 139 | + $seen = New-Object 'System.Collections.Generic.HashSet[string]' |
| 140 | + foreach ($m in [regex]::Matches($text, $ipRx)) { |
| 141 | + $ip = $m.Value |
| 142 | + if (-not $seen.Add($ip)) { continue } |
| 143 | + if ($script:AllowedPublicIPs -contains $ip) { continue } |
| 144 | + |
| 145 | + $octets = $ip -split '\.' | ForEach-Object { [int]$_ } |
| 146 | + $o1 = $octets[0]; $o2 = $octets[1]; $o3 = $octets[2] |
| 147 | + |
| 148 | + if ($o1 -eq 0) { continue } # 0.0.0.0/8 |
| 149 | + if ($o1 -eq 10) { continue } # RFC1918 |
| 150 | + if ($o1 -eq 172 -and $o2 -ge 16 -and $o2 -le 31) { continue } # RFC1918 |
| 151 | + if ($o1 -eq 192 -and $o2 -eq 168) { continue } # RFC1918 |
| 152 | + if ($o1 -eq 127) { continue } # loopback |
| 153 | + if ($o1 -eq 169 -and $o2 -eq 254) { continue } # link-local |
| 154 | + if ($o1 -eq 100 -and $o2 -ge 64 -and $o2 -le 127){ continue } # CGNAT |
| 155 | + if ($o1 -ge 224) { continue } # multicast / reserved |
| 156 | + if ($o1 -eq 255) { continue } # broadcast / mask |
| 157 | + if ($o1 -eq 192 -and $o2 -eq 0 -and $o3 -eq 2) { continue } # RFC5737 TEST-NET-1 |
| 158 | + if ($o1 -eq 198 -and $o2 -eq 51 -and $o3 -eq 100){ continue } # RFC5737 TEST-NET-2 |
| 159 | + if ($o1 -eq 203 -and $o2 -eq 0 -and $o3 -eq 113){ continue } # RFC5737 TEST-NET-3 |
| 160 | + if ($o1 -eq 198 -and ($o2 -eq 18 -or $o2 -eq 19)) { continue } # RFC2544 benchmarking |
| 161 | + |
| 162 | + # Subnet-mask shapes (every octet is 0 or 255). |
| 163 | + $maskShape = $true |
| 164 | + foreach ($o in $octets) { if ($o -ne 0 -and $o -ne 255) { $maskShape = $false; break } } |
| 165 | + if ($maskShape) { continue } |
| 166 | + |
| 167 | + $unsafe.Add("$($f.Name): $ip") |
| 168 | + } |
| 169 | + } |
| 170 | + $unsafe | Should -BeNullOrEmpty -Because "found public IPv4 addresses not on the allow-list: $($unsafe -join '; ')" |
| 171 | + } |
| 172 | +} |
0 commit comments