From 176f18107407bba48ed0f7c9c0ee3becaeed5029 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 09:13:55 -0500 Subject: [PATCH 1/3] feat(setup): one-command workstation setup with keys in 1Password - setup/bootstrap.ps1 (Windows) and setup/bootstrap.sh (macOS/Linux) install PowerShell 7 (and Homebrew) and run setup/Install-Workstation.ps1. - Install-Workstation.ps1: installs the core tools and optional language toolchains (winget/brew), configures git to sign commits and tags through 1Password's op-ssh-sign (Store and direct installs), points ssh at the 1Password agent, optionally registers the signing key on GitHub, and checks the security baseline. Idempotent; supports -WhatIf and -CheckOnly. - standards/workstation.md (K22-WS-*): tools, keys created and used only in 1Password, op run for secrets, machine baseline. - Security program names 1Password as the password manager and SSH agent and forbids private key files (K22-SEC-10, K22-SEC-14). - README: machine setup is now the one-liners plus options. Verified on Windows 11: -CheckOnly and -WhatIf runs; PSScriptAnalyzer (repo settings) 0 findings and Invoke-Formatter no changes; ShellCheck --enable=all and shfmt clean on bootstrap.sh. Co-Authored-By: Claude Opus 5.5 Signed-off-by: James Maes --- README.md | 83 ++-- policies/README.md | 1 + policies/security-program.md | 20 +- setup/Install-Workstation.ps1 | 705 ++++++++++++++++++++++++++++++++++ setup/bootstrap.ps1 | 47 +++ setup/bootstrap.sh | 45 +++ standards/workstation.md | 75 ++++ 7 files changed, 933 insertions(+), 43 deletions(-) create mode 100644 setup/Install-Workstation.ps1 create mode 100644 setup/bootstrap.ps1 create mode 100644 setup/bootstrap.sh create mode 100644 standards/workstation.md diff --git a/README.md b/README.md index 956351c..c40c89d 100644 --- a/README.md +++ b/README.md @@ -84,57 +84,70 @@ Every rule has a stable ID (`K22-SDLC-12`, `K22-CODE-03`, …), a level (**MUST* ## Set up your machine -You need git with commit signing, the GitHub CLI, PowerShell 7 (for the conformance -checker), and the four security tools the hooks and CI use. Language toolchains come -from the [language profiles](standards/coding/README.md#language-profiles). +One script sets up a workstation on Windows, macOS, or Linux, and checks it afterwards +([workstation standard](standards/workstation.md)). It installs the tools, configures +git to sign every commit with an SSH key **held in 1Password** (no key files on disk), +and checks disk encryption, firewall, malware protection, and stray private keys. It +is safe to run again at any time. -### Windows +**Before you run it:** install [1Password](https://1password.com/downloads/), turn on +*Settings → Developer → Use the SSH agent*, and create (or import) an SSH key item for +GitHub. Set your git name and a verified GitHub email: +`git config --global user.name "…"` and `git config --global user.email "…"`. + +### Windows (PowerShell) ```powershell -winget install --id Git.Git -e -winget install --id GitHub.cli -e -winget install --id Microsoft.PowerShell -e -winget install --id astral-sh.uv -e -winget install --id Gitleaks.Gitleaks -e -winget install --id AquaSecurity.Trivy -e -winget install --id rhysd.actionlint -e -uv tool install zizmor -uv tool install pre-commit +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1))) ``` -### macOS +### macOS and Linux (Terminal) ```bash -brew install git gh uv gitleaks trivy actionlint zizmor pre-commit -brew install --cask powershell +curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh | bash ``` -### Linux +The bootstrap installs PowerShell 7 (and Homebrew on macOS/Linux) if needed, then runs +[`setup/Install-Workstation.ps1`](setup/Install-Workstation.ps1). -Install `git`, `gh`, `gitleaks`, `trivy`, `actionlint`, and PowerShell from your -distribution or each project's releases, then `uv tool install zizmor pre-commit`. +### Options -### Configure git (once per machine) +Add them after the command (Windows), or after `bash -s --` (macOS/Linux): -Every commit on `main` must be signed ([`K22-SDLC-14`](policies/sdlc.md#build)) and -carry a DCO sign-off ([`K22-SDLC-13`](policies/sdlc.md#build)). +| Option | Effect | +| --- | --- | +| `-Languages dotnet,java,python,rust,go,swift,shell,terraform` | Also install those toolchains | +| `-SigningKey GitHub` | Sign with the 1Password SSH key whose name contains "GitHub" (otherwise it asks) | +| `-RegisterSigningKey` | Upload that key to GitHub as a *signing* key | +| `-CheckOnly` | Change nothing; report PASS / WARN / FAIL | +| `-WhatIf` | Show every change it would make | + +```powershell +# Windows: .NET and Java developer, register the signing key with GitHub +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1))) -Languages dotnet,java -SigningKey GitHub -RegisterSigningKey +``` ```bash -# Sign commits and tags with your SSH key -git config --global gpg.format ssh -git config --global user.signingkey ~/.ssh/id_ed25519.pub -git config --global commit.gpgsign true -git config --global tag.gpgsign true - -# Register the same key on GitHub as a *signing* key (separate from authentication) -gh ssh-key add ~/.ssh/id_ed25519.pub --type signing --title "commit signing" - -# Sign off every commit: use `git commit -s`, or this alias -git config --global alias.cs "commit -s" +# macOS: check an existing machine without changing anything +curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh | bash -s -- -CheckOnly ``` -The email in `user.email` must be a verified email on your GitHub account, or commits -will not show as *Verified*. +### Prefer to read it first? + +Download, read, then run. Replace `main` with a release tag for a pinned version. + +```powershell +irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/Install-Workstation.ps1 -OutFile Install-Workstation.ps1 +notepad Install-Workstation.ps1 +pwsh -File ./Install-Workstation.ps1 -WhatIf +``` + +### After it runs + +- Open a new terminal so newly installed tools are on your `PATH`. +- Sign off every commit with `git commit -s` (or the alias it adds: `git cs`). +- In each repository, enable the hooks once: + `pre-commit install --hook-type pre-commit --hook-type commit-msg`. ## Set up your IDE diff --git a/policies/README.md b/policies/README.md index 5b0c548..984f8b2 100644 --- a/policies/README.md +++ b/policies/README.md @@ -59,6 +59,7 @@ K22-- | `TEST` | [standards/testing.md](../standards/testing.md) | Test levels, coverage, test hygiene | | `DEP` | [standards/dependencies.md](../standards/dependencies.md) | Selecting, pinning, and updating dependencies | | `REL` | [standards/releases.md](../standards/releases.md) | Versioning, signing, provenance, SBOM, support | +| `WS` | [standards/workstation.md](../standards/workstation.md) | Developer machine setup, 1Password keys, machine baseline | | `CODE` | [standards/coding/](../standards/coding/README.md) | Cross-language coding rules | | `CODE-` | `standards/coding/.md` | One profile per language, e.g. `K22-CODE-CS-03` | diff --git a/policies/security-program.md b/policies/security-program.md index 43010ec..97079ff 100644 --- a/policies/security-program.md +++ b/policies/security-program.md @@ -54,8 +54,9 @@ not needed. The review is recorded as a closed issue in this repository. ## Secrets and credentials -**K22-SEC-10 (MUST)** Secrets are stored only in: a password manager, the operating -system's credential store, or GitHub encrypted secrets bound to a protected environment. +**K22-SEC-10 (MUST)** Secrets are stored only in: 1Password (KofTwentyTwo's password +manager), the operating system's credential store (for an application's own user +credentials), or GitHub encrypted secrets bound to a protected environment. They never appear in source, history, logs, CI output, issue or pull request text, command-line arguments, or prompts to AI tools. *Why:* each of those places is copied, cached, or published. *Verified by:* secret @@ -78,12 +79,15 @@ is revoked first, then replaced, then the service's access logs are checked for and the event is handled as an incident. *Why:* removing a secret from a file does not un-leak it. *Verified by:* incident record. -**K22-SEC-14 (MUST)** Signing keys are held by hardware or a managed signing service: -commit-signing keys in a hardware-backed or password-manager SSH agent, release signing -through a cloud HSM-backed service (Azure Trusted Signing for Windows, Apple Developer ID -for macOS). Private signing keys are never stored as files on disk or in CI secrets. -*Why:* a stolen signing key lets an attacker ship malware as you. *Verified by:* signing -configuration review. +**K22-SEC-14 (MUST)** Private keys never exist as files. SSH keys (authentication, +commit signing, deploy keys) are generated in 1Password and used through its SSH agent +([workstation standard](../standards/workstation.md#keys-and-secrets-live-in-1password)); +release signing goes through a cloud HSM-backed service (Azure Trusted Signing for +Windows, Apple Developer ID for macOS). Private keys are never stored on disk or in CI +secrets. +*Why:* a stolen signing key lets an attacker ship malware as you, and a key file can be +copied silently. *Verified by:* `setup/Install-Workstation.ps1 -CheckOnly` (private keys +on disk, git signing through 1Password); signing configuration review. ## Development environment diff --git a/setup/Install-Workstation.ps1 b/setup/Install-Workstation.ps1 new file mode 100644 index 0000000..abb6d7c --- /dev/null +++ b/setup/Install-Workstation.ps1 @@ -0,0 +1,705 @@ +#Requires -Version 7.2 +<# +.SYNOPSIS + Sets up (or checks) a KofTwentyTwo development workstation on Windows, macOS, or Linux. + +.DESCRIPTION + Implements the workstation standard (standards/workstation.md, K22-WS-*): + + 1. Installs the core tools every KofTwentyTwo repository needs (git, GitHub CLI, uv, + Node.js, gitleaks, Trivy, OSV-Scanner, actionlint, zizmor, pre-commit, 1Password + and its CLI), plus optional language toolchains. + 2. Configures git to sign every commit and tag with an SSH key held in 1Password and + served by the 1Password SSH agent, so no private key exists on disk (K22-SEC-14). + 3. Checks the machine against the security baseline (disk encryption, firewall, + malware protection, stray private keys) and reports PASS / WARN / FAIL. + + The script is idempotent: run it again at any time to repair or update a machine. + It never generates keys and never reads secrets; keys are created in 1Password. + Supports -WhatIf to show what would change. + +.PARAMETER Languages + Optional language toolchains to install: dotnet, java, python, rust, go, swift, shell, + terraform. + +.PARAMETER SigningKey + Part of the name of the 1Password SSH key to sign commits with, for example "GitHub". + When omitted and the agent holds several keys, the script asks. + +.PARAMETER RegisterSigningKey + Also upload the signing key's public half to GitHub as a signing key (gh CLI). + +.PARAMETER CheckOnly + Change nothing; only report the workstation's state. + +.PARAMETER SkipGitConfig + Install tools but leave the global git configuration alone. + +.EXAMPLE + ./Install-Workstation.ps1 -Languages dotnet,java -SigningKey GitHub -RegisterSigningKey + +.EXAMPLE + ./Install-Workstation.ps1 -CheckOnly +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive installer: colored console output is its user interface.')] +[CmdletBinding(SupportsShouldProcess)] +param( + [ValidateSet('dotnet', 'java', 'python', 'rust', 'go', 'swift', 'shell', 'terraform')] + [string[]] $Languages = @(), + + [string] $SigningKey, + + [switch] $RegisterSigningKey, + + [switch] $CheckOnly, + + [switch] $SkipGitConfig +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + + + +################################################################################# +# Tool catalog: what to install, how to detect it, and the package id per OS. # +# Winget ids are verified against the winget community source; Brew entries # +# marked Cask are macOS-only (Homebrew casks do not exist on Linux). # +################################################################################# +$script:Catalog = @( + @{ Group = 'core'; Name = 'git'; Command = 'git'; Winget = 'Git.Git'; Brew = 'git' } + @{ Group = 'core'; Name = 'GitHub CLI'; Command = 'gh'; Winget = 'GitHub.cli'; Brew = 'gh' } + @{ Group = 'core'; Name = 'uv'; Command = 'uv'; Winget = 'astral-sh.uv'; Brew = 'uv' } + @{ Group = 'core'; Name = 'Node.js LTS'; Command = 'node'; Winget = 'OpenJS.NodeJS.LTS'; Brew = 'node' } + @{ Group = 'core'; Name = 'gitleaks'; Command = 'gitleaks'; Winget = 'Gitleaks.Gitleaks'; Brew = 'gitleaks' } + @{ Group = 'core'; Name = 'Trivy'; Command = 'trivy'; Winget = 'AquaSecurity.Trivy'; Brew = 'trivy' } + @{ Group = 'core'; Name = 'OSV-Scanner'; Command = 'osv-scanner'; Winget = 'Google.OSVScanner'; Brew = 'osv-scanner' } + @{ Group = 'core'; Name = 'actionlint'; Command = 'actionlint'; Winget = 'rhysd.actionlint'; Brew = 'actionlint' } + @{ Group = 'core'; Name = '1Password'; Command = '1Password'; MacApp = '/Applications/1Password.app'; Winget = 'AgileBits.1Password'; Brew = '1password'; Cask = $true } + @{ Group = 'core'; Name = '1Password CLI'; Command = 'op'; Winget = 'AgileBits.1Password.CLI'; Brew = '1password-cli'; Cask = $true } + @{ Group = 'dotnet'; Name = '.NET SDK 10'; Command = 'dotnet'; Winget = 'Microsoft.DotNet.SDK.10'; Brew = 'dotnet-sdk'; Cask = $true } + @{ Group = 'java'; Name = 'Temurin JDK 21'; Command = 'java'; Winget = 'EclipseAdoptium.Temurin.21.JDK'; Brew = 'temurin@21'; Cask = $true } + @{ Group = 'rust'; Name = 'rustup'; Command = 'rustup'; Winget = 'Rustlang.Rustup'; Brew = 'rustup' } + @{ Group = 'go'; Name = 'Go'; Command = 'go'; Winget = 'GoLang.Go'; Brew = 'go' } + @{ Group = 'swift'; Name = 'SwiftLint'; Command = 'swiftlint'; Winget = $null; Brew = 'swiftlint' } + @{ Group = 'swift'; Name = 'swift-format'; Command = 'swift-format'; Winget = $null; Brew = 'swift-format' } + @{ Group = 'shell'; Name = 'ShellCheck'; Command = 'shellcheck'; Winget = 'koalaman.shellcheck'; Brew = 'shellcheck' } + @{ Group = 'shell'; Name = 'shfmt'; Command = 'shfmt'; Winget = 'mvdan.shfmt'; Brew = 'shfmt' } + @{ Group = 'terraform'; Name = 'OpenTofu'; Command = 'tofu'; Winget = 'OpenTofu.Tofu'; Brew = 'opentofu' } + @{ Group = 'terraform'; Name = 'TFLint'; Command = 'tflint'; Winget = 'TerraformLinters.tflint'; Brew = 'tflint' } +) + +# Python tools installed with `uv tool install`, isolated from any project. +$script:UvTools = @('zizmor', 'pre-commit') + +$script:Results = [System.Collections.Generic.List[object]]::new() + + + +<# +.SYNOPSIS + Records one check result and prints it as a colored line. +#> +function Add-Result +{ + param( + [Parameter(Mandatory)] [ValidateSet('PASS', 'WARN', 'FAIL', 'INFO')] [string] $Status, + [Parameter(Mandatory)] [string] $Item, + [string] $Detail = '' + ) + + $script:Results.Add([pscustomobject]@{ Status = $Status; Item = $Item; Detail = $Detail }) + $color = @{ PASS = 'Green'; WARN = 'Yellow'; FAIL = 'Red'; INFO = 'Cyan' }[$Status] + Write-Host ('{0,-5} {1,-34} {2}' -f $Status, $Item, $Detail) -ForegroundColor $color +} + + + +<# +.SYNOPSIS + Prints a section heading. +#> +function Write-Section +{ + param([Parameter(Mandatory)] [string] $Title) + + Write-Host '' + Write-Host "== $Title ==" -ForegroundColor White +} + + + +<# +.SYNOPSIS + Reloads PATH from the registry so tools installed by winget are found in this session. +#> +function Import-MachinePath +{ + if ($IsWindows) + { + $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = "$machine;$user;$env:LOCALAPPDATA\Microsoft\WinGet\Links" + } +} + + + +<# +.SYNOPSIS + True when the named command is on PATH. +#> +function Test-Command +{ + param([Parameter(Mandatory)] [string] $Name) + + return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue) +} + + + +<# +.SYNOPSIS + True when a catalog tool is present: its command on PATH, or its app bundle on macOS. +#> +function Test-ToolInstalled +{ + param([Parameter(Mandatory)] [hashtable] $Tool) + + if ($IsMacOS -and $Tool.ContainsKey('MacApp') -and (Test-Path -LiteralPath $Tool.MacApp)) + { + return $true + } + return [bool] ($Tool.Command -and (Test-Command -Name $Tool.Command)) +} + + + +<# +.SYNOPSIS + Installs one catalog entry with the platform's package manager, unless already present. +#> +function Install-CatalogTool +{ + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Mandatory)] [hashtable] $Tool) + + if (Test-ToolInstalled -Tool $Tool) + { + Add-Result -Status PASS -Item $Tool.Name -Detail 'already installed' + return + } + + if ($IsWindows) + { + if (-not $Tool.Winget) + { + Add-Result -Status INFO -Item $Tool.Name -Detail 'not available on Windows; skipped' + return + } + if ($PSCmdlet.ShouldProcess($Tool.Name, "winget install $($Tool.Winget)")) + { + & winget install --id $Tool.Winget --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity | Out-Host + Import-MachinePath + } + } + else + { + $cask = $Tool.ContainsKey('Cask') -and $Tool.Cask + if ($cask -and $IsLinux) + { + Add-Result -Status WARN -Item $Tool.Name -Detail 'install manually on Linux (see standards/workstation.md)' + return + } + $arguments = if ($cask) { @('install', '--cask', $Tool.Brew) } else { @('install', $Tool.Brew) } + if ($PSCmdlet.ShouldProcess($Tool.Name, "brew $($arguments -join ' ')")) + { + & brew @arguments | Out-Host + } + } + + if ($WhatIfPreference) + { + return + } + if ($Tool.Command -and -not (Test-ToolInstalled -Tool $Tool)) + { + Add-Result -Status FAIL -Item $Tool.Name -Detail 'install did not put it on PATH; open a new terminal and re-run' + return + } + Add-Result -Status PASS -Item $Tool.Name -Detail 'installed' +} + + + +<# +.SYNOPSIS + Installs the uv-managed Python tools (zizmor, pre-commit), upgrading them if present. +#> +function Install-UvTool +{ + [CmdletBinding(SupportsShouldProcess)] + param() + + if (-not (Test-Command -Name 'uv')) + { + Add-Result -Status FAIL -Item 'uv tools' -Detail 'uv is missing' + return + } + foreach ($name in $script:UvTools) + { + if ($PSCmdlet.ShouldProcess($name, 'uv tool install --upgrade')) + { + & uv tool install --upgrade $name 2>&1 | Out-Null + Add-Result -Status PASS -Item $name -Detail 'installed with uv' + } + } +} + + + +<# +.SYNOPSIS + Path of 1Password's SSH signing helper (op-ssh-sign) on this OS, or $null. +#> +function Get-OnePasswordSigner +{ + $candidates = if ($IsWindows) + { + # Direct installer (per user or per machine), then the Microsoft Store (MSIX) alias. + @( + "$env:LOCALAPPDATA\1Password\app\8\op-ssh-sign.exe" + "$env:ProgramFiles\1Password\app\8\op-ssh-sign.exe" + "$env:LOCALAPPDATA\Microsoft\WindowsApps\op-ssh-sign.exe" + ) + } + elseif ($IsMacOS) + { + @('/Applications/1Password.app/Contents/MacOS/op-ssh-sign') + } + else + { + @('/opt/1Password/op-ssh-sign') + } + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} + + + +<# +.SYNOPSIS + The socket of the 1Password SSH agent on macOS and Linux (Windows uses a named pipe). +#> +function Get-OnePasswordAgentSocket +{ + if ($IsMacOS) + { + return Join-Path $HOME 'Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock' + } + if ($IsLinux) + { + return Join-Path $HOME '.1password/agent.sock' + } + return $null +} + + + +<# +.SYNOPSIS + Public keys the SSH agent offers, as objects with Line, Type, and Comment. +#> +function Get-AgentKey +{ + # Read-only: never inherit -WhatIf, which would leak into the cmdlets used here. + $WhatIfPreference = $false + + $sshAdd = if ($IsWindows) { "$env:SystemRoot\System32\OpenSSH\ssh-add.exe" } else { 'ssh-add' } + $socket = Get-OnePasswordAgentSocket + if ($socket -and (Test-Path -LiteralPath $socket)) + { + $env:SSH_AUTH_SOCK = $socket + } + + $lines = & $sshAdd -L 2>$null + if ($LASTEXITCODE -ne 0 -or -not $lines) + { + return @() + } + return @($lines | ForEach-Object { + $parts = $_ -split ' ', 3 + [pscustomobject]@{ Line = "$($parts[0]) $($parts[1])"; Type = $parts[0]; Comment = if ($parts.Count -gt 2) { $parts[2] } else { '' } } + }) +} + + + +<# +.SYNOPSIS + Chooses the commit-signing key: by -SigningKey name, the only key, or by asking. +#> +function Select-SigningKey +{ + param( + [Parameter(Mandatory)] [object[]] $Keys, + [string] $Name + ) + + $candidates = @($Keys | Where-Object { $_.Type -like 'ssh-ed25519*' -or $_.Type -like 'ecdsa*' -or $_.Type -like 'sk-*' }) + if ($Name) + { + $candidates = @($candidates | Where-Object { $_.Comment -like "*$Name*" }) + } + if ($candidates.Count -eq 1) + { + return $candidates[0] + } + if ($candidates.Count -eq 0) + { + return $null + } + + Write-Host 'Several keys are available. Which one signs your commits?' + for ($index = 0; $index -lt $candidates.Count; $index++) + { + Write-Host (' [{0}] {1}' -f ($index + 1), $candidates[$index].Comment) + } + $choice = Read-Host 'Number' + $number = 0 + if (-not [int]::TryParse($choice, [ref] $number) -or $number -lt 1 -or $number -gt $candidates.Count) + { + throw 'No signing key selected.' + } + return $candidates[$number - 1] +} + + + +<# +.SYNOPSIS + Points ssh (macOS/Linux) at the 1Password agent through ~/.ssh/config, keeping a backup. +#> +function Set-SshAgentConfig +{ + [CmdletBinding(SupportsShouldProcess)] + param() + + $socket = Get-OnePasswordAgentSocket + if (-not $socket) + { + return + } + $sshDirectory = Join-Path $HOME '.ssh' + $config = Join-Path $sshDirectory 'config' + $existing = if (Test-Path -LiteralPath $config) { Get-Content -LiteralPath $config -Raw } else { '' } + if ($existing -match '(?im)^\s*IdentityAgent\s') + { + Add-Result -Status PASS -Item 'ssh IdentityAgent' -Detail 'already configured in ~/.ssh/config' + return + } + if ($PSCmdlet.ShouldProcess($config, 'add Host * IdentityAgent for 1Password')) + { + New-Item -ItemType Directory -Force -Path $sshDirectory | Out-Null + if ($existing) + { + Copy-Item -LiteralPath $config -Destination "$config.k22-backup" -Force + } + $block = "# Added by KofTwentyTwo Install-Workstation: SSH keys live in 1Password (K22-SEC-14).`nHost *`n IdentityAgent `"$socket`"`n`n" + Set-Content -LiteralPath $config -Value ($block + $existing) -NoNewline + Add-Result -Status PASS -Item 'ssh IdentityAgent' -Detail 'pointed at the 1Password agent' + } +} + + + +<# +.SYNOPSIS + Sets one global git configuration value, honoring -WhatIf. +#> +function Set-GitGlobal +{ + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] [string] $Key, + [Parameter(Mandatory)] [string] $Value + ) + + if ($PSCmdlet.ShouldProcess("git config --global $Key", $Value)) + { + & git config --global $Key $Value + } +} + + + +<# +.SYNOPSIS + Configures git for KofTwentyTwo: SSH signing through 1Password, DCO alias, sane defaults. +#> +function Set-GitConfiguration +{ + [CmdletBinding(SupportsShouldProcess)] + param( + [string] $SigningKeyName, + [switch] $Register + ) + + $email = & git config --global user.email + $name = & git config --global user.name + if (-not $email -or -not $name) + { + Add-Result -Status FAIL -Item 'git identity' -Detail 'set it first: git config --global user.name "..." ; git config --global user.email "..." (a verified GitHub email)' + return + } + + $signer = Get-OnePasswordSigner + if (-not $signer) + { + Add-Result -Status FAIL -Item '1Password signer' -Detail 'op-ssh-sign not found: install 1Password and enable its SSH agent' + return + } + + $keys = @(Get-AgentKey) + if ($keys.Count -eq 0) + { + Add-Result -Status FAIL -Item '1Password SSH agent' -Detail 'no keys offered: enable Settings > Developer > SSH agent and create an SSH key item' + return + } + $key = Select-SigningKey -Keys $keys -Name $SigningKeyName + if (-not $key) + { + Add-Result -Status FAIL -Item 'signing key' -Detail "no Ed25519/ECDSA key matching '$SigningKeyName' in the agent" + return + } + + Set-GitGlobal -Key 'gpg.format' -Value 'ssh' + Set-GitGlobal -Key 'gpg.ssh.program' -Value ($signer -replace '\\', '/') + Set-GitGlobal -Key 'user.signingkey' -Value $key.Line + Set-GitGlobal -Key 'commit.gpgsign' -Value 'true' + Set-GitGlobal -Key 'tag.gpgsign' -Value 'true' + Set-GitGlobal -Key 'init.defaultBranch' -Value 'main' + Set-GitGlobal -Key 'fetch.prune' -Value 'true' + Set-GitGlobal -Key 'pull.rebase' -Value 'true' + Set-GitGlobal -Key 'core.autocrlf' -Value 'false' + Set-GitGlobal -Key 'alias.cs' -Value 'commit -s' + if ($IsWindows) + { + # Windows OpenSSH talks to the 1Password agent's named pipe; Git's bundled ssh does not. + Set-GitGlobal -Key 'core.sshCommand' -Value 'C:/Windows/System32/OpenSSH/ssh.exe' + } + + # Lets `git log --show-signature` verify your own commits locally. + $signersFile = Join-Path $HOME '.config/git/allowed_signers' + if ($PSCmdlet.ShouldProcess($signersFile, 'write allowed signers')) + { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $signersFile) | Out-Null + Set-Content -LiteralPath $signersFile -Value "$email namespaces=`"git`" $($key.Line)" + Set-GitGlobal -Key 'gpg.ssh.allowedSignersFile' -Value ($signersFile -replace '\\', '/') + } + Add-Result -Status PASS -Item 'git signing' -Detail "signs with '$($key.Comment)' via 1Password" + + if ($Register) + { + Register-GitHubSigningKey -Key $key + } +} + + + +<# +.SYNOPSIS + Uploads the public signing key to GitHub as a signing key, if not already there. +#> +function Register-GitHubSigningKey +{ + [CmdletBinding(SupportsShouldProcess)] + param([Parameter(Mandatory)] [object] $Key) + + $existing = & gh api user/ssh_signing_keys --jq '.[].key' 2>$null + if ($LASTEXITCODE -eq 0 -and ($existing -contains $Key.Line)) + { + Add-Result -Status PASS -Item 'GitHub signing key' -Detail 'already registered' + return + } + if ($PSCmdlet.ShouldProcess('GitHub', 'add SSH signing key')) + { + & gh auth refresh --hostname github.com --scopes admin:ssh_signing_key | Out-Host + $file = Join-Path ([System.IO.Path]::GetTempPath()) 'k22-signing-key.pub' + Set-Content -LiteralPath $file -Value $Key.Line + try + { + & gh ssh-key add $file --type signing --title "$([Environment]::MachineName) commit signing" | Out-Host + } + finally + { + Remove-Item -LiteralPath $file -Force + } + Add-Result -Status PASS -Item 'GitHub signing key' -Detail 'registered' + } +} + + + +<# +.SYNOPSIS + Reports the machine security baseline of K22-SEC-20 and stray private keys (K22-SEC-14). +#> +function Test-SecurityBaseline +{ + # Read-only: never inherit -WhatIf, which would leak into the cmdlets used here. + $WhatIfPreference = $false + + if ($IsWindows) + { + # Readable without elevation: 1 = BitLocker on, 3 = encrypting. + $protection = (New-Object -ComObject Shell.Application).NameSpace("$env:SystemDrive\").Self.ExtendedProperty('System.Volume.BitLockerProtection') + if ($protection -in 1, 3) { Add-Result -Status PASS -Item 'Disk encryption' -Detail 'BitLocker on system drive' } + else { Add-Result -Status FAIL -Item 'Disk encryption' -Detail 'BitLocker is off on the system drive' } + + $profiles = @(Get-NetFirewallProfile -ErrorAction SilentlyContinue) + if ($profiles.Count -gt 0 -and -not ($profiles | Where-Object { -not $_.Enabled })) { Add-Result -Status PASS -Item 'Firewall' -Detail 'all profiles enabled' } + else { Add-Result -Status FAIL -Item 'Firewall' -Detail 'a Windows Firewall profile is disabled' } + + try + { + # CIM instead of Get-MpComputerStatus: importing the Defender module would pick up -WhatIf. + $defender = Get-CimInstance -Namespace 'root/Microsoft/Windows/Defender' -ClassName 'MSFT_MpComputerStatus' + if ($defender.RealTimeProtectionEnabled) { Add-Result -Status PASS -Item 'Malware protection' -Detail 'Defender real-time protection on' } + else { Add-Result -Status WARN -Item 'Malware protection' -Detail 'Defender real-time protection off (fine if another product is active)' } + } + catch + { + Add-Result -Status WARN -Item 'Malware protection' -Detail 'could not read Defender status' + } + } + elseif ($IsMacOS) + { + $fileVault = & fdesetup isactive 2>$null + if ("$fileVault" -eq 'true') { Add-Result -Status PASS -Item 'Disk encryption' -Detail 'FileVault on' } + else { Add-Result -Status FAIL -Item 'Disk encryption' -Detail 'FileVault is off' } + + $firewall = & /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>$null + if ("$firewall" -match 'enabled') { Add-Result -Status PASS -Item 'Firewall' -Detail 'application firewall on' } + else { Add-Result -Status FAIL -Item 'Firewall' -Detail 'application firewall is off' } + } + else + { + $crypt = & lsblk -o TYPE --noheadings 2>$null + if ("$crypt" -match 'crypt') { Add-Result -Status PASS -Item 'Disk encryption' -Detail 'LUKS volume present' } + else { Add-Result -Status WARN -Item 'Disk encryption' -Detail 'no LUKS volume found; confirm full-disk encryption' } + Add-Result -Status INFO -Item 'Firewall' -Detail 'confirm ufw/firewalld is enabled' + } + + $sshDirectory = Join-Path $HOME '.ssh' + $privateKeys = @() + if (Test-Path -LiteralPath $sshDirectory) + { + $privateKeys = @(Get-ChildItem -LiteralPath $sshDirectory -File | Where-Object { + $_.Extension -ne '.pub' -and ((Get-Content -LiteralPath $_.FullName -TotalCount 1 -ErrorAction SilentlyContinue) -match 'PRIVATE KEY') + }) + } + if ($privateKeys.Count -eq 0) { Add-Result -Status PASS -Item 'Private keys on disk' -Detail 'none in ~/.ssh' } + else { Add-Result -Status WARN -Item 'Private keys on disk' -Detail ("move into 1Password, then delete: {0}" -f (($privateKeys | ForEach-Object Name) -join ', ')) } +} + + + +<# +.SYNOPSIS + Reports whether git is configured for 1Password signing (used by -CheckOnly). +#> +function Test-GitConfiguration +{ + # Read-only: never inherit -WhatIf, which would leak into the cmdlets used here. + $WhatIfPreference = $false + + $format = & git config --global gpg.format + $program = & git config --global gpg.ssh.program + $sign = & git config --global commit.gpgsign + if ($format -eq 'ssh' -and $sign -eq 'true' -and "$program" -match 'op-ssh-sign') + { + Add-Result -Status PASS -Item 'git signing' -Detail 'SSH signing through 1Password' + } + elseif ($format -eq 'ssh' -and $sign -eq 'true') + { + Add-Result -Status WARN -Item 'git signing' -Detail "signs with SSH but not through op-ssh-sign ($program)" + } + else + { + Add-Result -Status FAIL -Item 'git signing' -Detail 'commits are not signed by default' + } + if (@(Get-AgentKey).Count -gt 0) { Add-Result -Status PASS -Item 'SSH agent' -Detail 'agent reachable and offering keys' } + else { Add-Result -Status FAIL -Item 'SSH agent' -Detail 'no agent or no keys' } +} + + + +################################################################################# +# Main # +################################################################################# +$platform = if ($IsWindows) { 'Windows' } elseif ($IsMacOS) { 'macOS' } else { 'Linux' } +Write-Host "KofTwentyTwo workstation setup ($platform)" -ForegroundColor White +if (-not $IsWindows -and -not (Test-Command -Name 'brew')) +{ + throw 'Homebrew is required on macOS and Linux; run setup/bootstrap.sh, which installs it.' +} +if ($IsWindows -and -not (Test-Command -Name 'winget')) +{ + throw 'winget (App Installer) is required; install it from the Microsoft Store.' +} + +$groups = @('core') + $Languages +Write-Section -Title 'Tools' +foreach ($tool in $script:Catalog | Where-Object { $_.Group -in $groups }) +{ + if ($CheckOnly) + { + if (Test-ToolInstalled -Tool $tool) { Add-Result -Status PASS -Item $tool.Name -Detail 'installed' } + else { Add-Result -Status FAIL -Item $tool.Name -Detail 'missing' } + } + else + { + Install-CatalogTool -Tool $tool + } +} +if ($CheckOnly) +{ + foreach ($name in $script:UvTools) + { + if (Test-Command -Name $name) { Add-Result -Status PASS -Item $name -Detail 'installed' } + else { Add-Result -Status FAIL -Item $name -Detail 'missing' } + } +} +else +{ + Install-UvTool + if ('shell' -in $Languages -and $PSCmdlet.ShouldProcess('PSScriptAnalyzer', 'Install-PSResource')) + { + Install-PSResource -Name PSScriptAnalyzer -Scope CurrentUser -TrustRepository -Quiet + Add-Result -Status PASS -Item 'PSScriptAnalyzer' -Detail 'installed' + } +} + +Write-Section -Title 'Git and 1Password' +if ($CheckOnly) +{ + Test-GitConfiguration +} +elseif (-not $SkipGitConfig) +{ + Set-SshAgentConfig + Set-GitConfiguration -SigningKeyName $SigningKey -Register:$RegisterSigningKey +} + +Write-Section -Title 'Security baseline (K22-SEC-20)' +Test-SecurityBaseline + +$failed = @($script:Results | Where-Object { $_.Status -eq 'FAIL' }).Count +$warned = @($script:Results | Where-Object { $_.Status -eq 'WARN' }).Count +Write-Host '' +Write-Host ("Done: {0} failed, {1} warnings." -f $failed, $warned) -ForegroundColor ($failed -gt 0 ? 'Red' : 'Green') +if (-not $CheckOnly -and $failed -eq 0) +{ + Write-Host 'Open a new terminal so newly installed tools are on PATH.' +} +exit ($failed -gt 0 ? 1 : 0) diff --git a/setup/bootstrap.ps1 b/setup/bootstrap.ps1 new file mode 100644 index 0000000..1a5fbc0 --- /dev/null +++ b/setup/bootstrap.ps1 @@ -0,0 +1,47 @@ +<# +.SYNOPSIS + Windows bootstrap: installs PowerShell 7 if needed, then runs Install-Workstation.ps1. + +.DESCRIPTION + Runs in the Windows PowerShell 5.1 that ships with Windows, so it works on a brand-new + machine. It downloads Install-Workstation.ps1 from the same ref of + KofTwentyTwo/standards and runs it in PowerShell 7, passing through any arguments. + +.PARAMETER Ref + Branch, tag, or commit of KofTwentyTwo/standards to use. Pin a release tag for a + reproducible setup. + +.EXAMPLE + & ([scriptblock]::Create((Invoke-RestMethod https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1))) -Languages dotnet +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive bootstrap: progress messages for the person running it.')] +[CmdletBinding()] +param( + [string] $Ref = 'main', + + [Parameter(ValueFromRemainingArguments)] + [object[]] $WorkstationArguments = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command -Name winget -ErrorAction SilentlyContinue)) +{ + throw 'winget (App Installer) is required. Install "App Installer" from the Microsoft Store, then run this again.' +} + +if (-not (Get-Command -Name pwsh -ErrorAction SilentlyContinue)) +{ + Write-Host 'Installing PowerShell 7...' + & winget install --id Microsoft.PowerShell --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity + $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') +} + +$script = Join-Path ([System.IO.Path]::GetTempPath()) 'Install-Workstation.ps1' +$url = "https://raw.githubusercontent.com/KofTwentyTwo/standards/$Ref/setup/Install-Workstation.ps1" +Write-Host "Downloading $url" +Invoke-WebRequest -Uri $url -OutFile $script -UseBasicParsing + +& pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File $script @WorkstationArguments +exit $LASTEXITCODE diff --git a/setup/bootstrap.sh b/setup/bootstrap.sh new file mode 100644 index 0000000..7848242 --- /dev/null +++ b/setup/bootstrap.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# macOS and Linux bootstrap for a KofTwentyTwo workstation (standards/workstation.md). +# Installs Homebrew and PowerShell 7 if needed, then runs Install-Workstation.ps1 from +# the same ref of KofTwentyTwo/standards, passing through every argument. +# +# curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh | bash -s -- -Languages dotnet +# +# Set K22_REF to a release tag for a reproducible setup. +set -euo pipefail + +ref="${K22_REF:-main}" +os="$(uname -s)" + +if ! command -v brew > /dev/null 2>&1; then + echo "Installing Homebrew..." + # Captured first so a failed download stops the script (set -e) instead of running nothing. + installer="$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + NONINTERACTIVE=1 /bin/bash -c "${installer}" + for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew /home/linuxbrew/.linuxbrew/bin/brew; do + if [[ -x "${candidate}" ]]; then + environment="$("${candidate}" shellenv)" + eval "${environment}" + break + fi + done +fi + +if ! command -v pwsh > /dev/null 2>&1; then + echo "Installing PowerShell 7..." + if [[ "${os}" == "Darwin" ]]; then + brew install --cask powershell + else + echo "Install PowerShell 7 for your distribution, then run this again:" >&2 + echo " https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-linux" >&2 + exit 1 + fi +fi + +script="$(mktemp -t install-workstation.XXXXXX).ps1" +trap 'rm -f "${script}"' EXIT +url="https://raw.githubusercontent.com/KofTwentyTwo/standards/${ref}/setup/Install-Workstation.ps1" +echo "Downloading ${url}" +curl -fsSL "${url}" -o "${script}" + +pwsh -NoLogo -NoProfile -File "${script}" "$@" diff --git a/standards/workstation.md b/standards/workstation.md new file mode 100644 index 0000000..478cba5 --- /dev/null +++ b/standards/workstation.md @@ -0,0 +1,75 @@ +# Workstation standard + +How a machine used to write, sign, or release KofTwentyTwo software is set up. The +[`setup/`](../setup) scripts implement this standard on Windows, macOS, and Linux and +check a machine against it; running them is the expected way to comply. + +## Setup + +**K22-WS-01 (MUST)** A development machine is set up, and re-checked after major +changes and at the annual access review, with `setup/Install-Workstation.ps1` +(through the bootstrap for its OS). A machine that cannot run it meets each requirement +below by hand. +*Why:* one repeatable setup instead of a checklist from memory. *Verified by:* +`Install-Workstation.ps1 -CheckOnly`. + +**K22-WS-02 (MUST)** These tools are installed and kept current: git, GitHub CLI, +PowerShell 7, uv, Node.js LTS, gitleaks, Trivy, OSV-Scanner, actionlint, zizmor, +pre-commit, 1Password, and the 1Password CLI, plus the toolchain of every language +profile the maintainer works in. +*Why:* the local hooks and checks are the same ones CI runs +([`K22-CI-03`](ci-cd.md#required-checks)). *Verified by:* `-CheckOnly`. + +## Keys and secrets live in 1Password + +1Password is KofTwentyTwo's password manager and SSH agent. Keys are **created in +1Password** and never exist as files. + +**K22-WS-10 (MUST)** Every SSH key (GitHub authentication, commit signing, deploy keys, +server access) is generated inside 1Password and used through the 1Password SSH agent. +No private key is stored in `~/.ssh` or anywhere else on disk; a key found on disk is +moved into 1Password (import it, or replace it with a new key) and the file deleted. +*Why:* a key file can be copied by any process running as the user, by backups, and by +malware; a key in 1Password is released only through the agent, with the user's +approval. *Verified by:* `-CheckOnly` (private keys on disk). *Maps to:* +[`K22-SEC-14`](../policies/security-program.md#secrets-and-credentials). + +**K22-WS-11 (MUST)** Commits and tags are signed with an SSH key from 1Password +(`gpg.format = ssh`, `gpg.ssh.program` = 1Password's `op-ssh-sign`), and that key is +registered on GitHub as a **signing** key. +*Why:* *Verified* commits ([`K22-SDLC-14`](../policies/sdlc.md#build)) without a signing +key on disk. *Verified by:* `-CheckOnly` (git signing); GitHub shows the commit as +Verified. + +**K22-WS-12 (MUST)** Secrets a tool needs at run time (API tokens, registry keys) are +read from 1Password when needed, with `op run`, `op read`, or `op://` references in an +environment file, never stored in shell profiles, `.env` files, or tool configuration. +*Why:* nothing to leak from a dotfile or a screen share. *Verified by:* gitleaks; review. + +**K22-WS-13 (SHOULD)** The 1Password SSH agent is configured to ask for approval per +application, and 1Password locks with the operating system. +*Why:* a malicious process cannot silently use a key. *Verified by:* annual review. + +## Machine security baseline + +**K22-WS-20 (MUST)** The machine meets the [security program](../policies/security-program.md#development-environment) +baseline: full-disk encryption (BitLocker, FileVault, or LUKS), firewall on, malware +protection on (Windows), automatic OS updates, and a screen lock. +*Why:* a lost or compromised workstation must not expose source, sessions, or keys. +*Verified by:* `-CheckOnly` (disk encryption, firewall, malware protection). + +## Running the setup + +| OS | Command | +| --- | --- | +| Windows | `& ([scriptblock]::Create((irm https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.ps1)))` | +| macOS, Linux | `curl -fsSL https://raw.githubusercontent.com/KofTwentyTwo/standards/main/setup/bootstrap.sh \| bash` | + +Add arguments after the command, for example `-Languages dotnet,java -SigningKey GitHub +-RegisterSigningKey`. See the [README](../README.md#set-up-your-machine) for the +options, the inspect-before-running variant, and pinning to a release. + +On Linux the script installs tools with Homebrew; install 1Password and PowerShell 7 +from their official Linux packages first +([1Password](https://support.1password.com/install-linux/), +[PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-linux)). -- 2.54.0 From 8dc117a85ec06f91e75d2ba39b8ec373f137fa2a Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 09:48:47 -0500 Subject: [PATCH 2/3] fix(setup): never change global autocrlf or pull.rebase Both alter behavior in every repository on the machine, including ones that are not KofTwentyTwo's (autocrlf=false would show CRLF working trees as modified in repositories without .gitattributes). Line endings are governed per repository by .gitattributes (K22-REPO-03). Co-Authored-By: Claude Opus 5.5 Signed-off-by: James Maes --- setup/Install-Workstation.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup/Install-Workstation.ps1 b/setup/Install-Workstation.ps1 index abb6d7c..f0513d5 100644 --- a/setup/Install-Workstation.ps1 +++ b/setup/Install-Workstation.ps1 @@ -479,8 +479,9 @@ function Set-GitConfiguration Set-GitGlobal -Key 'tag.gpgsign' -Value 'true' Set-GitGlobal -Key 'init.defaultBranch' -Value 'main' Set-GitGlobal -Key 'fetch.prune' -Value 'true' - Set-GitGlobal -Key 'pull.rebase' -Value 'true' - Set-GitGlobal -Key 'core.autocrlf' -Value 'false' + # Deliberately NOT set globally: core.autocrlf and pull.rebase. They change behavior in + # every repository on the machine, including ones that are not KofTwentyTwo's; line + # endings are governed per repository by .gitattributes (K22-REPO-03). Set-GitGlobal -Key 'alias.cs' -Value 'commit -s' if ($IsWindows) { -- 2.54.0 From b90a83fae52f92d69bb1527c6087a2bc138e3f6d Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 09:49:36 -0500 Subject: [PATCH 3/3] fix(setup): accept comma-separated -Languages from pwsh -File Both bootstraps run the script with pwsh -File, which passes "dotnet,java" as a single string, so the documented example failed validation. Languages are now split on commas and validated after splitting. Found by running the setup for real on Windows 11. Co-Authored-By: Claude Opus 5.5 Signed-off-by: James Maes --- setup/Install-Workstation.ps1 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/setup/Install-Workstation.ps1 b/setup/Install-Workstation.ps1 index f0513d5..1398fcf 100644 --- a/setup/Install-Workstation.ps1 +++ b/setup/Install-Workstation.ps1 @@ -44,7 +44,8 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive installer: colored console output is its user interface.')] [CmdletBinding(SupportsShouldProcess)] param( - [ValidateSet('dotnet', 'java', 'python', 'rust', 'go', 'swift', 'shell', 'terraform')] + # Validated after splitting: `pwsh -File` (used by the bootstraps) passes "dotnet,java" + # as a single string. [string[]] $Languages = @(), [string] $SigningKey, @@ -59,6 +60,14 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$supportedLanguages = @('dotnet', 'java', 'python', 'rust', 'go', 'swift', 'shell', 'terraform') +$Languages = @($Languages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) +$unknown = @($Languages | Where-Object { $_ -notin $supportedLanguages }) +if ($unknown.Count -gt 0) +{ + throw "Unknown language(s): $($unknown -join ', '). Supported: $($supportedLanguages -join ', ')." +} + ################################################################################# -- 2.54.0