Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
be4d544
Add git pre-commit hooks for sensitive data, run count, and PSScriptA…
dpaulson45 May 2, 2026
4c71371
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 4, 2026
78d17d7
Fix PR review findings: align hooks with repo validation pipeline
dpaulson45 May 4, 2026
dd0fcfa
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 4, 2026
c167b7e
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 5, 2026
fe1c6e7
Fix review findings and enhance sensitive data scanner
dpaulson45 May 5, 2026
00edf11
Enhance sensitive data scanner: domain detection, URL skip, IIS typo fix
dpaulson45 May 6, 2026
101b74a
Fix code review findings: return value, domain anchor, cross-platform…
dpaulson45 May 6, 2026
4ed26a7
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 6, 2026
3655c49
Fix review round 2: LASTEXITCODE capture, tokenizer, messaging, @ skip
dpaulson45 May 6, 2026
dffc67e
Fix review round 3: git CWD, rev-parse validation, Import-Module, docs
dpaulson45 May 7, 2026
00430ab
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 7, 2026
12f380e
Add .psd1 to ScriptAnalyzer filter and fix ParseError color
dpaulson45 May 7, 2026
04aff64
Use named -Path parameter on Get-Content calls
dpaulson45 May 7, 2026
1aff56b
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 7, 2026
cc21a94
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 7, 2026
1f91ddf
Fix review round 4: cross-platform paths, named params, email subdoma…
dpaulson45 May 7, 2026
5a82b5b
Hoist patterns outside loop, named Sort-Object, remove SilentlyContinue
dpaulson45 May 7, 2026
5f59c86
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 7, 2026
d4ba167
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 8, 2026
a6e7739
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 8, 2026
3c9b7bc
Fix PR review findings: fail-closed error handling
dpaulson45 May 8, 2026
adcfa5b
Merge branch 'dpaul-Copilot' into dpaul-GitHooks
dpaulson45 May 8, 2026
ca446b1
Automated PR review fixes
dpaulson45 May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/GitHooks/Test-HealthCheckerScenarioRunCount.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

<#
.SYNOPSIS
Blocks commits when test files exceed the max pipeline run count.
.DESCRIPTION
Scans staged .Tests.ps1 files for SetDefaultRunOfHealthChecker calls.
Blocks the commit if any file exceeds 5 runs (the benchmarked optimal maximum).
Comment thread
dpaulson45 marked this conversation as resolved.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$Files
)

$maxRunsPerFile = 5
$failed = $false

foreach ($file in $Files) {
if (-not (Test-Path $file)) { continue }

# Count actual calls only, excluding comments and strings
try {
$content = Get-Content -Path $file -Raw -ErrorAction Stop
} catch {
Write-Host " BLOCKED: $file - Unable to read file: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
continue
}
$parseErrors = $null
$tokens = [System.Management.Automation.PSParser]::Tokenize($content, [ref]$parseErrors)

if ($null -ne $parseErrors -and $parseErrors.Count -gt 0) {
Write-Host " WARN: $file has $($parseErrors.Count) parse error(s) - run count may be inaccurate" -ForegroundColor Yellow
}

$runCount = @($tokens | Where-Object {
$_.Type -eq 'Command' -and $_.Content -eq 'SetDefaultRunOfHealthChecker'
}).Count

if ($runCount -gt $maxRunsPerFile) {
Write-Host " BLOCKED: $file has $runCount pipeline runs (max: $maxRunsPerFile)" -ForegroundColor Red
Comment thread
dpaulson45 marked this conversation as resolved.
Write-Host " Consider splitting this file. Balance runs evenly (e.g., 4+2 not 5+1)." -ForegroundColor Yellow
$failed = $true
} elseif ($runCount -gt 0) {
Write-Host " OK: $file - $runCount pipeline run(s)" -ForegroundColor Green
}
}

# Use 'git commit --no-verify' to bypass if needed
if ($failed) {
Write-Host "`nTest run count check found issues. See above." -ForegroundColor Red
return 1
Comment thread
dpaulson45 marked this conversation as resolved.
}
Comment thread
dpaulson45 marked this conversation as resolved.
Comment thread
dpaulson45 marked this conversation as resolved.

return 0
75 changes: 75 additions & 0 deletions .github/GitHooks/Test-ScriptAnalyzer.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

<#
.SYNOPSIS
Runs PSScriptAnalyzer on staged PowerShell files.
.DESCRIPTION
Blocks commits with PSScriptAnalyzer violations using the repository's
PSScriptAnalyzerSettings.psd1 and CustomRules.psm1 to match the CI pipeline.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$Files
)

