-
Notifications
You must be signed in to change notification settings - Fork 391
Add git pre-commit hooks for code quality validation #2534
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dpaul-Copilot
Are you sure you want to change the base?
Changes from all commits
be4d544
4c71371
78d17d7
dd0fcfa
c167b7e
fe1c6e7
00edf11
101b74a
4ed26a7
3655c49
dffc67e
00430ab
12f380e
04aff64
1aff56b
cc21a94
1f91ddf
5a82b5b
5f59c86
d4ba167
a6e7739
3c9b7bc
adcfa5b
ca446b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). | ||
| #> | ||
| [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 | ||
|
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 | ||
|
dpaulson45 marked this conversation as resolved.
|
||
| } | ||
|
dpaulson45 marked this conversation as resolved.
dpaulson45 marked this conversation as resolved.
|
||
|
|
||
| return 0 | ||
| 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 | ||
|
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') | ||
| } | ||
|
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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not fixing —
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
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' }) | ||
| } | ||
| } | ||
|
dpaulson45 marked this conversation as resolved.
dpaulson45 marked this conversation as resolved.
dpaulson45 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if ($hasViolations) { | ||
| Write-Host "`nPSScriptAnalyzer found violations. Fix before committing." -ForegroundColor Red | ||
| return 1 | ||
|
dpaulson45 marked this conversation as resolved.
|
||
| } else { | ||
| Write-Host " PSScriptAnalyzer: no issues found." -ForegroundColor Green | ||
| return 0 | ||
| } | ||
| 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)' | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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., |
||
| $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') | ||
|
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 | ||
| } | ||
|
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 | ||
| } | ||
| 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)" | ||
|
dpaulson45 marked this conversation as resolved.
|
||
| exec pwsh -NoProfile -NonInteractive -File "$HOOK_DIR/pre-commit.ps1" | ||
|
dpaulson45 marked this conversation as resolved.
dpaulson45 marked this conversation as resolved.
|
||
Uh oh!
There was an error while loading. Please reload this page.