diff --git a/.github/GitHooks/Test-HealthCheckerScenarioRunCount.ps1 b/.github/GitHooks/Test-HealthCheckerScenarioRunCount.ps1 new file mode 100644 index 0000000000..5bfe02e44f --- /dev/null +++ b/.github/GitHooks/Test-HealthCheckerScenarioRunCount.ps1 @@ -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 + 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 +} + +return 0 diff --git a/.github/GitHooks/Test-ScriptAnalyzer.ps1 b/.github/GitHooks/Test-ScriptAnalyzer.ps1 new file mode 100644 index 0000000000..b5858dd66c --- /dev/null +++ b/.github/GitHooks/Test-ScriptAnalyzer.ps1 @@ -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 + +$hasViolations = $false + +foreach ($file in $Files) { + if (-not (Test-Path $file)) { continue } + + $params = @{ + Path = $file + Severity = @('ParseError', 'Error', 'Warning') + } + + # 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 + } + + $results = Invoke-ScriptAnalyzer @params + + 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' }) + } + } +} + +if ($hasViolations) { + Write-Host "`nPSScriptAnalyzer found violations. Fix before committing." -ForegroundColor Red + return 1 +} else { + Write-Host " PSScriptAnalyzer: no issues found." -ForegroundColor Green + return 0 +} diff --git a/.github/GitHooks/Test-SensitiveData.ps1 b/.github/GitHooks/Test-SensitiveData.ps1 new file mode 100644 index 0000000000..ef7113eeb5 --- /dev/null +++ b/.github/GitHooks/Test-SensitiveData.ps1 @@ -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)' + $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') + 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 + } + } + } + + # 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 +[CmdletBinding()] +param() + +$repoRoot = Get-Item (Join-Path -Path $PSScriptRoot -ChildPath "..") +$hooksDir = Join-Path -Path $PSScriptRoot -ChildPath "GitHooks" + +if (-not (Test-Path (Join-Path -Path $repoRoot -ChildPath ".git"))) { + Write-Host "Error: .git not found. Are you in a git repository?" -ForegroundColor Red + exit 1 +} + +if (-not (Test-Path $hooksDir)) { + Write-Host "Error: .github/GitHooks directory not found." -ForegroundColor Red + exit 1 +} + +# Set git to use .github/GitHooks/ directly instead of .git/hooks/ +git -C $repoRoot.FullName config core.hooksPath ".github/GitHooks" +$gitConfigResult = $LASTEXITCODE + +# Ensure bash wrapper is executable on non-Windows platforms +if ($PSVersionTable.Platform -eq 'Unix' -or $PSVersionTable.OS -match 'Linux|Darwin') { + $preCommitPath = Join-Path -Path $hooksDir -ChildPath "pre-commit" + if (Test-Path $preCommitPath) { + chmod +x $preCommitPath + if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Failed to set executable permission on pre-commit hook." -ForegroundColor Red + exit 1 + } + } +} + +if ($gitConfigResult -eq 0) { + Write-Host "Git hooks configured successfully." -ForegroundColor Green + Write-Host " Hooks path: .github/GitHooks/" -ForegroundColor Cyan + Write-Host " Hook updates are picked up automatically on git pull." -ForegroundColor Cyan + Write-Host " Use 'git commit --no-verify' to bypass hooks when needed." -ForegroundColor Cyan +} else { + Write-Host "Error: Failed to set git hooks path." -ForegroundColor Red + exit 1 +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f00d82832..9b9fd1fdb0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,23 @@ * From PS7, run `.build\Build.ps1`. Test the resulting script in `dist/`. * Commit the changes on your own branch and open a Pull Request. +## Git Hooks (Optional) + +Pre-commit hooks are available to catch common issues before committing: +- **Sensitive data scanning** on test data files (blocks unrecognized email domains, public IPs, credential-like values) +- **Pester test run count** checking (blocks if a test file exceeds 5 pipeline runs) +- **PSScriptAnalyzer** validation on staged PowerShell files + +To enable, run once after cloning: + +```powershell +.github\Install-GitHooks.ps1 +``` + +This sets `core.hooksPath` to `.github/GitHooks/`. Hook updates are picked up automatically on `git pull`. Use `git commit --no-verify` to bypass hooks when needed. + +PSScriptAnalyzer >= 1.24 is required for full hook coverage. Install with `Install-Module PSScriptAnalyzer -MinimumVersion 1.24`. Without it, the analyzer check is skipped. + It is recommended to use Visual Studio Code when developing scripts for this project. Opening VSCode at the root of this repo will ensure that VSCode uses the settings in the repro to enforce most of the formatting rules.