# cspell:ignore toplevel
$repoRoot = git rev-parse --show-toplevel

if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrEmpty($repoRoot)) {
Write-Host " Error: Unable to determine repository root." -ForegroundColor Red
return 1
}

$settingsPath = Join-Path -Path $repoRoot -ChildPath "PSScriptAnalyzerSettings.psd1"
$customRulesPath = Join-Path -Path (Join-Path -Path (Join-Path -Path $repoRoot -ChildPath ".build") -ChildPath "CodeFormatterChecks") -ChildPath "CustomRules.psm1"

# Check if PSScriptAnalyzer >= 1.24 is available (matches .build/CodeFormatter.ps1)
$module = Get-Module -ListAvailable -Name PSScriptAnalyzer |
Where-Object { $_.Version -ge [version]"1.24" } |
Select-Object -First 1

if ($null -eq $module) {
Write-Host " SKIP: PSScriptAnalyzer >= 1.24 not installed." -ForegroundColor Yellow
return 0
}

Import-Module PSScriptAnalyzer -MinimumVersion "1.24" -ErrorAction Stop
Comment thread
dpaulson45 marked this conversation as resolved.

$hasViolations = $false

foreach ($file in $Files) {
if (-not (Test-Path $file)) { continue }

$params = @{
Path = $file
Severity = @('ParseError', 'Error', 'Warning')
}
Comment thread
dpaulson45 marked this conversation as resolved.

# Use repo settings and custom rules if available
if (Test-Path $settingsPath) {
$params.Settings = $settingsPath
}
if (Test-Path $customRulesPath) {
$params.CustomRulePath = $customRulesPath
$params.IncludeDefaultRules = $true
}
Comment on lines +50 to +57
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Multi-model review (3/3 models) confirms this is a valid concern — when PSScriptAnalyzer is not installed, the hook returns 0 (success) with only a console SKIP message. This diverges from CI which auto-installs the module. The CONTRIBUTING.md now documents the prerequisite, but the hook still silently passes. This is an intentional trade-off — forcing module installation in a pre-commit hook would be intrusive.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Multi-model review (3/3 models) confirms this is a valid concern — when PSScriptAnalyzer is not installed, the hook returns 0 with a SKIP message. This diverges from CI which auto-installs the module. This is an intentional trade-off — forcing module installation in a pre-commit hook would be intrusive for contributors. CONTRIBUTING.md documents the prerequisite.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing — PSScriptAnalyzerSettings.psd1 and CustomRules.psm1 are committed files in the repository. They will always exist at the computed paths as long as $repoRoot resolves correctly, which is validated with error handling on lines 20-22. The Test-Path guards on lines 53/56 are defensive coding for an impossible-in-practice scenario. Adding failure modes for missing committed files would add complexity without meaningful protection. The separate concern about the module not being installed is already addressed: SKIP message + CONTRIBUTING.md prerequisite documentation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. As the author explained, PSScriptAnalyzerSettings.psd1 and CustomRules.psm1 are committed files that will always exist at the computed paths when repoRoot resolves correctly. The Test-Path guards are defensive coding. Left for author to resolve as this is an intentional design decision.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left for the author to resolve — this is an intentional design decision as discussed in the thread.


$results = Invoke-ScriptAnalyzer @params

Comment thread
dpaulson45 marked this conversation as resolved.
if ($null -ne $results -and $results.Count -gt 0) {
$hasViolations = $true
foreach ($r in $results) {
Write-Host " $($r.Severity): $file`:$($r.Line) - [$($r.RuleName)] $($r.Message)" -ForegroundColor $(if ($r.Severity -in @('Error', 'ParseError')) { 'Red' } else { 'Yellow' })
}
}
Comment thread
dpaulson45 marked this conversation as resolved.
Comment thread
dpaulson45 marked this conversation as resolved.
Comment thread
dpaulson45 marked this conversation as resolved.
}

if ($hasViolations) {
Write-Host "`nPSScriptAnalyzer found violations. Fix before committing." -ForegroundColor Red
return 1
Comment thread
dpaulson45 marked this conversation as resolved.
} else {
Write-Host " PSScriptAnalyzer: no issues found." -ForegroundColor Green
return 0
}
193 changes: 193 additions & 0 deletions .github/GitHooks/Test-SensitiveData.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

