feat(setup): one-command workstation setup with keys in 1Password #2

Merged
KofTwentyTwo merged 3 commits from feat/workstation-setup into main 2026-10-09 19:35:33 +00:00
7 changed files with 943 additions and 43 deletions

No files matched your search

+48 -35
View File
@@ -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
+1
View File
@@ -59,6 +59,7 @@ K22-<AREA>-<NN>
| `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-<LANG>` | `standards/coding/<language>.md` | One profile per language, e.g. `K22-CODE-CS-03` |
+12 -8
View File
@@ -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
+715
View File
@@ -0,0 +1,715 @@
#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(
# Validated after splitting: `pwsh -File` (used by the bootstraps) passes "dotnet,java"
# as a single string.
[string[]] $Languages = @(),
[string] $SigningKey,
[switch] $RegisterSigningKey,
[switch] $CheckOnly,
[switch] $SkipGitConfig
)
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 ', ')."
}
#################################################################################
# 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'
# 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)
{
# 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)
+47
View File
@@ -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
+45
View File
@@ -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}" "$@"
+75
View File
@@ -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)).