Security¶
Secrets handling¶
- Nothing sensitive is committed.
.gitignoreexcludes.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
.envor the environment. None of the 11 PowerShell scripts in this repo contain a literal password, token, or key. Postgres passwords flow throughdocker 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.ps1prints it once, deliberately, because the operator needs to copy it to runJoin-K3sAgent.ps1— it is never written to a file by this repo's scripts.Join-K3sAgent.ps1takes it as a[securestring]parameter (the runbook tells the operator toRead-Host -AsSecureString), converts it to plaintext only at the moment of use, and explicitly scrubs the plaintext variable ($plainToken = $null; [System.GC]::Collect()) in afinallyblock. It never appears in aWrite-Hostcall.Get-K3sKubeconfig.ps1fetches 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.ps1with 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.ps1and the hosts-alias-updating branch ofSet-PrimaryMarker.ps1require Administrator (they editC:\Windows\System32\drivers\etc\hosts) and check for it explicitly before doing anything, throwing a clear error otherwise.- Every mutating script declares
SupportsShouldProcess, so-WhatIfis 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>, AWSAKIA...,sk-..., GitHubgh[a-z]_..., Slackxox[a-z]-..., and PEM private-key patterns) runs over every.ps1,.md,.yml/.yaml,.sql,.env,.env.example, and.jsonfile 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 thesemgrep/semgrepDocker image — there is no native Windows Semgrep binary) runs in thesecurityCI 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 / avolumes: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 exclusivelycompose/scripts/.curl -sfL https://get.k3s.io | sh -(inInstall-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 wholekubernetes/directory is an explicitly-labeled learning lab that only ever runs against a Linux node the operator controls, never in CI. Pinning-K3sVersionreduces 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'sci.ymltop 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/semgrephas no explicit tag at all;Install-Module Pesteruses-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_PASSWORDis 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.