feat(setup): one-command workstation setup with keys in 1Password #2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}" "$@"
|
||||
@@ -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)).
|
||||
Reference in new issue
Block a user