Skip to content

Security

Secrets handling

  • Nothing sensitive is committed. .gitignore excludes .env, *.secret, *.key, *.pem, *.pfx, *.kdbx, kubeconfig/*.kubeconfig/k3s.yaml, and any *-token.txt / node-token* / join-token* file. Only .env.example (placeholders, e.g. POSTGRES_PASSWORD=CHANGE_ME_use_a_long_random_string) is tracked.
  • Every script reads credentials from .env or the environment. None of the 11 PowerShell scripts in this repo contain a literal password, token, or key. Postgres passwords flow through docker exec -e "PGPASSWORD=$pgPass" (never as a bare CLI argument that would land in shell history/process listings unredacted across the board — it's still visible to anyone who can read the running process table on that box, which is an accepted risk for a single-operator home lab).
  • The k3s node-join token is a real secret and is handled specially:
  • Install-K3sServer.ps1 prints it once, deliberately, because the operator needs to copy it to run Join-K3sAgent.ps1 — it is never written to a file by this repo's scripts.
  • Join-K3sAgent.ps1 takes it as a [securestring] parameter (the runbook tells the operator to Read-Host -AsSecureString), converts it to plaintext only at the moment of use, and explicitly scrubs the plaintext variable ($plainToken = $null; [System.GC]::Collect()) in a finally block. It never appears in a Write-Host call.
  • Get-K3sKubeconfig.ps1 fetches a kubeconfig (which embeds client certs/keys) and writes it only to a path covered by .gitignore.
  • The Docker Swarm join token is printed by Init-Swarm.ps1 with an explicit on-screen warning ("the join token is a SECRET — do not commit it") and is never written to disk by this repo.

Elevation boundaries

  • Set-HomelabHostsAliases.ps1 and the hosts-alias-updating branch of Set-PrimaryMarker.ps1 require Administrator (they edit C:\Windows\System32\drivers\etc\hosts) and check for it explicitly before doing anything, throwing a clear error otherwise.
  • Every mutating script declares SupportsShouldProcess, so -WhatIf is always available to preview a change with zero elevation and zero side effects — verified in CI (tests/Hermetic.Tests.ps1, tests/Scripts.Tests.ps1).

Split-brain protection

Promote-PostgresStandby.ps1 refuses to promote a standby to primary if the claude-mem-primary alias resolves to a different, currently-reachable node, unless the operator passes -Force. This is the mechanical guard behind "two nodes must never both believe they are primary" — see the sequence diagram in ARCHITECTURE.md.

Scan results summary

  • Repo-wide secret-shaped-literal grep (tests/Scripts.Tests.ps1, "No hardcoded secret literals" — council-token, Bearer <token>, AWS AKIA..., sk-..., GitHub gh[a-z]_..., Slack xox[a-z]-..., and PEM private-key patterns) runs over every .ps1, .md, .yml/.yaml, .sql, .env, .env.example, and .json file in the repo as part of the Pester suite, and again as the standalone hard gate documented in the project's shared build process. Result at time of writing: clean.
  • Semgrep (semgrep scan --config auto --error ., run via the semgrep/semgrep Docker image — there is no native Windows Semgrep binary) runs in the security CI job against the whole repo. See the CI run history for the current result; the workflow fails the build on any finding (--error).

Codex security review (2026-07-08)

codex exec "Security review this repository for: hardcoded secrets, injection, unsafe download/exec, CI supply-chain issues..." found no checked-in secrets and no exploitable RCE. It flagged the k3s scripts' NodeIp / ServerUrl / K3sVersion / SshTarget / node-token values as being interpolated into a remote shell command string (Invoke-Remote's bash -lc / ssh call) without validation — a malicious value there could break out of the intended command. Fixed: Install-K3sServer.ps1, Join-K3sAgent.ps1, Get-K3sKubeconfig.ps1, and Teardown-K3s.ps1 now [ValidatePattern(...)] every one of those parameters (IP/hostname-safe characters only for NodeIp/ServerIp/WslDistro, user@host shape for SshTarget, https://host:port shape for ServerUrl, version-string shape for K3sVersion), and Join-K3sAgent.ps1 additionally validates the decrypted token against a safe character set before it is interpolated. None of this changes normal usage — real IPs, hostnames, k3s versions, and tokens all satisfy these patterns; it only rejects shell-metacharacter payloads.

The remaining findings were judged non-blocking (documented risk, not a fix in this pass) and are recorded below rather than silently dropped.

Known accepted risks (documented, not hidden)

  • Deploy-SwarmStack.ps1's "no stateful services" guardrail is a heuristic (regex over image names / a volumes: key) — it is defense-in-depth, not a guarantee. Do not rely on it alone to keep Postgres out of Swarm; the real control is "we don't put stateful stacks there," documented repeatedly.
  • 21-cnpg-postgres-cluster.yaml (the k3s learning exercise) intentionally ships no scheduled backup configuration — there is no S3/NAS target in this environment, and the point of that manifest is to teach operator mechanics, not to be a second, competing DR system. Production Postgres DR is exclusively compose/scripts/.
  • curl -sfL https://get.k3s.io | sh - (in Install-K3sServer.ps1 / Join-K3sAgent.ps1) downloads and runs k3s's own official installer over HTTPS without a separate checksum/signature pin. This is k3s's documented install method upstream, not something this repo invented; the whole kubernetes/ directory is an explicitly-labeled learning lab that only ever runs against a Linux node the operator controls, never in CI. Pinning -K3sVersion reduces drift; full checksum verification is a reasonable future hardening (HDR-4-adjacent) but out of scope for this pass.
  • CI runs on a non-ephemeral self-hosted runner, including for pull_request (.github/workflows/ci.yml). This repo is private with house-member-only contributors, matching the same accepted pattern used across this tool-repo fleet (see the pilot repo's ci.yml top comment). Untrusted PR code should never land here; if that assumption ever changes, switch to ephemeral runners or gate PR runs behind manual approval first.
  • Container/package pins in CI are tag-, not digest-, pinned (semgrep/semgrep has no explicit tag at all; Install-Module Pester uses -MinimumVersion/-SkipPublisherCheck). This matches the shared CI template used across the tool-repo fleet verbatim, trading perfect reproducibility for staying current with security patches in a small, low-blast-radius home-lab CI. Compose/Kubernetes runtime images (postgres:16-alpine, valkey/valkey:8-alpine, etc.) are similarly tag-pinned, not digest-pinned.
  • VALKEY_PASSWORD is passed as a container command-line argument (compose/stacks/claude-mem/docker-compose.yml), which means it is visible to anything that can read that container's process arguments (e.g. docker inspect) on the host. Valkey here is cache/derived-state only (see "Stateful vs. stateless treatment" in ../pros-cons.md), the box is single-operator, and the alternative (a mounted config file) is a bigger structural change deferred to a future pass if this ever needs to be multi-tenant.