diff --git a/.config/PSScriptAnalyzerRules/Measure-RequireStrictMode.Tests.ps1 b/.config/PSScriptAnalyzerRules/Measure-RequireStrictMode.Tests.ps1 new file mode 100644 index 0000000..9687da4 --- /dev/null +++ b/.config/PSScriptAnalyzerRules/Measure-RequireStrictMode.Tests.ps1 @@ -0,0 +1,40 @@ +#Requires -Version 7.0 +Set-StrictMode -Version Latest + +# Capability flag at DISCOVERY time (-Skip is evaluated then). +$HasPSSA = [bool](Get-Module -ListAvailable -Name PSScriptAnalyzer) + +BeforeAll { + # Exercise the rule through the real hook in a CHILD process. This tests the + # public boundary (what CI runs) and avoids in-process PSScriptAnalyzer state + # leaking between test files in one Pester session. + $script:Hook = Join-Path $PSScriptRoot '..' 'scripts' 'Test-PowerShellScript.ps1' | + Resolve-Path | Select-Object -ExpandProperty Path + + function Invoke-Hook { + param([string]$Content, [string]$Extension = '.ps1') + $file = Join-Path ([IO.Path]::GetTempPath()) ('mrsm-{0}{1}' -f [guid]::NewGuid(), $Extension) + Set-Content -LiteralPath $file -Value $Content + try { + $output = & pwsh -NoProfile -File $script:Hook $file 2>&1 | Out-String + [pscustomobject]@{ Exit = $LASTEXITCODE; Output = $output } + } + finally { Remove-Item -LiteralPath $file -ErrorAction SilentlyContinue } + } +} + +Describe 'Measure-RequireStrictMode (via the hook)' -Tag 'Fast' -Skip:(-not $HasPSSA) { + It 'fails a script that omits Set-StrictMode, naming the rule' { + $r = Invoke-Hook -Content 'Write-Output 1' + $r.Exit | Should -Be 1 + $r.Output | Should -Match 'Measure-RequireStrictMode' + } + + It 'passes a script that calls Set-StrictMode' { + (Invoke-Hook -Content "Set-StrictMode -Version Latest`nWrite-Output 1").Exit | Should -Be 0 + } + + It 'exempts data files (*.psd1)' { + (Invoke-Hook -Content '@{ Answer = 42 }' -Extension '.psd1').Exit | Should -Be 0 + } +} diff --git a/.config/PSScriptAnalyzerRules/Measure-RequireStrictMode.psm1 b/.config/PSScriptAnalyzerRules/Measure-RequireStrictMode.psm1 new file mode 100644 index 0000000..12c61f1 --- /dev/null +++ b/.config/PSScriptAnalyzerRules/Measure-RequireStrictMode.psm1 @@ -0,0 +1,58 @@ +#Requires -Version 7.0 +<# + Custom PSScriptAnalyzer rule, loaded via CustomRulePath in + ../PSScriptAnalyzerSettings.psd1. See .config/scripts/README.md. +#> + +Set-StrictMode -Version Latest + +function Measure-RequireStrictMode { + <# + .SYNOPSIS + Flags a script that never calls Set-StrictMode. + .DESCRIPTION + `Set-StrictMode -Version Latest` turns unassigned variables, missing + properties and malformed calls into errors instead of silent bugs — the + fail-fast baseline this repo expects. The rule fires once per file when + no Set-StrictMode invocation is present anywhere in it. + .PARAMETER ScriptBlockAst + Script block AST supplied by PSScriptAnalyzer for each block in the file. + .OUTPUTS + [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]] + #> + [CmdletBinding()] + [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.ScriptBlockAst]$ScriptBlockAst + ) + + process { + # Evaluate only the file's root block. Nested blocks (functions, script + # blocks) inherit strict mode, so checking them would raise a duplicate + # finding per block. + if ($null -ne $ScriptBlockAst.Parent) { return } + + # Data files (*.psd1) are declarative manifests, not executable scripts; + # Set-StrictMode does not apply to them. + if ($ScriptBlockAst.Extent.File -like '*.psd1') { return } + + $hasStrictMode = $ScriptBlockAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Set-StrictMode' + }, $true) + + if ($hasStrictMode) { return } + + [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new( + 'Script does not call Set-StrictMode. Add `Set-StrictMode -Version Latest` near the top to fail fast on unassigned variables and typos.', + $ScriptBlockAst.Extent, + 'Measure-RequireStrictMode', + [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Warning, + $ScriptBlockAst.Extent.File + ) + } +} + +Export-ModuleMember -Function Measure-RequireStrictMode diff --git a/.config/overlays/semgrep-pro/README.md b/.config/overlays/semgrep-pro/README.md new file mode 100644 index 0000000..1fd0f7d --- /dev/null +++ b/.config/overlays/semgrep-pro/README.md @@ -0,0 +1,48 @@ +# Semgrep Pro overlay (documentation only — no license, not activated) + +This folder is a **decision record**, not working config. It exists so the +tradeoff is written down once instead of rediscovered per repo. + +The free-tier semgrep setup is the [`semgrep` overlay](../semgrep/README.md) — +also opt-in, also needing no account, license, or per-repo quota. Activating +that overlay is unaffected by anything below; this overlay only concerns +*Pro*-tier features layered on top of it. + +## What Pro adds, and why it isn't on + +Semgrep's **free/OSS CLI accepts a `languages: [powershell]` rule but silently +skips it** — 0 findings, no error, just a warning that it "requires Pro". Pro +also adds cross-file analysis, AI-assisted triage/remediation, and 60 AI +credits. Verified by testing against the running CLI (2026-07, semgrep 1.170.0). + +The blocker is not cost tuning, it's a **hard account-wide cap**: + +> Scan up to 10 repositories · Maximum 10 contributors + +This template is cloned into new repos on purpose. Baking a Pro dependency into +the template would mean every repo created from it silently competes for one of +only 10 slots on the account — a scarce resource, not a per-repo toggle. That is +why this stays an inert, deliberately-activated overlay: the decision to spend a +slot belongs to a human, once, per repo that actually needs PowerShell +enforcement (or the other Pro features) badly enough to justify it. + +## If you decide to activate it (on a specific repo) + +1. Check the current slot count against the 10-repo cap before linking another + one — (or your org's usage page). +2. Link the repo: , then `semgrep login` locally / in + CI. +3. Rules with `languages: [powershell]` (or other Pro-only targets) then run + with `semgrep scan --pro` — the free-tier flag drops the Pro skip. +4. No PowerShell Pro rules exist in this repo yet — none are written or tested, + since there is currently no account to verify them against. Write and verify + them the same way the [bash rule](../../semgrep/rules/bash-no-blanket-strict-mode.yaml) + was: a violating + a clean sample file, run through `semgrep scan`, before + adding the rule to `.config/semgrep/rules/`. + +## Re-evaluate if + +- The account already holds a Pro subscription for other reasons (then the + 10-repo cap is a real, trackable constraint to budget against, not a blocker). +- PowerShell policy enforcement becomes valuable enough on a *specific* repo to + spend one of the 10 slots on it deliberately. diff --git a/.config/overlays/semgrep/README.md b/.config/overlays/semgrep/README.md new file mode 100644 index 0000000..f7b5ee8 --- /dev/null +++ b/.config/overlays/semgrep/README.md @@ -0,0 +1,44 @@ +# Semgrep overlay (opt-in activation) + +Semgrep is **not wired into pre-commit by default**. The rule files are real +and tracked at [`.config/semgrep/`](../../semgrep/README.md) — this overlay is +only the *activation* (the pre-commit hook fragment). Nothing runs until you +paste it in. + +## Why activation is opt-in, not core + +It was briefly wired in as an active hook + scheduled CI workflow and then +reverted. Two real frictions drove that: + +- **The managed pre-commit hook does not work on Windows** — pre-commit clones + semgrep's own git repo, which contains case-colliding paths that fail + checkout on NTFS. The `repo: local` PyPI-wheel workaround below fixes it but + adds install weight and a `require_serial: true` workaround for a + `~/.semgrep/settings.yml` race between parallel instances. +- **Its language coverage has real gaps for this template's stack**: SQL has + no support at any tier; **PowerShell parses but Pro-gates rule execution** + (free CLI silently reports 0 findings — see + [`../semgrep-pro/README.md`](../semgrep-pro/README.md)), and Pro is capped at + 10 repos account-wide — unsuitable for a template cloned into many repos. + +None of that makes semgrep *bad* — the shipped rules are verified and work — +but it does not clear the bar for base-template tooling every clone inherits by +default. Tracking issue for a replacement evaluation: +. + +## Activate + +1. **Install semgrep** — `pip install semgrep` (or `pipx install semgrep`). +2. **Wire the hook** — paste the block from + [`precommit-hook.yaml`](precommit-hook.yaml) into the `repos:` list of + [`/.pre-commit-config.yaml`](../../../.pre-commit-config.yaml). It installs + from PyPI as a `repo: local` hook (not the managed `repo:` clone, which + fails on Windows — see above). +3. **Verify** — `semgrep scan --config .config/semgrep/rules --metrics=off .` + should run clean, or report only intended findings. + +## Why an overlay, not a placeholder + +A half-configured tool that errors on every run trains people to ignore it. +Inert-but-complete means it never breaks a repo that ignores it, yet activation +is a paste-and-pin. Same rationale as the [Vale overlay](../vale/README.md). diff --git a/.config/overlays/semgrep/precommit-hook.yaml b/.config/overlays/semgrep/precommit-hook.yaml new file mode 100644 index 0000000..6a27dc0 --- /dev/null +++ b/.config/overlays/semgrep/precommit-hook.yaml @@ -0,0 +1,23 @@ +# Semgrep hook FRAGMENT — inert. To activate, paste this block into the `repos:` +# list of /.pre-commit-config.yaml, then bump the pin manually (semgrep is NOT +# managed by `pre-commit autoupdate` here — see below). +# +# `repo: local` + PyPI wheel, NOT the managed `repo: https://github.com/semgrep/semgrep` +# form: that clones semgrep's own git repo, which contains case-colliding paths +# that fail checkout on Windows/NTFS. `require_serial: true` avoids a +# `~/.semgrep/settings.yml` race between parallel instances on Windows. +- repo: local + hooks: + - id: semgrep + name: semgrep (project policy rules) + language: python + additional_dependencies: ["semgrep==1.170.0"] # verify / bump manually + require_serial: true + entry: semgrep + args: + - --config=.config/semgrep/rules + - --error + - --metrics=off + - --quiet + - --skip-unknown-extensions + - --disable-version-check diff --git a/.config/scripts/Enable-MergeQueue.Tests.ps1 b/.config/scripts/Enable-MergeQueue.Tests.ps1 deleted file mode 100644 index 3be7df6..0000000 --- a/.config/scripts/Enable-MergeQueue.Tests.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -#Requires -Version 7.0 -BeforeAll { - # Dot-source the SUT: defines its functions; the run guard skips execution. - . (Join-Path $PSScriptRoot 'Enable-MergeQueue.ps1') -} - -Describe 'Get-MergeQueueRuleset' -Tag 'Fast' { - BeforeAll { - $script:R = Get-MergeQueueRuleset -RulesetName 'x' -CheckName 'lint' -MergeMethod 'REBASE' - } - - It 'targets the default branch' { - $script:R.conditions.ref_name.include | Should -Contain '~DEFAULT_BRANCH' - } - - It 'requires the given status check' { - $rsc = ($script:R.rules | Where-Object type -EQ 'required_status_checks').parameters.required_status_checks - $rsc.context | Should -Contain 'lint' - } - - It 'uses the chosen merge method' { - $mq = ($script:R.rules | Where-Object type -EQ 'merge_queue').parameters - $mq.merge_method | Should -Be 'REBASE' - } -} - -Describe 'Invoke-EnableMergeQueue' -Tag 'Fast' { - It 'returns non-zero when gh is not installed' { - Mock Test-GhCli { $false } - Invoke-EnableMergeQueue -CheckName 'lint' -RulesetName 'x' -MergeMethod 'SQUASH' 2>$null | - Should -Be 1 - } - - It 'posts the ruleset to the repo rulesets endpoint on success' { - Mock Test-GhCli { $true } - Mock gh { - if ($args -contains 'view') { 'owner/repo' } else { $global:LASTEXITCODE = 0 } - } - Invoke-EnableMergeQueue -CheckName 'lint' -RulesetName 'x' -MergeMethod 'SQUASH' 6>$null | - Should -Be 0 - Should -Invoke gh -ParameterFilter { - ($args -contains 'api') -and (($args -join ' ') -match 'repos/owner/repo/rulesets') - } - } -} diff --git a/.config/scripts/Enable-MergeQueue.ps1 b/.config/scripts/Enable-MergeQueue.ps1 deleted file mode 100644 index 1288cac..0000000 --- a/.config/scripts/Enable-MergeQueue.ps1 +++ /dev/null @@ -1,109 +0,0 @@ -#Requires -Version 7.0 -<# -.SYNOPSIS - Create a merge-queue ruleset on this repo's default branch. - -.DESCRIPTION - Run once per repo, AFTER the first push and at least one CI run (so the - required status check exists). Requires the GitHub CLI, authenticated with - permission to manage rulesets. - - The ruleset requires the CI check (default 'lint') and routes merges through - a merge queue, so the full check set runs on the queued commit before it - lands. - -.PARAMETER CheckName - Status-check context that must pass. Must match the CI job name - (lint.yml job -> `name: lint`). - -.PARAMETER MergeMethod - How the queue merges entries. - -.NOTES - verify: the merge_queue parameter schema against the current GitHub REST - rulesets API on first run. -#> -[CmdletBinding()] -param( - [string]$CheckName = 'lint', - [string]$RulesetName = 'merge-queue', - [ValidateSet('MERGE', 'SQUASH', 'REBASE')] - [string]$MergeMethod = 'SQUASH' -) - -function Test-GhCli { [bool](Get-Command gh -ErrorAction SilentlyContinue) } - -function Get-MergeQueueRuleset { - <# Build the rulesets-API payload (pure; no side effects). #> - param( - [Parameter(Mandatory)][string]$RulesetName, - [Parameter(Mandatory)][string]$CheckName, - [Parameter(Mandatory)][string]$MergeMethod - ) - @{ - name = $RulesetName - target = 'branch' - enforcement = 'active' - conditions = @{ ref_name = @{ include = @('~DEFAULT_BRANCH'); exclude = @() } } - rules = @( - @{ - type = 'merge_queue' - parameters = @{ - merge_method = $MergeMethod - grouping_strategy = 'ALLGREEN' - max_entries_to_build = 5 - min_entries_to_merge = 1 - max_entries_to_merge = 5 - min_entries_to_merge_wait_minutes = 5 - check_response_timeout_minutes = 60 - } - } - @{ - type = 'required_status_checks' - parameters = @{ - required_status_checks = @(@{ context = $CheckName }) - strict_required_status_checks_policy = $false - } - } - ) - } -} - -function Invoke-EnableMergeQueue { - param( - [Parameter(Mandatory)][string]$CheckName, - [Parameter(Mandatory)][string]$RulesetName, - [Parameter(Mandatory)][string]$MergeMethod - ) - - # Return codes are the contract; keep Write-Error non-terminating even under - # a caller running $ErrorActionPreference = 'Stop'. - $ErrorActionPreference = 'Continue' - - if (-not (Test-GhCli)) { - Write-Error 'GitHub CLI (gh) is not installed. https://cli.github.com/' - return 1 - } - - $repo = gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null - if (-not $repo) { - Write-Error 'Not a GitHub repository, or gh is not authenticated (`gh auth login`).' - return 1 - } - - $json = Get-MergeQueueRuleset -RulesetName $RulesetName -CheckName $CheckName -MergeMethod $MergeMethod | - ConvertTo-Json -Depth 8 - $json | gh api --method POST "repos/$repo/rulesets" --input - - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create ruleset (already exists, or check name '$CheckName' mismatch)." - return 1 - } - - Write-Information "Merge-queue ruleset '$RulesetName' active on $repo." -InformationAction Continue - return 0 -} - -# Execute only when run directly, not when dot-sourced by a test. -if ($MyInvocation.InvocationName -ne '.') { - exit (Invoke-EnableMergeQueue -CheckName $CheckName -RulesetName $RulesetName -MergeMethod $MergeMethod) -} diff --git a/.config/scripts/Initialize-DevEnvironment.Tests.ps1 b/.config/scripts/Initialize-DevEnvironment.Tests.ps1 index acae795..d2a5342 100644 --- a/.config/scripts/Initialize-DevEnvironment.Tests.ps1 +++ b/.config/scripts/Initialize-DevEnvironment.Tests.ps1 @@ -1,4 +1,6 @@ #Requires -Version 7.0 +Set-StrictMode -Version Latest + BeforeAll { # Dot-source the SUT: defines its functions; the run guard skips execution. . (Join-Path $PSScriptRoot 'Initialize-DevEnvironment.ps1') diff --git a/.config/scripts/Initialize-DevEnvironment.ps1 b/.config/scripts/Initialize-DevEnvironment.ps1 index b9df345..07c5f59 100644 --- a/.config/scripts/Initialize-DevEnvironment.ps1 +++ b/.config/scripts/Initialize-DevEnvironment.ps1 @@ -26,6 +26,8 @@ param( [switch]$UpdateHooks ) +Set-StrictMode -Version Latest + function Test-Command { param([string]$Name) [bool](Get-Command $Name -ErrorAction SilentlyContinue) } function Test-Elevated { @@ -94,6 +96,19 @@ Docs: https://docs.astral.sh/uv/getting-started/installation/ Invoke-DevModuleInstall -Name $m.Name -MinimumVersion $m.Min } + # asciidoctor backs the asciidoctor-validate hook; it needs Ruby, which this + # script does not install. Warn (not fail) — the hook only fires on *.adoc. + if (-not (Test-Command asciidoctor)) { + if (Test-Command gem) { + Write-Verbose 'Installing asciidoctor...' + & gem install asciidoctor --no-document + if ($LASTEXITCODE -ne 0) { Write-Warning 'gem install asciidoctor failed; committing *.adoc files will fail until it is installed.' } + } + else { + Write-Warning 'Ruby/gem not found — asciidoctor not installed. Committing *.adoc files will fail until Ruby is installed (winget install RubyInstallerTeam.Ruby.3.4) and `gem install asciidoctor` is run.' + } + } + Invoke-PreCommitInstall -PythonVersion $PythonVersion -UpdateHooks:$UpdateHooks Write-Information 'Dev environment ready. Hooks active for commit and push.' -InformationAction Continue return 0 diff --git a/.config/scripts/Invoke-TestLane.Tests.ps1 b/.config/scripts/Invoke-TestLane.Tests.ps1 index 25378e3..e0a5a30 100644 --- a/.config/scripts/Invoke-TestLane.Tests.ps1 +++ b/.config/scripts/Invoke-TestLane.Tests.ps1 @@ -1,4 +1,6 @@ #Requires -Version 7.0 +Set-StrictMode -Version Latest + # Capability flags must be set at DISCOVERY time (-Skip is evaluated then). $HasPester = [bool](Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge [version]'5.0.0' }) diff --git a/.config/scripts/Invoke-TestLane.ps1 b/.config/scripts/Invoke-TestLane.ps1 index 376e10c..9f9da00 100644 --- a/.config/scripts/Invoke-TestLane.ps1 +++ b/.config/scripts/Invoke-TestLane.ps1 @@ -23,6 +23,7 @@ param( [string]$Lane ) +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') | Select-Object -ExpandProperty Path diff --git a/.config/scripts/README.md b/.config/scripts/README.md index 9e44c59..cd07901 100644 --- a/.config/scripts/README.md +++ b/.config/scripts/README.md @@ -28,9 +28,20 @@ Approved PowerShell Verb-Noun. The verb encodes the role: | ------------------------------- | ---------------------------------- | ------------------------ | | `Initialize-DevEnvironment.ps1` | one-time local setup | manually, once per clone | | `Test-PowerShellScript.ps1` | PSScriptAnalyzer over passed files | commit + CI | +| ↳ custom rules | `../PSScriptAnalyzerRules/*.psm1` | via the hook above | | `Test-AsciiDoc.ps1` | asciidoctor syntax validation | commit + CI | | `Invoke-TestLane.ps1` | Pester by `-Lane` | commit / push / CI | +## Custom PSScriptAnalyzer rules + +Project-specific PowerShell policies live in `../PSScriptAnalyzerRules/` as +`Measure-*` functions in `.psm1` modules. `Test-PowerShellScript.ps1` passes them +to `Invoke-ScriptAnalyzer -CustomRulePath` (absolute, so resolution is +cwd-independent) alongside the built-in rules. Shipped rule: +`Measure-RequireStrictMode` — flags any `.ps1`/`.psm1` that never calls +`Set-StrictMode` (data-only `.psd1` files are exempt). Add a rule by dropping a +new module beside it; wire extra modules into the hook's `-CustomRulePath`. + ## Test lanes Lanes are execution **cadences**, not test types. A test opts into a lane via a diff --git a/.config/scripts/Test-AsciiDoc.Tests.ps1 b/.config/scripts/Test-AsciiDoc.Tests.ps1 index 96e9f54..647a457 100644 --- a/.config/scripts/Test-AsciiDoc.Tests.ps1 +++ b/.config/scripts/Test-AsciiDoc.Tests.ps1 @@ -1,4 +1,6 @@ #Requires -Version 7.0 +Set-StrictMode -Version Latest + # Capability flags must be set at DISCOVERY time (-Skip is evaluated then). $HasAsciidoctor = [bool](Get-Command asciidoctor -ErrorAction SilentlyContinue) diff --git a/.config/scripts/Test-AsciiDoc.ps1 b/.config/scripts/Test-AsciiDoc.ps1 index 3210b8a..d61c00b 100644 --- a/.config/scripts/Test-AsciiDoc.ps1 +++ b/.config/scripts/Test-AsciiDoc.ps1 @@ -19,6 +19,7 @@ param( [string[]]$Path ) +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $Path -or $Path.Count -eq 0) { diff --git a/.config/scripts/Test-Jsonc.Tests.ps1 b/.config/scripts/Test-Jsonc.Tests.ps1 index 29aa297..24f36cf 100644 --- a/.config/scripts/Test-Jsonc.Tests.ps1 +++ b/.config/scripts/Test-Jsonc.Tests.ps1 @@ -1,4 +1,6 @@ #Requires -Version 7.0 +Set-StrictMode -Version Latest + BeforeAll { $script:Script = Join-Path $PSScriptRoot 'Test-Jsonc.ps1' . (Join-Path $PSScriptRoot '_TestHelpers.ps1') diff --git a/.config/scripts/Test-Jsonc.ps1 b/.config/scripts/Test-Jsonc.ps1 index 923b474..1d8fb6d 100644 --- a/.config/scripts/Test-Jsonc.ps1 +++ b/.config/scripts/Test-Jsonc.ps1 @@ -15,6 +15,7 @@ param( [string[]]$Path ) +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $Path -or $Path.Count -eq 0) { Write-Verbose 'No JSONC files.'; exit 0 } diff --git a/.config/scripts/Test-PowerShellScript.Tests.ps1 b/.config/scripts/Test-PowerShellScript.Tests.ps1 index a93f0c1..94d3496 100644 --- a/.config/scripts/Test-PowerShellScript.Tests.ps1 +++ b/.config/scripts/Test-PowerShellScript.Tests.ps1 @@ -1,4 +1,6 @@ #Requires -Version 7.0 +Set-StrictMode -Version Latest + # Capability flags must be set at DISCOVERY time (-Skip is evaluated then). $HasPSSA = [bool](Get-Module -ListAvailable -Name PSScriptAnalyzer) diff --git a/.config/scripts/Test-PowerShellScript.ps1 b/.config/scripts/Test-PowerShellScript.ps1 index b7c6aa4..4e721fd 100644 --- a/.config/scripts/Test-PowerShellScript.ps1 +++ b/.config/scripts/Test-PowerShellScript.ps1 @@ -18,6 +18,7 @@ param( [string[]]$Path ) +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $Path -or $Path.Count -eq 0) { @@ -39,9 +40,15 @@ Import-Module PSScriptAnalyzer -ErrorAction Stop $settingsPath = Join-Path $PSScriptRoot '..' 'PSScriptAnalyzerSettings.psd1' | Resolve-Path | Select-Object -ExpandProperty Path +# Custom rules live beside the settings. Pass the module explicitly (absolute) so +# resolution is independent of the caller's working directory; -Settings still +# governs severity and IncludeDefaultRules. +$customRulePath = Join-Path $PSScriptRoot '..' 'PSScriptAnalyzerRules' 'Measure-RequireStrictMode.psm1' | + Resolve-Path | Select-Object -ExpandProperty Path + $findings = foreach ($file in $Path) { if (-not (Test-Path -LiteralPath $file)) { continue } - Invoke-ScriptAnalyzer -Path $file -Settings $settingsPath + Invoke-ScriptAnalyzer -Path $file -Settings $settingsPath -CustomRulePath $customRulePath } if ($findings) { diff --git a/.config/scripts/_TestHelpers.ps1 b/.config/scripts/_TestHelpers.ps1 index f5ce1f4..1eb6b6f 100644 --- a/.config/scripts/_TestHelpers.ps1 +++ b/.config/scripts/_TestHelpers.ps1 @@ -2,6 +2,8 @@ # Shared helpers for the .config/scripts Pester tests. Dot-sourced from each # *.Tests.ps1 BeforeAll; not a test file itself. +Set-StrictMode -Version Latest + function Invoke-ScriptFile { <# .SYNOPSIS diff --git a/.config/semgrep/README.md b/.config/semgrep/README.md new file mode 100644 index 0000000..4796b85 --- /dev/null +++ b/.config/semgrep/README.md @@ -0,0 +1,34 @@ +# Semgrep policy rules + +Project-specific policy checks — patterns a code generator repeatedly gets +wrong, or house style the standard linters cannot express. These rule files are +tracked, real, and verified — but **nothing runs them by default**. Wiring +semgrep into pre-commit is opt-in: see +[`.config/overlays/semgrep/README.md`](../overlays/semgrep/README.md) to +activate. + +Scope boundaries: PowerShell parses but its rules are **Pro-gated** (free-tier +CLI silently skips them — see +[`../overlays/semgrep-pro/README.md`](../overlays/semgrep-pro/README.md) +before assuming a `languages: [powershell]` rule here does anything). SQL has +no semgrep support at any tier. pwsh policies live in +[`../PSScriptAnalyzerRules/`](../PSScriptAnalyzerRules/) instead — that +mechanism is active in the base template today. Secrets are gitleaks; workflow +security is zizmor. + +## Rules shipped here + +- [`rules/python-no-silently-swallowed-exception.yaml`](rules/python-no-silently-swallowed-exception.yaml) — + Python, empty-except swallowing. +- [`rules/bash-no-blanket-strict-mode.yaml`](rules/bash-no-blanket-strict-mode.yaml) — + bash, flags blanket `set -e`/`set -euo pipefail` (shellcheck, the base-template + bash linter, has no opinion on this — it's a style choice, not a mechanic). + +Both are verified: run against a violating and a clean sample file through the +real hook before being added. Test a rule the same way: + +```pwsh +semgrep scan --config .config/semgrep/rules --metrics=off +``` + +Syntax reference: . diff --git a/.config/semgrep/rules/bash-no-blanket-strict-mode.yaml b/.config/semgrep/rules/bash-no-blanket-strict-mode.yaml new file mode 100644 index 0000000..1f41d46 --- /dev/null +++ b/.config/semgrep/rules/bash-no-blanket-strict-mode.yaml @@ -0,0 +1,17 @@ +# Bash: shellcheck (already a pre-commit hook) covers most bash mechanics — quoting, +# `read` safety, `$()` over backticks, array iteration. This rule covers a +# STYLE choice shellcheck has no opinion on. +rules: + - id: no-blanket-strict-mode + languages: [bash] + severity: WARNING # weight only — every finding blocks (hook runs with --error) + message: > + Blanket `set -e`/`set -euo pipefail` masks which command failed and + breaks legitimate non-zero control flow (e.g. `grep` finding nothing). + Prefer explicit failure handling: capture exit codes, branch expected + non-zero paths, check PIPESTATUS after pipelines. + pattern-either: + - pattern: set -e + - pattern: set -eu + - pattern: set -euo pipefail + - pattern: set -o errexit diff --git a/.config/semgrep/rules/python-no-silently-swallowed-exception.yaml b/.config/semgrep/rules/python-no-silently-swallowed-exception.yaml new file mode 100644 index 0000000..a5f698f --- /dev/null +++ b/.config/semgrep/rules/python-no-silently-swallowed-exception.yaml @@ -0,0 +1,14 @@ +# Python: an except/catch that silently swallows the error. A code generator +# reaches for this to make a red test pass; it hides real failures. +rules: + - id: no-silently-swallowed-exception + languages: [python] + severity: WARNING # weight only — every finding blocks (hook runs with --error) + message: > + Empty except swallows the error. Handle it, log it, or re-raise with + context — never `pass`. + pattern: | + try: + ... + except ...: + pass diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..db6356b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Control surfaces only — no `*` catch-all, so unlisted paths need no owner review. +# Requests review only until "require code owner review" is on the branch ruleset. + +/.github/ @DenWin +/.pre-commit-config.yaml @DenWin +/.config/ @DenWin +/setup/ @DenWin +/AGENTS.md @DenWin +/CLAUDE.md @DenWin diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..bc14f9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug report +description: Something behaves incorrectly. +labels: [bug] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What went wrong, in one or two sentences. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Exact commands / actions. Include the smallest case that shows it. + placeholder: | + 1. … + 2. … + validations: + required: true + - type: textarea + id: expected-actual + attributes: + label: Expected vs actual + description: What you expected, and what happened instead (error text, exit code). + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: OS, PowerShell version (`$PSVersionTable.PSVersion`), relevant tool versions. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..649bd7a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,3 @@ +# Issue chooser config. blank_issues_enabled:false forces contributors (and AI +# maintainers via `gh issue create --template`) through a structured form. +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..9d2df48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Propose a change or addition. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: The need or gap this addresses. Lead with the problem, not the solution. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed change + description: What you want to happen. Note alternatives you considered. + validations: + required: true + - type: textarea + id: scope + attributes: + label: Scope & risk + description: Which areas change, and any backward-compatibility or security impact. + validations: + required: false diff --git a/.github/README.md b/.github/README.md index 707da64..e64deec 100644 --- a/.github/README.md +++ b/.github/README.md @@ -17,21 +17,31 @@ There is **no bespoke CI lint logic**. The workflow runs the exact same File-scoped linters run only over changed files on a PR; the test hooks are `always_run`, so the fast lane runs in full regardless. -### Enabling the merge gate (once per repo) +Third-party actions are **pinned by full commit SHA** (with a `# vN` comment for +readability) — a mutable tag can be re-pointed at malicious code, a SHA cannot. +Dependabot updates the pins and refreshes the comment. The `zizmor` pre-commit +hook enforces this and other workflow-security rules. -For the full set to gate a merge (not just run after), the default branch needs -a **merge queue** ruleset that requires the `lint` check. The `merge_group` event -then validates the queued commit **before** it reaches `main`; without it, -`push: main` still runs the full set, but only *after* the merge has landed. +The `lint` job's first step is a **full-history secret scan** (`gitleaks-action`). +This is the one CI check that is *not* a local pre-commit hook, by design: the +pre-commit gitleaks hook scans only staged changes (a no-op in CI) and local +hooks are bypassable, so this server-side scan is the unbypassable backstop over +the whole history / PR range. It runs the same gitleaks engine, so no lint logic +is duplicated. Org-owned repos need a free `GITLEAKS_LICENSE` secret; user-owned +repos do not. -Do this **after** the first push and one CI run (so the `lint` check exists): +### Protecting the default branch (once per repo) + +The `merge_group` gate only fires if the branch has a **merge-queue ruleset**. +That ruleset — plus the PR requirement, the required `lint` check, and +force-push/deletion blocks — is applied by the one-time setup tooling: ```pwsh -pwsh -NoProfile -File .config/scripts/Enable-MergeQueue.ps1 +pwsh -NoProfile -File setup/Protect-MainBranch.ps1 ``` -The script creates the ruleset via `gh`. Equivalent manual path: *Settings → -Rules → Rulesets → New → require merge queue + require the `lint` status check*. +Run it **after** the first push and one CI run (so the `lint` check exists), +then delete the `setup/` folder. See [`setup/README.md`](../setup/README.md). ### What CI installs @@ -42,9 +52,25 @@ self-install: - PowerShell modules: `PSScriptAnalyzer`, `Pester`. - Ruby gem: `asciidoctor` (backs `Test-AsciiDoc.ps1`). +## `CODEOWNERS` + +Auto-requests review from the listed owners when matching paths change; the +sensitive control surfaces (`.github/`, `.pre-commit-config.yaml`, `.config/`, +`AGENTS.md`, `setup/`) are called out explicitly. It only *requests* review +until "require code owner review" is turned on in the branch ruleset — left off +for solo repos, since you cannot approve your own PR. + +## `pull_request_template.md` + +Pre-fills every PR with Goal / Scope / Risk & rollback / **Evidence**. Evidence +is mandatory: a PR must carry proof it was validated (CI run, `pre-commit run +--all-files`, tests). Especially load-bearing for AI-authored changes. + ## `dependabot.yml` A scheduled, GitHub-hosted service (not a workflow, not push-driven). Its scope is deliberately narrow — the only ecosystem in this template it can maintain is **GitHub Actions versions** (`github-actions`, monthly). pre-commit hook pins are updated by `pre-commit autoupdate`; PowerShell/npm deps are handled elsewhere. +A 7-day `cooldown` delays adopting a just-published version, leaving a window for +a compromised or yanked release to be caught upstream. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 10c6ae5..341a004 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,10 @@ updates: directory: / schedule: interval: monthly + # Wait out a freshly-published action version before adopting it — a window + # for a compromised/yanked release to be caught upstream (supply-chain). + cooldown: + default-days: 7 commit-message: prefix: ci # Bundle all action bumps into a single monthly PR. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e7d7cf2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ + + +## Goal + + + +## Scope + + + +## Risk & rollback + + + +## Evidence + + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8df0ff2..a3ca703 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,12 +29,26 @@ jobs: name: lint # stable status-check name (used by the merge-queue ruleset) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: # Full history so diff-range, gitleaks and actionlint see everything. fetch-depth: 0 + persist-credentials: false # CI only reads; never pushes with the token - - uses: actions/setup-python@v6 + # Server-side secret backstop, run first so a leak fails the gate fast. + # This is the ONE CI check that is not a local pre-commit hook, by design: + # the pre-commit gitleaks hook scans only STAGED changes (a no-op on a + # clean CI checkout) and local hooks are bypassable (--no-verify, web + # edits, an unconfigured clone). This scans the full history / PR range on + # every push and PR — server context a local hook cannot provide. It runs + # the same gitleaks engine, so there is no bespoke lint logic to drift. + - name: gitleaks (full-history secret scan) + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Org accounts need a free GITLEAKS_LICENSE here; user-owned repos do not. + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.x" @@ -53,7 +67,7 @@ jobs: - name: Install asciidoctor run: sudo gem install asciidoctor --no-document - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/pre-commit key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} @@ -86,8 +100,10 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false # CI only reads; never pushes with the token + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.x" - name: codespell diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 063e4b3..5beedd0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,6 +59,13 @@ repos: hooks: - id: actionlint + # actionlint checks workflow *correctness*; zizmor checks workflow *security* + # (template injection, credential leakage, unpinned/mutable action refs). + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.27.0 + hooks: + - id: zizmor + - repo: https://github.com/gitleaks/gitleaks rev: v8.30.0 hooks: diff --git a/AGENTS.md b/AGENTS.md index 7d1a23d..9bf28c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,14 +16,16 @@ PRs, full at the merge gate. ## Mechanism map -| Mechanism | Where | Doc | -| ------------------ | -------------------------------- | -------------------------------------------------------------------- | -| Orchestration | `.pre-commit-config.yaml` (root) | this file + inline comments | -| Configs | `.config/` | [`.config/README.md`](.config/README.md) | -| Linting & testing | `.config/scripts/` | [`.config/scripts/README.md`](.config/scripts/README.md) | -| CI & automation | `.github/` | [`.github/README.md`](.github/README.md) | -| Editor integration | `.vscode/` | [`.vscode/README.md`](.vscode/README.md) | -| Opt-in tooling | `.config/overlays/` | [`.config/overlays/vale/README.md`](.config/overlays/vale/README.md) | +| Mechanism | Where | Doc | +| ------------------- | -------------------------------- | -------------------------------------------------------------------- | +| Orchestration | `.pre-commit-config.yaml` (root) | this file + inline comments | +| Configs | `.config/` | [`.config/README.md`](.config/README.md) | +| Linting & testing | `.config/scripts/` | [`.config/scripts/README.md`](.config/scripts/README.md) | +| CI & automation | `.github/` | [`.github/README.md`](.github/README.md) | +| Editor integration | `.vscode/` | [`.vscode/README.md`](.vscode/README.md) | +| Policy rules | `.config/PSScriptAnalyzerRules/` | [`.config/scripts/README.md`](.config/scripts/README.md) | +| Opt-in tooling | `.config/overlays/` | [`.config/overlays/vale/README.md`](.config/overlays/vale/README.md) | +| One-time repo setup | `setup/` (delete after use) | [`setup/README.md`](setup/README.md) | Root convention files are declarative and self-documenting: `.editorconfig` (style), `.gitattributes` (eol=lf), `.gitignore`, `.claudeignore`. @@ -34,8 +36,8 @@ Root convention files are declarative and self-documenting: `.editorconfig` root). Key principles encoded there: - **Managed vs local hooks.** Off-the-shelf linters (yamllint, markdownlint, - shellcheck, shfmt, actionlint, gitleaks) are managed hooks. PowerShell/AsciiDoc/ - tests are `repo: local` hooks → `.config/scripts/*.ps1`. + shellcheck, shfmt, actionlint, zizmor, gitleaks) are managed hooks. + PowerShell/AsciiDoc/tests are `repo: local` hooks → `.config/scripts/*.ps1`. - **Stages = cadence.** `pre-commit` (commit), `pre-push` (push), `manual` (CI/merge). Test lanes map to these; standard/thorough ship commented-in-place. - **Local hooks fail fast**, never self-install. Bootstrap is explicit @@ -80,3 +82,13 @@ hand-rolled `.githooks/` hook) up to this architecture: Anti-pattern this replaces: local hook logic and CI workflows re-implementing "the same" checks separately, which drift apart. One orchestrator, one source. + +## Opening a pull request + +`.github/pull_request_template.md` is the PR contract — `gh pr create` and the +web UI both pre-fill it from this repo. Fill every section; **Evidence is +mandatory** (how the change was validated: CI run, `pre-commit run --all-files`, +tests). CI enforces the machine half (a green required `lint` check gates the +merge); this file enforces the narrative half. The template lives *in the repo* +(not only an org-level `.github` fallback) precisely so the CLI — and any agent +driving it — applies it automatically. diff --git a/setup/AI-Maintainer-Identity.adoc b/setup/AI-Maintainer-Identity.adoc new file mode 100644 index 0000000..28f9dcc --- /dev/null +++ b/setup/AI-Maintainer-Identity.adoc @@ -0,0 +1,155 @@ += AI Maintainer Identity — setup knowledge base +:toc: macro +:toclevels: 3 +:icons: font + +One-time reference for giving each AI maintainer (Codex, Claude, Copilot, …) its +*own least-privilege identity*, so agent actions are attributable and an agent +*cannot* weaken the repo's protection. Read once, set up once; this file can be +deleted with the rest of `setup/` afterwards (keep a copy if you want the +reference). + +toc::[] + +== Why + +When an agent runs under *your* authenticated `gh`, it inherits *your* +privileges — including admin. Two problems follow: + +* *No attribution.* The audit log cannot tell an agent's commit, branch, or API + call apart from yours. +* *No containment.* With admin, an agent can disable the branch ruleset, delete + protected branches, or rewrite history — with a single command, by accident or + through prompt injection. + +The fix is not a cleverer ruleset. It is *identity + least privilege*: + +. Each agent acts under its own identity with *write, never admin*. +. Your admin identity becomes *break-glass only* — used deliberately, rarely, + and never handed to an agent. + +With that in place, the branch ruleset (`Protect-MainBranch.ps1`) is enforced +*against the agents*, and `CODEOWNERS` can require your review on the control +surfaces while routine agent PRs flow through CI + auto-merge. + +== Choose a mechanism + +[cols="1,3,3",options="header"] +|=== +| | GitHub App (recommended) | Fine-grained PAT + +| What it is +| An installable app whose installation token acts as a bot identity + (`your-app[bot]`). +| A scoped personal access token owned by you or a dedicated bot account. + +| Attribution +| Commits/PRs show the app's bot identity — clearly non-human. +| Commits show the token owner. Use a *separate bot account* for a clean identity. + +| Privilege +| Per-permission (Contents: RW, Pull requests: RW, Administration: none). +| Per-permission, but *repository-scoped*; still no admin. + +| Revocation / rotation +| Uninstall or roll the key; short-lived installation tokens (1 h). +| Revoke/rotate the token; you manage expiry. + +| Setup cost +| Higher (create app, install, mint installation tokens). +| Lower (one token), but weaker identity separation. + +| Best when +| You want durable, auditable, revocable automation identities. +| You want the quickest path and accept token management. +|=== + +Recommendation: *GitHub App* for durable agents; a *fine-grained PAT on a bot +account* if you want to move fast today. Never use a classic (non-fine-grained) +PAT, and never grant `Administration`. + +== Option A — GitHub App + +=== Create + +. *Settings → Developer settings → GitHub Apps → New GitHub App.* +. Name it per agent or one shared maintainer app (e.g. `denwin-ai-maintainer`). +. *Repository permissions* — grant only: ++ +[cols="1,1",options="header"] +|=== +| Permission | Access +| Contents | Read and write +| Pull requests | Read and write +| Metadata | Read-only (mandatory) +|=== ++ +Leave *Administration*, *Workflows*, *Secrets*, and everything else at *No +access*. Granting `Workflows` would let an agent edit `.github/workflows/` — +keep that behind `CODEOWNERS` review instead. +. Uncheck *Webhook → Active* (not needed for a maintainer bot). +. Create, then *generate a private key* and store it in your secret manager. + +=== Install + +. *Install App* → select this repository only (not "All repositories"). +. Note the *App ID* and the *Installation ID*. + +=== Use from an agent + +An App authenticates as itself (JWT), then exchanges that for a short-lived +*installation access token*. The simplest path is the `gh` token helper or the +`actions/create-github-app-token` action in CI. Locally: + +[source,bash] +---- +# Mint an installation token (expires in ~1 hour), then point git/gh at it. +# Use a helper such as `gh-token` or a small script signing a JWT with the +# app private key; store the result in GH_TOKEN for the agent's shell only. +export GH_TOKEN="" +git -c user.name="denwin-ai-maintainer[bot]" \ + -c user.email="+denwin-ai-maintainer[bot]@users.noreply.github.com" \ + commit -m "…" +---- + +Give the agent a shell where `GH_TOKEN` is the installation token — separate from +your personal `gh auth login` session. + +== Option B — Fine-grained PAT + +. Create (or reuse) a dedicated *bot GitHub account*; add it as a repository + *collaborator with Write* (not Admin). +. As that account: *Settings → Developer settings → Fine-grained tokens → + Generate new token.* +. *Resource owner* = you; *Repository access* = only this repo. +. *Permissions* — Contents: Read and write; Pull requests: Read and write; + Metadata: Read-only. Nothing else. No Administration. +. Set a short expiry and calendar a rotation. +. Configure the agent's git/`gh` to use that token and the bot account's + `user.name`/`user.email`. + +== Harden further (optional) + +* *Require signed commits* — add the `required_signatures` rule to the branch + ruleset so only verified identities' commits are accepted. GitHub Apps sign + automatically; a bot account needs a GPG/SSH signing key. +* *Restrict who can push to `main`* — with a PR-only ruleset and no bypass + actors, no identity (including yours) pushes directly; break-glass is a + deliberate, logged admin action. +* *Scope secrets* — never expose repo/org secrets to agent-triggered workflows + you would not trust the agent to read. + +== Interim posture (until identities exist) + +Until the above is in place, an agent in your `gh` session has your privileges. +Mitigate by convention: + +* Run admin/ruleset operations (`Protect-MainBranch.ps1`, `Enable-RepoSecurity.ps1`) + *yourself*, not via an agent. +* Keep agents to branch/commit/PR work; do not ask them to change protection. + +== See also + +* `setup/Protect-MainBranch.ps1` — the branch ruleset the identities are enforced against. +* `setup/Enable-RepoSecurity.ps1` — server-side security toggles. +* `.github/CODEOWNERS` — the control surfaces that require owner review. diff --git a/setup/Enable-RepoSecurity.Tests.ps1 b/setup/Enable-RepoSecurity.Tests.ps1 new file mode 100644 index 0000000..bf8ded0 --- /dev/null +++ b/setup/Enable-RepoSecurity.Tests.ps1 @@ -0,0 +1,65 @@ +#Requires -Version 7.0 +Set-StrictMode -Version Latest + +BeforeAll { + # Dot-source the SUT: defines its functions; the run guard skips execution. + . (Join-Path $PSScriptRoot 'Enable-RepoSecurity.ps1') +} + +Describe 'Get-SecuritySetting' -Tag 'Fast' { + BeforeAll { + $script:S = Get-SecuritySetting -Repo 'owner/repo' -IncludeWorkflowToken + } + + It 'enables secret scanning and push protection' { + $ss = $script:S | Where-Object Name -Match 'secret scanning' + $ss.Body.security_and_analysis.secret_scanning.status | Should -Be 'enabled' + $ss.Body.security_and_analysis.secret_scanning_push_protection.status | Should -Be 'enabled' + } + + It 'flags secret scanning as public-only (GHAS-gated on private)' { + $ss = $script:S | Where-Object Name -Match 'secret scanning' + $ss.PublicOnly | Should -BeTrue + } + + It 'targets the given repo in every path' { + $script:S.Path | Should -Not -BeNullOrEmpty + ($script:S.Path | Where-Object { $_ -notmatch 'owner/repo' }) | Should -BeNullOrEmpty + } + + It 'restricts Actions to an allowlist and requires SHA pinning' { + $ap = $script:S | Where-Object Name -Match 'Actions policy' + $ap.Body.allowed_actions | Should -Be 'selected' + $ap.Body.sha_pinning_required | Should -BeTrue + } + + It 'allowlists GitHub-owned, verified, and the pinned third-party actions' { + $al = $script:S | Where-Object Name -Match 'Actions allowlist' + $al.Body.github_owned_allowed | Should -BeTrue + $al.Body.verified_allowed | Should -BeTrue + $al.Body.patterns_allowed | Should -Contain 'gitleaks/gitleaks-action@*' + } + + It 'orders the Actions policy before its allowlist (selected-actions 409s otherwise)' { + $names = @($script:S.Name) + $names.IndexOf(($names -match 'Actions policy')[0]) | + Should -BeLessThan $names.IndexOf(($names -match 'Actions allowlist')[0]) + } + + It 'sets the workflow token read-only when requested' { + $wt = $script:S | Where-Object Name -Match 'workflow token' + $wt.Body.default_workflow_permissions | Should -Be 'read' + } + + It 'omits the workflow-token change when not requested' { + $noToken = Get-SecuritySetting -Repo 'owner/repo' + ($noToken | Where-Object Name -Match 'workflow token') | Should -BeNullOrEmpty + } +} + +Describe 'Invoke-RepoSecurity' -Tag 'Fast' { + It 'returns non-zero when gh is not installed' { + Mock Test-GhCli { $false } + Invoke-RepoSecurity 2>$null | Should -Be 1 + } +} diff --git a/setup/Enable-RepoSecurity.ps1 b/setup/Enable-RepoSecurity.ps1 new file mode 100644 index 0000000..0558fb2 --- /dev/null +++ b/setup/Enable-RepoSecurity.ps1 @@ -0,0 +1,142 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Turn on GitHub's server-side security settings that live in the API, not in + repo files (secret scanning, Dependabot alerts/fixes, private vuln + reporting, read-only default workflow token). + +.DESCRIPTION + Run once on a new repo made from this template, then delete the setup/ + folder. Requires the GitHub CLI, authenticated with admin on the repo. + Every call is idempotent — re-running is safe. Individual settings that fail + (plan limits, already-set, insufficient scope) warn and do not abort the + rest; the exit code is non-zero if any failed. + + Complements setup/Protect-MainBranch.ps1 (branch ruleset) and the in-repo + files (CODEOWNERS, workflows). See setup/README.md. + +.PARAMETER SkipWorkflowTokenReadOnly + Leave the default GITHUB_TOKEN permission untouched. Set this if any workflow + relies on the legacy read/write default instead of per-job `permissions:`. +#> +[CmdletBinding()] +param( + [switch]$SkipWorkflowTokenReadOnly +) + +Set-StrictMode -Version Latest + +function Test-GhCli { [bool](Get-Command gh -ErrorAction SilentlyContinue) } + +function Get-SecuritySetting { + <# The settings to apply, as ordered {Name, Method, Path, Body} records. + Pure: no side effects, so it is unit-testable. #> + param([Parameter(Mandatory)][string]$Repo, [switch]$IncludeWorkflowToken) + + $settings = [System.Collections.Generic.List[hashtable]]::new() + + # Native secret scanning + push protection (free on public repos; needs GHAS + # on private). Push protection blocks the push at the server on a match. + $settings.Add(@{ + Name = 'secret scanning + push protection' + Method = 'PATCH' + Path = "repos/$Repo" + # Free on public repos; on private/internal it requires GitHub + # Advanced Security. Skipped with a clear message otherwise. + PublicOnly = $true + Body = @{ security_and_analysis = @{ + secret_scanning = @{ status = 'enabled' } + secret_scanning_push_protection = @{ status = 'enabled' } + } } + }) + # Dependabot vulnerability alerts. + $settings.Add(@{ Name = 'Dependabot alerts'; Method = 'PUT'; Path = "repos/$Repo/vulnerability-alerts"; Body = $null }) + # Dependabot automated security-fix PRs. + $settings.Add(@{ Name = 'Dependabot security updates'; Method = 'PUT'; Path = "repos/$Repo/automated-security-fixes"; Body = $null }) + # Private vulnerability reporting (pairs with a SECURITY.md reporting policy). + $settings.Add(@{ Name = 'private vulnerability reporting'; Method = 'PUT'; Path = "repos/$Repo/private-vulnerability-reporting"; Body = $null }) + # Actions policy: only allowlisted actions may run, and every `uses:` must be + # pinned to a full-length commit SHA (zizmor lints this; the platform enforces it). + $settings.Add(@{ + Name = 'Actions policy (selected actions + SHA pinning required)' + Method = 'PUT' + Path = "repos/$Repo/actions/permissions" + Body = @{ enabled = $true; allowed_actions = 'selected'; sha_pinning_required = $true } + }) + # Allowlist: GitHub-owned + Marketplace-verified creators, plus the explicit + # third-party actions this template uses (robust even if unverified). + $settings.Add(@{ + Name = 'Actions allowlist (GitHub-owned + verified + pinned third-party)' + Method = 'PUT' + Path = "repos/$Repo/actions/permissions/selected-actions" + Body = @{ + github_owned_allowed = $true + verified_allowed = $true + patterns_allowed = @('gitleaks/gitleaks-action@*') + } + }) + + if ($IncludeWorkflowToken) { + # Default GITHUB_TOKEN to read-only; workflows opt into writes per-job. + $settings.Add(@{ + Name = 'read-only default workflow token' + Method = 'PUT' + Path = "repos/$Repo/actions/permissions/workflow" + Body = @{ default_workflow_permissions = 'read'; can_approve_pull_request_reviews = $false } + }) + } + $settings +} + +function Invoke-RepoSecurity { + param([switch]$IncludeWorkflowToken) + + # Return codes are the contract; keep Write-Error non-terminating. + $ErrorActionPreference = 'Continue' + + if (-not (Test-GhCli)) { + Write-Error 'GitHub CLI (gh) is not installed. https://cli.github.com/' + return 1 + } + $repoInfo = gh repo view --json nameWithOwner, visibility 2>$null | ConvertFrom-Json + if (-not $repoInfo.nameWithOwner) { + Write-Error 'Not a GitHub repository, or gh is not authenticated (`gh auth login`).' + return 1 + } + $repo = $repoInfo.nameWithOwner + $isPublic = $repoInfo.visibility -eq 'PUBLIC' + + $failed = 0 + foreach ($s in (Get-SecuritySetting -Repo $repo -IncludeWorkflowToken:$IncludeWorkflowToken)) { + if ($s.ContainsKey('PublicOnly') -and $s.PublicOnly -and -not $isPublic) { + # A valid not-applicable result, not a failure. + Write-Warning "Skipped (needs a public repo or GitHub Advanced Security): $($s.Name)." + continue + } + if ($null -ne $s.Body) { + ($s.Body | ConvertTo-Json -Depth 8) | gh api --method $s.Method $s.Path --input - 2>$null | Out-Null + } + else { + gh api --method $s.Method $s.Path 2>$null | Out-Null + } + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not enable: $($s.Name) (already set, plan-restricted, or missing scope)." + $failed++ + } + else { + Write-Information "Enabled: $($s.Name)." -InformationAction Continue + } + } + + if ($failed -gt 0) { + Write-Warning "$failed setting(s) not applied on $repo. Review the warnings above." + return 1 + } + Write-Information "Repository security settings applied on $repo." -InformationAction Continue + return 0 +} + +# Execute only when run directly, not when dot-sourced by a test. +if ($MyInvocation.InvocationName -ne '.') { + exit (Invoke-RepoSecurity -IncludeWorkflowToken:(-not $SkipWorkflowTokenReadOnly)) +} diff --git a/setup/Protect-MainBranch.Tests.ps1 b/setup/Protect-MainBranch.Tests.ps1 new file mode 100644 index 0000000..a280e3d --- /dev/null +++ b/setup/Protect-MainBranch.Tests.ps1 @@ -0,0 +1,83 @@ +#Requires -Version 7.0 +Set-StrictMode -Version Latest + +BeforeAll { + # Dot-source the SUT: defines its functions; the run guard skips execution. + . (Join-Path $PSScriptRoot 'Protect-MainBranch.ps1') +} + +Describe 'Get-ProtectionRuleset' -Tag 'Fast' { + BeforeAll { + $script:R = Get-ProtectionRuleset -CheckName 'lint' -RequiredApprovals 0 -MergeMethod 'SQUASH' + } + + It 'targets the default branch' { + $script:R.conditions.ref_name.include | Should -Contain '~DEFAULT_BRANCH' + } + + It 'requires a pull request' { + ($script:R.rules | Where-Object type -EQ 'pull_request') | Should -Not -BeNullOrEmpty + } + + It 'requires the given status check' { + $rsc = ($script:R.rules | Where-Object type -EQ 'required_status_checks').parameters.required_status_checks + $rsc.context | Should -Contain 'lint' + } + + It 'blocks force-push and deletion' { + $script:R.rules.type | Should -Contain 'non_fast_forward' + $script:R.rules.type | Should -Contain 'deletion' + } + + It 'routes merges through a merge queue' { + $script:R.rules.type | Should -Contain 'merge_queue' + } + + It 'requires review threads to be resolved (holds auto-merge on open comments)' { + ($script:R.rules | Where-Object type -EQ 'pull_request').parameters.required_review_thread_resolution | + Should -BeTrue + } +} + +Describe 'Get-StrictLayerRuleset' -Tag 'Fast' { + BeforeAll { + $script:L = Get-StrictLayerRuleset + } + + It 'exempts the repository admin role' { + $script:L.bypass_actors.actor_type | Should -Contain 'RepositoryRole' + $script:L.bypass_actors.bypass_mode | Should -Contain 'exempt' + } + + It 'requires one code-owner approval given after the last push' { + $p = ($script:L.rules | Where-Object type -EQ 'pull_request').parameters + $p.required_approving_review_count | Should -Be 1 + $p.require_code_owner_review | Should -BeTrue + $p.require_last_push_approval | Should -BeTrue + $p.dismiss_stale_reviews_on_push | Should -BeTrue + } + + It 'targets the default branch under a distinct name' { + $script:L.conditions.ref_name.include | Should -Contain '~DEFAULT_BRANCH' + $script:L.name | Should -Not -Be 'main-protection' + } +} + +Describe 'Get-MergeFlowSetting' -Tag 'Fast' { + It 'enables auto-merge and branch cleanup, and touches nothing else' { + $s = Get-MergeFlowSetting + $s.allow_auto_merge | Should -BeTrue + $s.delete_branch_on_merge | Should -BeTrue + # No merge-method keys: a repo-wide disable would also bind bypass actors. + $s.Keys | Where-Object { $_ -match 'allow_.*_merge$' -and $_ -ne 'allow_auto_merge' } | + Should -BeNullOrEmpty + } +} + +Describe 'Invoke-BranchProtection' -Tag 'Fast' { + It 'returns non-zero when gh is not installed' { + Mock Test-GhCli { $false } + Invoke-BranchProtection -CheckName 'lint' -RequiredApprovals 0 -MergeMethod 'SQUASH' 2>$null | + Should -Be 1 + } +} diff --git a/setup/Protect-MainBranch.ps1 b/setup/Protect-MainBranch.ps1 new file mode 100644 index 0000000..55926e3 --- /dev/null +++ b/setup/Protect-MainBranch.ps1 @@ -0,0 +1,180 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Protect the default branch with a ruleset (PR + lint check + merge queue, + no force-push/deletion). + +.DESCRIPTION + Run once on a new repo made from this template, then delete the setup/ + folder. Requires the GitHub CLI, authenticated with admin on the repo. + +.PARAMETER CheckName + Required status-check context (must match the CI job name, lint.yml -> lint). + +.PARAMETER RequiredApprovals + PR approvals required before merge (0 suits a solo repo). + +.PARAMETER MergeMethod + How the merge queue merges entries. + +.PARAMETER WithStrictLayer + Also create a second, admin-exempt ruleset: 1 approval from a code owner, + given after the last push, stale approvals dismissed. Use once a second + identity (e.g. an AI-maintainer App/PAT) opens PRs — until then the layer + binds nobody. +#> +[CmdletBinding()] +param( + [string]$CheckName = 'lint', + [int]$RequiredApprovals = 0, + [ValidateSet('MERGE', 'SQUASH', 'REBASE')] + [string]$MergeMethod = 'SQUASH', + [switch]$WithStrictLayer +) + +Set-StrictMode -Version Latest + +function Test-GhCli { [bool](Get-Command gh -ErrorAction SilentlyContinue) } + +function Get-ProtectionRuleset { + <# Build the rulesets-API payload (pure; no side effects). #> + param( + [Parameter(Mandatory)][string]$CheckName, + [Parameter(Mandatory)][int]$RequiredApprovals, + [Parameter(Mandatory)][string]$MergeMethod + ) + @{ + name = 'main-protection' + target = 'branch' + enforcement = 'active' + conditions = @{ ref_name = @{ include = @('~DEFAULT_BRANCH'); exclude = @() } } + rules = @( + @{ type = 'deletion' } + @{ type = 'non_fast_forward' } + @{ + type = 'pull_request' + parameters = @{ + required_approving_review_count = $RequiredApprovals + dismiss_stale_reviews_on_push = $false + require_code_owner_review = $false + require_last_push_approval = $false + # Unresolved review threads block the merge — with auto-merge + # on, a review comment holds an agent PR until it is resolved. + required_review_thread_resolution = $true + } + } + @{ + type = 'required_status_checks' + parameters = @{ + required_status_checks = @(@{ context = $CheckName }) + strict_required_status_checks_policy = $false + } + } + @{ + type = 'merge_queue' + parameters = @{ + merge_method = $MergeMethod + grouping_strategy = 'ALLGREEN' + max_entries_to_build = 5 + min_entries_to_merge = 1 + max_entries_to_merge = 5 + min_entries_to_merge_wait_minutes = 5 + check_response_timeout_minutes = 60 + } + } + ) + } +} + +function Get-StrictLayerRuleset { + <# Build the second, admin-exempt ruleset (pure; no side effects). + Layered on top of main-protection: rulesets aggregate most-restrictive, + so everyone EXCEPT the exempt repo admin additionally needs one approval + from a code owner, given after the last push. Run only once a second + identity (e.g. an AI-maintainer App/PAT) exists — with no other identity, + this layer binds nobody. #> + @{ + name = 'main-protection-strict' + target = 'branch' + enforcement = 'active' + conditions = @{ ref_name = @{ include = @('~DEFAULT_BRANCH'); exclude = @() } } + # actor_id 5 = Repository admin role; 'exempt' removes these rules for it. + bypass_actors = @(@{ actor_id = 5; actor_type = 'RepositoryRole'; bypass_mode = 'exempt' }) + rules = @( + @{ + type = 'pull_request' + parameters = @{ + required_approving_review_count = 1 + require_code_owner_review = $true + require_last_push_approval = $true + dismiss_stale_reviews_on_push = $true + required_review_thread_resolution = $true + } + } + ) + } +} + +function Get-MergeFlowSetting { + <# Repo-level merge-flow settings (pure; no side effects). + All three merge methods stay enabled: the merge queue already enforces + $MergeMethod for the gated path, and a ruleset cannot re-enable a method + disabled repo-wide — disabling here would also bind bypass actors. #> + @{ + allow_auto_merge = $true # `gh pr merge --auto` queues on green checks + delete_branch_on_merge = $true + } +} + +function Invoke-BranchProtection { + param( + [Parameter(Mandatory)][string]$CheckName, + [Parameter(Mandatory)][int]$RequiredApprovals, + [Parameter(Mandatory)][string]$MergeMethod, + [switch]$WithStrictLayer + ) + + # Return codes are the contract; keep Write-Error non-terminating. + $ErrorActionPreference = 'Continue' + + if (-not (Test-GhCli)) { + Write-Error 'GitHub CLI (gh) is not installed. https://cli.github.com/' + return 1 + } + + $repo = gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null + if (-not $repo) { + Write-Error 'Not a GitHub repository, or gh is not authenticated (`gh auth login`).' + return 1 + } + + $json = Get-ProtectionRuleset -CheckName $CheckName -RequiredApprovals $RequiredApprovals -MergeMethod $MergeMethod | + ConvertTo-Json -Depth 8 + $json | gh api --method POST "repos/$repo/rulesets" --input - + if ($LASTEXITCODE -ne 0) { + Write-Error 'Failed to create ruleset (it may already exist, or you lack admin on the repo).' + return 1 + } + + if ($WithStrictLayer) { + (Get-StrictLayerRuleset | ConvertTo-Json -Depth 8) | gh api --method POST "repos/$repo/rulesets" --input - + if ($LASTEXITCODE -ne 0) { + Write-Error 'Base ruleset created, but the strict layer failed (it may already exist).' + return 1 + } + } + + (Get-MergeFlowSetting | ConvertTo-Json) | gh api --method PATCH "repos/$repo" --input - | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error 'Ruleset created, but setting auto-merge/branch-cleanup failed.' + return 1 + } + + Write-Information "Branch protection + merge flow active on $repo (default branch)." -InformationAction Continue + return 0 +} + +# Execute only when run directly, not when dot-sourced by a test. +if ($MyInvocation.InvocationName -ne '.') { + exit (Invoke-BranchProtection -CheckName $CheckName -RequiredApprovals $RequiredApprovals -MergeMethod $MergeMethod -WithStrictLayer:$WithStrictLayer) +} diff --git a/setup/README.md b/setup/README.md new file mode 100644 index 0000000..22fd276 --- /dev/null +++ b/setup/README.md @@ -0,0 +1,52 @@ +# One-time repo setup + +Tooling to run **once** on a new repo created from this template — then delete +this whole folder. + +## Protect the default branch + +Needs the GitHub CLI, authenticated with admin on the repo: + +```pwsh +pwsh -NoProfile -File setup/Protect-MainBranch.ps1 +``` + +Creates a `main-protection` ruleset on the default branch: require a PR, require +the `lint` check, require review threads resolved, route merges through a merge +queue, block force-pushes and branch deletion. Also enables auto-merge and +delete-branch-on-merge on the repo. + +`-WithStrictLayer` adds a second, admin-exempt ruleset (1 code-owner approval, +given after the last push). Run it only once a second identity — e.g. an +AI-maintainer App/PAT per [`AI-Maintainer-Identity.adoc`](AI-Maintainer-Identity.adoc) +— opens PRs; with no other identity it binds nobody. + +## Enable server-side security settings + +The security toggles that live in GitHub's API, not in repo files. Same +requirements (admin + `gh`); every call is idempotent, so re-running is safe: + +```pwsh +pwsh -NoProfile -File setup/Enable-RepoSecurity.ps1 +``` + +Turns on native secret scanning + push protection, Dependabot alerts and +automated security fixes, private vulnerability reporting, and a read-only +default workflow token. Pass `-SkipWorkflowTokenReadOnly` if a workflow depends +on the legacy read/write default. Settings that are plan-restricted or already +set warn and are skipped; the script still applies the rest. + +## Set up AI-maintainer identities + +[`AI-Maintainer-Identity.adoc`](AI-Maintainer-Identity.adoc) is the knowledge +base for giving each AI agent its own least-privilege identity (GitHub App or +fine-grained PAT) instead of running under the owner's admin account. + +## Remove this folder + +Once protection is on, this folder has served its purpose: + +```pwsh +git rm -r setup +git commit -m "Remove one-time setup tooling" +```