|
3 | 3 | # |
4 | 4 | # Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') -Force |
5 | 5 | # |
6 | | -# All exported functions use approved PowerShell verbs. |
7 | 6 |
|
8 | 7 | # Module-scoped flag. Flip to $false locally if you need to see raw values |
9 | 8 | # while debugging — never commit that change. |
10 | 9 | $script:EnableMasking = $true |
11 | 10 |
|
| 11 | +# Built-in regex redactions applied by Protect-LogLine on every line, in |
| 12 | +# addition to caller-supplied exact-match Replacements. These cover sensitive |
| 13 | +# values that the caller cannot enumerate up front (ARM async-operation |
| 14 | +# signing material, request IDs, etc.). |
| 15 | +# |
| 16 | +# IMPORTANT: keep these patterns conservative. We deliberately do NOT mask |
| 17 | +# every GUID, because well-known role-definition IDs and template hashes are |
| 18 | +# legitimately public and useful in debugging. Anchor patterns to the path |
| 19 | +# segment, header name, or query-parameter context that makes the value |
| 20 | +# sensitive. |
| 21 | +# |
| 22 | +# References: |
| 23 | +# - ARM async operations contract (see `Azure-AsyncOperation` / |
| 24 | +# `operationStatuses` URL shape with t/c/s/h query parameters): |
| 25 | +# https://learn.microsoft.com/azure/azure-resource-manager/management/async-operations |
| 26 | +# - Azure correlation / request IDs (`x-ms-correlation-request-id`, |
| 27 | +# `x-ms-request-id`, `x-ms-client-request-id`): |
| 28 | +# https://learn.microsoft.com/azure/azure-resource-manager/management/request-limits-and-throttling |
| 29 | +$script:BuiltinRedactions = @( |
| 30 | + # ARM async-operation signing material: t= (timestamp), c= (cert), |
| 31 | + # s= (signature), h= (hash) on operationStatuses / operationResults URLs. |
| 32 | + # These query parameters are effectively a bearer credential for polling |
| 33 | + # the long-running operation result and MUST NOT appear in logs. |
| 34 | + @{ Pattern = '([?&])(t|c|s|h)=[^&''"\s]+' |
| 35 | + Replacement = '$1$2=<REDACTED:arm-async>' } |
| 36 | + |
| 37 | + # Azure subscription IDs embedded in ARM resource paths. |
| 38 | + @{ Pattern = '/subscriptions/[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}' |
| 39 | + Replacement = '/subscriptions/<SUBSCRIPTION_ID>' } |
| 40 | + |
| 41 | + # ARM long-running operation IDs (numeric or alphanumeric, 10+ chars) |
| 42 | + # following /operationStatuses/ or /operationResults/. |
| 43 | + @{ Pattern = '/(operationStatuses|operationResults)/[A-Za-z0-9._-]{10,}' |
| 44 | + Replacement = '/$1/<OPERATION_ID>' } |
| 45 | + |
| 46 | + # x-ms-correlation-request-id, x-ms-request-id, x-ms-client-request-id |
| 47 | + # header values. Match Python-repr style (`'x-ms-request-id': 'guid'`), |
| 48 | + # HTTP-header style (`x-ms-request-id: guid`), and JSON style. |
| 49 | + @{ Pattern = "(?i)(['""]?x-ms-(?:correlation-)?(?:request|client-request)-id['""]?\s*[:=]\s*['""]?)[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}" |
| 50 | + Replacement = '$1<REDACTED:request-id>' } |
| 51 | + |
| 52 | + # x-ms-routing-request-id: '<REGION>:<TIMESTAMP>:<guid>'. Mask the whole |
| 53 | + # value because all three components (region, timestamp, GUID) leak |
| 54 | + # tenant geography / activity timing. |
| 55 | + @{ Pattern = "(?i)(['""]?x-ms-routing-request-id['""]?\s*[:=]\s*['""]?)[A-Z0-9]+:\d{8}T\d{6}Z:[0-9a-fA-F-]{36}" |
| 56 | + Replacement = '$1<REDACTED:routing-request-id>' } |
| 57 | + |
| 58 | + # Authorization: Bearer <token> — defense in depth. We do not expect |
| 59 | + # bearer tokens to appear because `az --debug` redacts them by default, |
| 60 | + # but if Azure CLI ever changes that, this catches them. |
| 61 | + @{ Pattern = "(?i)(authorization[:\s=]+bearer\s+)[A-Za-z0-9._\-+/=]+" |
| 62 | + Replacement = '$1<REDACTED:bearer>' } |
| 63 | + |
| 64 | + # JWT-shaped tokens (eyJ<base64url>.<base64url>.<base64url>). |
| 65 | + @{ Pattern = '\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}' |
| 66 | + Replacement = '<REDACTED:jwt>' } |
| 67 | +) |
| 68 | + |
12 | 69 | function Protect-Identifier { |
13 | 70 | param( |
14 | 71 | [string]$Value, |
@@ -50,17 +107,31 @@ function Protect-LogLine { |
50 | 107 | [hashtable]$Replacements |
51 | 108 | ) |
52 | 109 |
|
53 | | - if (-not $script:EnableMasking -or [string]::IsNullOrEmpty($Line) -or -not $Replacements) { |
| 110 | + if (-not $script:EnableMasking -or [string]::IsNullOrEmpty($Line)) { |
54 | 111 | return $Line |
55 | 112 | } |
56 | 113 |
|
57 | 114 | $protectedLine = $Line |
58 | | - foreach ($entry in $Replacements.GetEnumerator()) { |
59 | | - if ([string]::IsNullOrEmpty($entry.Key) -or [string]::IsNullOrEmpty($entry.Value)) { |
60 | | - continue |
| 115 | + |
| 116 | + # Pass 1: caller-supplied exact-string replacements (sub id, RG name, ...). |
| 117 | + if ($Replacements) { |
| 118 | + foreach ($entry in $Replacements.GetEnumerator()) { |
| 119 | + if ([string]::IsNullOrEmpty($entry.Key) -or [string]::IsNullOrEmpty($entry.Value)) { |
| 120 | + continue |
| 121 | + } |
| 122 | + |
| 123 | + $protectedLine = $protectedLine.Replace($entry.Key, $entry.Value) |
61 | 124 | } |
| 125 | + } |
62 | 126 |
|
63 | | - $protectedLine = $protectedLine.Replace($entry.Key, $entry.Value) |
| 127 | + # Pass 2: built-in regex redactions for well-known secret shapes that |
| 128 | + # callers cannot enumerate up front (ARM signing material, request IDs, |
| 129 | + # operation IDs, bearer tokens). Always applied. |
| 130 | + foreach ($rule in $script:BuiltinRedactions) { |
| 131 | + $protectedLine = [System.Text.RegularExpressions.Regex]::Replace( |
| 132 | + $protectedLine, |
| 133 | + $rule.Pattern, |
| 134 | + $rule.Replacement) |
64 | 135 | } |
65 | 136 |
|
66 | 137 | return $protectedLine |
|
0 commit comments