Security¶
Secrets handling¶
- No secret ever lives in this repo, an image, or CI. The
.gitignore/.dockerignoreexclude*.pem,*.dpapi,*.kdbx/*.kdb,config.psd1,config.json,*.local.psd1, any*token*, and.env*.config.example.psd1is committed and contains placeholders only (AppId = '000000',InstallationId = '00000000'). - Key custody chain: KeePass
.kdbxvault (master copy, one entry per PC) -> one-timeProvision-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'sGet-WWRunnerRegistrationwraps this in atry/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 toconfig.cmd --tokenon 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.ps1uses only a throwaway RSA key generated fresh in-memory per test run and mocks every GitHub API call (seeTESTING.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 standalonesecret-gatejob. Both currently return clean. Test fixtures that need PEM-shaped text (seetests/GitHubApp.Tests.ps1) build the-----BEGIN/-----ENDmarkers 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:writeonly, ≤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.