Skip to content

Commit 8ee62af

Browse files
dpaulson45Copilot
andcommitted
Add git pre-commit hooks for code quality validation
Add optional pre-commit hooks that validate staged files before commit: - Sensitive data scanning on test data files (blocks unrecognized email domains, public IPs, credential-like values) - Pester test file pipeline run count checking (blocks if a test file exceeds 5 SetDefaultRunOfHealthChecker runs) - PSScriptAnalyzer validation with all CodeFormatterChecks to match CI Install via: .github/Install-GitHooks.ps1 Sets core.hooksPath to .github/GitHooks/ so hook updates are automatic. CONTRIBUTING.md updated with setup instructions and prerequisites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3cf0900 commit 8ee62af

7 files changed

Lines changed: 521 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
<#
5+
.SYNOPSIS
6+
Blocks commits when test files exceed the max pipeline run count.
7+
.DESCRIPTION
8+
Scans staged .Tests.ps1 files for SetDefaultRunOfHealthChecker calls.
9+
Blocks the commit if any file exceeds 5 runs (the benchmarked optimal maximum).
10+
#>
11+
[CmdletBinding()]
12+
param(
13+
[Parameter(Mandatory)]
14+
[string[]]$Files
15+
)
16+
17+
$maxRunsPerFile = 5
18+
$failed = $false
19+
20+
foreach ($file in $Files) {
21+
if (-not (Test-Path $file)) { continue }
22+
23+
# Count actual calls only, excluding comments and strings
24+
try {
25+
$content = Get-Content -Path $file -Raw -ErrorAction Stop
26+
} catch {
27+
Write-Host " BLOCKED: $file - Unable to read file: $($_.Exception.Message)" -ForegroundColor Red
28+
$failed = $true
29+
continue
30+
}
31+
$parseErrors = $null
32+
$tokens = [System.Management.Automation.PSParser]::Tokenize($content, [ref]$parseErrors)
33+
34+
if ($null -ne $parseErrors -and $parseErrors.Count -gt 0) {
35+
Write-Host " WARN: $file has $($parseErrors.Count) parse error(s) - run count may be inaccurate" -ForegroundColor Yellow
36+
}
37+
38+
$runCount = @($tokens | Where-Object {
39+
$_.Type -eq 'Command' -and $_.Content -eq 'SetDefaultRunOfHealthChecker'
40+
}).Count
41+
42+
if ($runCount -gt $maxRunsPerFile) {
43+
Write-Host " BLOCKED: $file has $runCount pipeline runs (max: $maxRunsPerFile)" -ForegroundColor Red
44+
Write-Host " Consider splitting this file. Balance runs evenly (e.g., 4+2 not 5+1)." -ForegroundColor Yellow
45+
$failed = $true
46+
} elseif ($runCount -gt 0) {
47+
Write-Host " OK: $file - $runCount pipeline run(s)" -ForegroundColor Green
48+
}
49+
}
50+
51+
# Use 'git commit --no-verify' to bypass if needed
52+
if ($failed) {
53+
Write-Host "`nTest run count check found issues. See above." -ForegroundColor Red
54+
return 1
55+
}
56+
57+
return 0
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
<#
5+
.SYNOPSIS
6+
Runs PSScriptAnalyzer and CodeFormatter checks on staged PowerShell files.
7+
.DESCRIPTION
8+
Blocks commits with PSScriptAnalyzer violations and CodeFormatter issues using
9+
the repository's PSScriptAnalyzerSettings.psd1, CustomRules.psm1, and
10+
CodeFormatterChecks to match the CI pipeline.
11+
#>
12+
[CmdletBinding()]
13+
param(
14+
[Parameter(Mandatory)]
15+
[string[]]$Files
16+
)
17+
18+
# cspell:ignore toplevel
19+
$repoRoot = git rev-parse --show-toplevel
20+
21+
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrEmpty($repoRoot)) {
22+
Write-Host " Error: Unable to determine repository root." -ForegroundColor Red
23+
return 1
24+
}
25+
26+
$settingsPath = Join-Path -Path $repoRoot -ChildPath "PSScriptAnalyzerSettings.psd1"
27+
$customRulesPath = Join-Path -Path (Join-Path -Path (Join-Path -Path $repoRoot -ChildPath ".build") -ChildPath "CodeFormatterChecks") -ChildPath "CustomRules.psm1"
28+
$codeFormatterChecksPath = Join-Path -Path (Join-Path -Path $repoRoot -ChildPath ".build") -ChildPath "CodeFormatterChecks"
29+
30+
# Dot-source CodeFormatter check functions
31+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckContainsCurlyQuotes.ps1")
32+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckFileHasNewlineAtEndOfFile.ps1")
33+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckMultipleEmptyLines.ps1")
34+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckScriptFileHasBOM.ps1")
35+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckScriptFileHasComplianceHeader.ps1")
36+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckScriptFormat.ps1")
37+
. (Join-Path -Path $codeFormatterChecksPath -ChildPath "CheckTokenTypeCasing.ps1")
38+
39+
# Check if PSScriptAnalyzer >= 1.24 is available (matches .build/CodeFormatter.ps1)
40+
$module = Get-Module -ListAvailable -Name PSScriptAnalyzer |
41+
Where-Object { $_.Version -ge [version]"1.24" } |
42+
Select-Object -First 1
43+
44+
if ($null -eq $module) {
45+
Write-Host " SKIP: PSScriptAnalyzer >= 1.24 not installed." -ForegroundColor Yellow
46+
return 0
47+
}
48+
49+
Import-Module PSScriptAnalyzer -MinimumVersion "1.24" -ErrorAction Stop
50+
51+
# Check if EncodingAnalyzer is available for BOM checks
52+
$hasEncodingAnalyzer = $null -ne (Get-Module -ListAvailable -Name EncodingAnalyzer | Select-Object -First 1)
53+
if ($hasEncodingAnalyzer) {
54+
Import-Module EncodingAnalyzer -ErrorAction SilentlyContinue
55+
} else {
56+
Write-Host " NOTE: EncodingAnalyzer not installed - BOM checks will be skipped." -ForegroundColor Yellow
57+
}
58+
59+
$hasViolations = $false
60+
61+
foreach ($file in $Files) {
62+
if (-not (Test-Path $file)) { continue }
63+
64+
$fileInfo = Get-Item -Path $file
65+
66+
# Run CodeFormatter checks (report only, no auto-fix)
67+
if (CheckFileHasNewlineAtEndOfFile $fileInfo $false) { $hasViolations = $true }
68+
if (CheckScriptFileHasComplianceHeader $fileInfo $false) { $hasViolations = $true }
69+
if (CheckTokenTypeCasing $fileInfo $false "Keyword") { $hasViolations = $true }
70+
if (CheckTokenTypeCasing $fileInfo $false "Operator") { $hasViolations = $true }
71+
if (CheckMultipleEmptyLines $fileInfo $false) { $hasViolations = $true }
72+
if (CheckContainsCurlyQuotes $fileInfo $false) { $hasViolations = $true }
73+
74+
if ($hasEncodingAnalyzer) {
75+
if (CheckScriptFileHasBOM $fileInfo $false) { $hasViolations = $true }
76+
}
77+
78+
$formatResults = @(CheckScriptFormat $fileInfo $false)
79+
if ($formatResults.Length -gt 0 -and $formatResults[0] -eq $true) {
80+
$hasViolations = $true
81+
}
82+
83+
# Run PSScriptAnalyzer with repo settings and custom rules
84+
$params = @{
85+
Path = $file
86+
Severity = @('ParseError', 'Error', 'Warning')
87+
Settings = $settingsPath
88+
CustomRulePath = $customRulesPath
89+
IncludeDefaultRules = $true
90+
}
91+
92+
$results = Invoke-ScriptAnalyzer @params
93+
94+
if ($null -ne $results -and $results.Count -gt 0) {
95+
$hasViolations = $true
96+
foreach ($r in $results) {
97+
Write-Host " $($r.Severity): $file`:$($r.Line) - [$($r.RuleName)] $($r.Message)" -ForegroundColor $(if ($r.Severity -in @('Error', 'ParseError')) { 'Red' } else { 'Yellow' })
98+
}
99+
}
100+
}
101+
102+
if ($hasViolations) {
103+
Write-Host "`nCode quality checks found violations. Fix before committing." -ForegroundColor Red
104+
return 1
105+
} else {
106+
Write-Host " Code quality checks: no issues found." -ForegroundColor Green
107+
return 0
108+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
<#
5+
.SYNOPSIS
6+
Scans test data files for sensitive data patterns.
7+
.DESCRIPTION
8+
Checks staged test data files for email addresses and domain names outside
9+
allowed test domains, public IP addresses, and credential-like patterns.
10+
#>
11+
[CmdletBinding()]
12+
param(
13+
[Parameter(Mandatory)]
14+
[string[]]$Files
15+
)
16+
17+
$failed = $false
18+
19+
# RFC-reserved TLDs that can't be publicly registered (safe for test data)
20+
# cspell:ignore Tlds
21+
$reservedTlds = @('local', 'test', 'example', 'invalid', 'localhost', 'internal', 'lan')
22+
23+
# Allowed domains in test data (entries are regex fragments with escaped dots)
24+
# cspell:ignore vnext apikey fabrikam microsoftonline cloudapp
25+
$allowedDomains = @(
26+
'fabrikam\.com',
27+
'example\.com',
28+
'contoso\.com',
29+
'contoso\.lab',
30+
'contoso\.mail\.onmicrosoft\.com',
31+
# Microsoft service domains (product constants in Exchange configuration)
32+
'microsoft\.com',
33+
'microsoftonline-p\.com',
34+
'microsoftonline\.com',
35+
'outlook\.com',
36+
'live\.com',
37+
'live-int\.com',
38+
'office365\.com',
39+
'office\.com',
40+
'msn\.com',
41+
'passport\.com',
42+
'cloudapp\.net'
43+
)
44+
$allowedDomainsPattern = $allowedDomains -join '|'
45+
46+
# Non-public IP ranges: RFC 1918 + loopback + APIPA + CGNAT + TEST-NET + benchmark + multicast + broadcast
47+
# cspell:ignore APIPA CGNAT
48+
$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]\.)'
49+
50+
# Specific email addresses that are allowed regardless of domain
51+
$allowedEmails = @(
52+
'ExToolsFeedback@microsoft.com'
53+
)
54+
55+
foreach ($file in $Files) {
56+
if (-not (Test-Path $file)) { continue }
57+
58+
try {
59+
$lines = @(Get-Content -Path $file -ErrorAction Stop)
60+
} catch {
61+
Write-Host " BLOCKED: $file - Unable to read file: $($_.Exception.Message)" -ForegroundColor Red
62+
$failed = $true
63+
continue
64+
}
65+
if ($lines.Count -eq 0) { continue }
66+
67+
$domainsInFile = @{}
68+
69+
# Patterns defined once outside the per-line loop for performance
70+
$tldPattern = '(com|net|org|edu|gov|io|info|biz|co|us|uk|de|au|ca|jp|fr|in|eu|cloud|app|dev|lab)'
71+
$fqdnRegex = '(?i)\b([a-zA-Z0-9][a-zA-Z0-9-]*(?:\.[a-zA-Z0-9-]+)*\.' + $tldPattern + ')\b'
72+
$credentialKeywords = 'password|secret|apikey|token|credential'
73+
$safeValues = @('true', 'false', 'Enabled', 'Disabled', 'Changed', 'None', 'NotConfigured')
74+
$assignRegex = '(?i)\b(?:' + $credentialKeywords + ')\s*[:=]\s*[''"]([^''"]+)[''"]'
75+
$xmlAttrRegex = '(?i)(?:key|name)\s*=\s*[''"](?:' + $credentialKeywords + ')[''"].*?value\s*=\s*[''"]([^''"]+)[''"]'
76+
# cspell:ignore clixml
77+
$clixmlRegex = '(?i)N\s*=\s*[''"](?:' + $credentialKeywords + ')[''"]>([^<]+)<'
78+
79+
$lineNumber = 0
80+
foreach ($line in $lines) {
81+
$lineNumber++
82+
83+
# Check for email addresses outside allowed domains
84+
$emailMatches = [regex]::Matches($line, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}')
85+
foreach ($match in $emailMatches) {
86+
if ($match.Value -in $allowedEmails) { continue }
87+
$emailTld = ($match.Value -split '\.')[-1].ToLower()
88+
if ($emailTld -in $reservedTlds) { continue }
89+
if ($match.Value -notmatch "@(?:.*\.)?($allowedDomainsPattern)$") {
90+
Write-Host " BLOCKED: $file`:$lineNumber - Unrecognized email domain: $($match.Value)" -ForegroundColor Red
91+
$failed = $true
92+
}
93+
}
94+
95+
# Check for bare domain names outside allowed domains
96+
# Skip XML namespaces, .NET assembly/binary contexts, and known product strings
97+
# cspell:ignore fqdn msilog microsft
98+
# Note: \\Microsft\.Net\\ is a known typo in the default IIS web.config comment template
99+
# shipped with Exchange ("located in \Windows\Microsft.Net\Frameworks\v2.x\Config")
100+
if ($line -notmatch 'xmlns|assembly|codeBase|PublicKeyToken|\.dll|\.exe|\\Microsoft\.NET\\|\\Microsft\.Net\\|ASP\.NET|WMI\.NET|\.msilog') {
101+
# Match FQDNs ending in known public TLDs
102+
$fqdnMatches = [regex]::Matches($line, $fqdnRegex)
103+
foreach ($fqdnMatch in $fqdnMatches) {
104+
$fqdn = $fqdnMatch.Groups[1].Value
105+
# Skip Windows file paths and email domain portions (already checked by email scanner)
106+
$matchIndex = $fqdnMatch.Index
107+
if ($matchIndex -ge 1 -and $line.Substring($matchIndex - 1, 1) -in @('\', '@')) { continue }
108+
if ($fqdn -match '(?i)^System\.') { continue }
109+
if ($fqdn -notmatch "(?i)(?:^|\.)($allowedDomainsPattern)$") {
110+
$fqdnLower = $fqdn.ToLower()
111+
if (-not $domainsInFile.ContainsKey($fqdnLower)) {
112+
$domainsInFile[$fqdnLower] = 0
113+
}
114+
$domainsInFile[$fqdnLower]++
115+
}
116+
}
117+
}
118+
119+
# Check for public IP addresses - only on lines that look like network/address context
120+
# CLIXML files contain many dotted-quad version numbers (assembly versions, OIDs, etc.)
121+
# so we only flag IPs on lines with network-related property names
122+
if ($line -match 'Address|IPAddress|IPRange|Subnet|Gateway|DNS|Binding|Proxy|SmartHost|Remote|Endpoint') {
123+
$ipMatches = [regex]::Matches($line, '\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b')
124+
foreach ($match in $ipMatches) {
125+
$ip = $match.Value
126+
$octets = $ip -split '\.'
127+
# Skip if first octet is 0 or any octet > 255 (not a valid IP)
128+
if ([int]$octets[0] -eq 0 -or ($octets | Where-Object { [int]$_ -gt 255 }).Count -gt 0) { continue }
129+
# Skip subnet masks
130+
if ($ip -match '^255\.') { continue }
131+
# Skip version-like patterns
132+
if ($line -match 'Version|Build|FileVersion|ProductVersion|ExchangeBuild') { continue }
133+
if ($ip -notmatch $privateIpPattern) {
134+
Write-Host " BLOCKED: $file`:$lineNumber - Public IP address: $ip" -ForegroundColor Red
135+
$failed = $true
136+
}
137+
}
138+
}
139+
140+
# Check for credential patterns in multiple formats
141+
$credentialFound = $false
142+
143+
# Assignment form: password = "value", secret: 'value'
144+
$assignMatches = [regex]::Matches($line, $assignRegex)
145+
foreach ($m in $assignMatches) {
146+
if ($m.Groups[1].Value -notin $safeValues) {
147+
$credentialFound = $true
148+
break
149+
}
150+
}
151+
152+
# XML attribute form: key="password" value="secret"
153+
if (-not $credentialFound) {
154+
$xmlAttrMatches = [regex]::Matches($line, $xmlAttrRegex)
155+
foreach ($m in $xmlAttrMatches) {
156+
if ($m.Groups[1].Value -notin $safeValues) {
157+
$credentialFound = $true
158+
break
159+
}
160+
}
161+
}
162+
163+
# CLIXML element form: N="Password">value</
164+
if (-not $credentialFound) {
165+
$clixmlMatches = [regex]::Matches($line, $clixmlRegex)
166+
foreach ($m in $clixmlMatches) {
167+
if ($m.Groups[1].Value.Trim() -notin $safeValues) {
168+
$credentialFound = $true
169+
break
170+
}
171+
}
172+
}
173+
174+
if ($credentialFound) {
175+
Write-Host " BLOCKED: $file`:$lineNumber - Possible credential value detected" -ForegroundColor Red
176+
$failed = $true
177+
}
178+
}
179+
180+
# Report unrecognized domains found in this file (one line per domain)
181+
foreach ($entry in $domainsInFile.GetEnumerator() | Sort-Object -Property Value -Descending) {
182+
Write-Host " BLOCKED: $file - Unrecognized domain: $($entry.Key) ($($entry.Value) occurrences)" -ForegroundColor Red
183+
$failed = $true
184+
}
185+
}
186+
187+
if ($failed) {
188+
Write-Host "`nSensitive data check failed. Review flagged items above." -ForegroundColor Red
189+
return 1
190+
} else {
191+
Write-Host " No sensitive data found." -ForegroundColor Green
192+
return 0
193+
}

.github/GitHooks/pre-commit

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/bin/bash
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
5+
# Git pre-commit hook - delegates to PowerShell for validation checks
6+
# Install via: .github/Install-GitHooks.ps1
7+
8+
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
9+
exec pwsh -NoProfile -NonInteractive -File "$HOOK_DIR/pre-commit.ps1"

0 commit comments

Comments
 (0)