<#
.SYNOPSIS
Scans test data files for sensitive data patterns.
.DESCRIPTION
Checks staged test data files for email addresses and domain names outside
allowed test domains, public IP addresses, and credential-like patterns.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$Files
)

$failed = $false

# RFC-reserved TLDs that can't be publicly registered (safe for test data)
# cspell:ignore Tlds
$reservedTlds = @('local', 'test', 'example', 'invalid', 'localhost', 'internal', 'lan')

# Allowed domains in test data (entries are regex fragments with escaped dots)
# cspell:ignore vnext apikey fabrikam microsoftonline cloudapp
$allowedDomains = @(
'fabrikam\.com',
'example\.com',
'contoso\.com',
'contoso\.lab',
'contoso\.mail\.onmicrosoft\.com',
# Microsoft service domains (product constants in Exchange configuration)
'microsoft\.com',
'microsoftonline-p\.com',
'microsoftonline\.com',
'outlook\.com',
'live\.com',
'live-int\.com',
'office365\.com',
'office\.com',
'msn\.com',
'passport\.com',
'cloudapp\.net'
)
$allowedDomainsPattern = $allowedDomains -join '|'

# Non-public IP ranges: RFC 1918 + loopback + APIPA + CGNAT + TEST-NET + benchmark + multicast + broadcast
# cspell:ignore APIPA CGNAT
$privateIpPattern = '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.0\.0\.0|255\.255\.255\.255|169\.254\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|198\.1[89]\.|22[4-9]\.|2[3-5][0-9]\.)'

# Specific email addresses that are allowed regardless of domain
$allowedEmails = @(
'ExToolsFeedback@microsoft.com'
)

