Skip to content

Security

Secrets handling

  • No secret ever lives in this repo, an image, or CI. The .gitignore / .dockerignore exclude *.pem, *.dpapi, *.kdbx/*.kdb, config.psd1, config.json, *.local.psd1, any *token*, and .env*. config.example.psd1 is committed and contains placeholders only (AppId = '000000', InstallationId = '00000000').
  • Key custody chain: KeePass .kdbx vault (master copy, one entry per PC) -> one-time Provision-Key.ps1 -> Windows DPAPI blob (CurrentUser-scoped, cannot be decrypted by another user or on another machine) or Credential Manager. The private key is loaded into memory only for the duration of a token-mint call and disposed ($rsa.Dispose()) immediately after - watchdog/GitHubApp.psm1's Get-WWRunnerRegistration wraps this in a try/finally.
  • Only the shortest-lived token ever crosses a trust boundary. The App private key never leaves the host. The App JWT (~9 min) and installation token (~1h) stay in the watchdog's memory. Only the runner registration token (~1h, single-purpose) is passed into a container (-e RUNNER_TOKEN) or to config.cmd --token on the Windows host.
  • CI reads no secrets. .github/workflows/ci.yml's jobs never reference an App key, installation ID, or token - tests/GitHubApp.Tests.ps1 uses only a throwaway RSA key generated fresh in-memory per test run and mocks every GitHub API call (see TESTING.md).

Scan results summary

  • Secret grep (hard gate): the exact regex from the house build brief (council_...|Bearer ...|AKIA...|sk-...|gh[pousr]_...|xox[baprs]-...|-----BEGIN [A-Z ]*PRIVATE KEY-----) runs both locally, as a Pester test (tests/Repo.Tests.ps1 "Repo-wide secret-literal scan"), and in CI as the standalone secret-gate job. Both currently return clean. Test fixtures that need PEM-shaped text (see tests/GitHubApp.Tests.ps1) build the -----BEGIN/-----END markers from split string parts specifically so the fixture's source text never contains a contiguous match.
  • Codex security review: run from repo root before each ship-gate commit (codex exec "Security review this repository for: hardcoded secrets, injection, unsafe download/exec, CI supply-chain issues..."). Verdict at time of writing (2026-07-08): hardcoded secrets CLEAN, no exploitable injection. Non-secret findings and their disposition:
Finding Severity Disposition
"No .dockerignore, so docker build . context may include config.psd1/*.pem" HIGH (reported) False positive.dockerignore exists at repo root and excludes exactly those patterns (config.psd1, *.pem, *.dpapi, *.kdbx, .env*, etc.). Codex's read tool fell back to a non-sandboxed listing that silently skips dotfiles; verified manually (ls -la .dockerignore).
Pinned-runner-checksum placeholders (docker/Dockerfile.linux, skill/scripts/Install-WindowsRunner.ps1) skip verification by default HIGH Known, pre-existing, already tracked — both files already warn loudly when the checksum is a placeholder (see their own comments) and this repo's README.md lists it under "Blocked on the real App / pre-ship TODO". Setting the real values is out of scope for this pass (no runtime-behavior changes) and is tracked as docs/BOARD.md WWCIR-3.
semgrep/semgrep Docker image run unpinned (no tag/digest) in .github/workflows/ci.yml HIGH Accepted, matches house template — the build brief mandates this exact command (docker run ... semgrep/semgrep semgrep scan --config auto --error .) to match the pilot repo's ci.yml. Pilot has the identical exposure. Pinning to a digest would deviate from the shared house CI template; tracked as a fleet-wide follow-up rather than a per-repo fix.
pull_request jobs run on self-hosted Windows runners MEDIUM Accepted, documented — this repo is private with house-member-only PR access, called out explicitly in the top-of-file comment in ci.yml (same convention as the pilot).
Pester installed from PSGallery at CI runtime with -SkipPublisherCheck, no version pin beyond -MinimumVersion MEDIUM Accepted, matches house template — identical to the pilot's ci.yml step; a fleet-wide pin (exact version + hash) is a good follow-up but out of scope for matching the established template in this pass.
Provision-Key.ps1 -ToCredentialManager passes the PEM via cmdkey /pass:$pemText, exposing key material in process command-line arguments during provisioning MEDIUM Known, pre-existing runtime script — not touched this pass (no runtime-behavior changes in scope). Tracked as docs/BOARD.md WWCIR-6 for a future pass (e.g. switch to cmdkey's stdin-safe alternative or a .NET ProcessStartInfo argument list that isn't visible in Get-CimInstance Win32_Process.CommandLine).
- Semgrep: security CI job runs `semgrep/semgrep scan --config auto
--error` via Docker (no native Windows binary exists) against the whole repo
on every push/PR.
- Supply chain: actions/checkout is pinned to a full commit SHA (not a
mutable tag) per house policy; docker/Dockerfile.linux pins
RUNNER_VERSION and checks RUNNER_SHA256 (warns instead of hard-failing
while the placeholder checksum is in place - flagged as a pre-ship TODO in
README.md); Install-WindowsRunner.ps1 verifies -RunnerSha256 the same
way for the Windows host runner zip.

Rotation and revocation

See docs/REVOCATION-RUNBOOK.md for the full fast-revocation procedure (per-PC key revoke, whole-App suspend/delete, reprovision, graceful stop, and force-deregister-by-id). The short version: because each of the 4 PCs has its own App private key, one PC's compromise never requires touching the other three.

Threat model notes

  • Blast radius of a compromised registration token: repo-scoped, Administration:write only, ≤1h lifetime, and the runner it registers is --ephemeral (serves exactly one job then self-removes). A leaked token cannot reach other repos or org settings.
  • Container compromise alone does not leak the App key - the key never enters a container. The worst a compromised container can do is the damage possible within one job's lifetime with a repo-admin-scoped runner token.
  • PR authors are trusted house members. This repo's runners are self-hosted and non-ephemeral at the watchdog level (the watchdog process itself persists; only the runner it launches is ephemeral), so untrusted external PR code must never be run against this pool - see the supply-chain note at the top of .github/workflows/ci.yml.