foreach ($file in $Files) {
if (-not (Test-Path $file)) { continue }

try {
$lines = @(Get-Content -Path $file -ErrorAction Stop)
} catch {
Write-Host " BLOCKED: $file - Unable to read file: $($_.Exception.Message)" -ForegroundColor Red
$failed = $true
continue
}
if ($lines.Count -eq 0) { continue }

$domainsInFile = @{}

# Patterns defined once outside the per-line loop for performance
$tldPattern = '(com|net|org|edu|gov|io|info|biz|co|us|uk|de|au|ca|jp|fr|in|eu|cloud|app|dev|lab)'
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left for the author — expanding the TLD pattern to cover all public TLDs (e.g., .ru, .cn, .xyz) is an architectural decision. A generic [a-z]{2,63} pattern would significantly broaden detection scope and likely introduce false positives in test data files that contain non-domain dotted strings. The current curated TLD list covers the most common TLDs found in Exchange test data.

$fqdnRegex = '(?i)\b([a-zA-Z0-9][a-zA-Z0-9-]*(?:\.[a-zA-Z0-9-]+)*\.' + $tldPattern + ')\b'
$credentialKeywords = 'password|secret|apikey|token|credential'
$safeValues = @('true', 'false', 'Enabled', 'Disabled', 'Changed', 'None', 'NotConfigured')
$assignRegex = '(?i)\b(?:' + $credentialKeywords + ')\s*[:=]\s*[''"]([^''"]+)[''"]'
$xmlAttrRegex = '(?i)(?:key|name)\s*=\s*[''"](?:' + $credentialKeywords + ')[''"].*?value\s*=\s*[''"]([^''"]+)[''"]'
# cspell:ignore clixml
$clixmlRegex = '(?i)N\s*=\s*[''"](?:' + $credentialKeywords + ')[''"]>([^<]+)<'

$lineNumber = 0
foreach ($line in $lines) {
$lineNumber++

# Check for email addresses outside allowed domains
$emailMatches = [regex]::Matches($line, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}')
foreach ($match in $emailMatches) {
if ($match.Value -in $allowedEmails) { continue }
$emailTld = ($match.Value -split '\.')[-1].ToLower()
if ($emailTld -in $reservedTlds) { continue }
if ($match.Value -notmatch "@(?:.*\.)?($allowedDomainsPattern)$") {
Write-Host " BLOCKED: $file`:$lineNumber - Unrecognized email domain: $($match.Value)" -ForegroundColor Red
$failed = $true
}
}

# Check for bare domain names outside allowed domains
# Skip XML namespaces, .NET assembly/binary contexts, and known product strings
# cspell:ignore fqdn msilog microsft
# Note: \\Microsft\.Net\\ is a known typo in the default IIS web.config comment template
# shipped with Exchange ("located in \Windows\Microsft.Net\Frameworks\v2.x\Config")
if ($line -notmatch 'xmlns|assembly|codeBase|PublicKeyToken|\.dll|\.exe|\\Microsoft\.NET\\|\\Microsft\.Net\\|ASP\.NET|WMI\.NET|\.msilog') {
# Match FQDNs ending in known public TLDs
$fqdnMatches = [regex]::Matches($line, $fqdnRegex)
foreach ($fqdnMatch in $fqdnMatches) {
$fqdn = $fqdnMatch.Groups[1].Value
# Skip Windows file paths and email domain portions (already checked by email scanner)
$matchIndex = $fqdnMatch.Index
if ($matchIndex -ge 1 -and $line.Substring($matchIndex - 1, 1) -in @('\', '@')) { continue }
if ($fqdn -match '(?i)^System\.') { continue }
if ($fqdn -notmatch "(?i)(?:^|\.)($allowedDomainsPattern)$") {
$fqdnLower = $fqdn.ToLower()
if (-not $domainsInFile.ContainsKey($fqdnLower)) {
$domainsInFile[$fqdnLower] = 0
}
$domainsInFile[$fqdnLower]++
}
}
}

# Check for public IP addresses - only on lines that look like network/address context
# CLIXML files contain many dotted-quad version numbers (assembly versions, OIDs, etc.)
# so we only flag IPs on lines with network-related property names
if ($line -match 'Address|IPAddress|IPRange|Subnet|Gateway|DNS|Binding|Proxy|SmartHost|Remote|Endpoint') {
$ipMatches = [regex]::Matches($line, '\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b')
Comment thread
dpaulson45 marked this conversation as resolved.
foreach ($match in $ipMatches) {
$ip = $match.Value
$octets = $ip -split '\.'
# Skip if first octet is 0 or any octet > 255 (not a valid IP)
if ([int]$octets[0] -eq 0 -or ($octets | Where-Object { [int]$_ -gt 255 }).Count -gt 0) { continue }
# Skip subnet masks
if ($ip -match '^255\.') { continue }
# Skip version-like patterns
if ($line -match 'Version|Build|FileVersion|ProductVersion|ExchangeBuild') { continue }
if ($ip -notmatch $privateIpPattern) {
Write-Host " BLOCKED: $file`:$lineNumber - Public IP address: $ip" -ForegroundColor Red
$failed = $true
}
Comment thread
dpaulson45 marked this conversation as resolved.
}
}

# Check for credential patterns in multiple formats
$credentialFound = $false

# Assignment form: password = "value", secret: 'value'
$assignMatches = [regex]::Matches($line, $assignRegex)
foreach ($m in $assignMatches) {
if ($m.Groups[1].Value -notin $safeValues) {
$credentialFound = $true
break
}
}

# XML attribute form: key="password" value="secret"
if (-not $credentialFound) {
$xmlAttrMatches = [regex]::Matches($line, $xmlAttrRegex)
foreach ($m in $xmlAttrMatches) {
if ($m.Groups[1].Value -notin $safeValues) {
$credentialFound = $true
break
}
}
}

# CLIXML element form: N="Password">value</
if (-not $credentialFound) {
$clixmlMatches = [regex]::Matches($line, $clixmlRegex)
foreach ($m in $clixmlMatches) {
if ($m.Groups[1].Value.Trim() -notin $safeValues) {
$credentialFound = $true
break
}
}
}

if ($credentialFound) {
Write-Host " BLOCKED: $file`:$lineNumber - Possible credential value detected" -ForegroundColor Red
$failed = $true
}
}

# Report unrecognized domains found in this file (one line per domain)
foreach ($entry in $domainsInFile.GetEnumerator() | Sort-Object -Property Value -Descending) {
Write-Host " BLOCKED: $file - Unrecognized domain: $($entry.Key) ($($entry.Value) occurrences)" -ForegroundColor Red
$failed = $true
}
}

if ($failed) {
Write-Host "`nSensitive data check failed. Review flagged items above." -ForegroundColor Red
return 1
} else {
Write-Host " No sensitive data found." -ForegroundColor Green
return 0
}
9 changes: 9 additions & 0 deletions .github/GitHooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

# Git pre-commit hook - delegates to PowerShell for validation checks
# Install via: .github/Install-GitHooks.ps1

HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
Comment thread
dpaulson45 marked this conversation as resolved.
exec pwsh -NoProfile -NonInteractive -File "$HOOK_DIR/pre-commit.ps1"
Comment thread
dpaulson45 marked this conversation as resolved.
Comment thread
dpaulson45 marked this conversation as resolved.
Loading