|
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
name: secret-hygiene
|
|
|
|
|
|
|
|
description: Prevent agent sessions from rendering credentials into the transcript, and recover safely when one leaks. Load BEFORE any command that touches credentials - kubectl exec/env/describe, kubectl get secret, whole-file SOPS decryption, token generation or rotation, envFrom inspection, or edits to encrypted config. Also load when a credential has already been exposed and a rotation chain is needed. Triggers - "rotate this token", "decrypt the config", "check the env var", "leaked credential", "did that print a secret", "why is this secret wrong".
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Secret hygiene for agent sessions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Goal: stop the recurring leak pattern where a diagnostic command renders a
|
|
|
|
|
|
|
|
SOPS-encrypted value or a Kubernetes Secret value into the session
|
|
|
|
|
|
|
|
transcript, forcing a rotation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**Confirmed past leaks** (see `mathias/infra#39` rotation umbrella):
|
|
|
|
|
|
|
|
- `POSTGRES_PASSWORD` — `kubectl exec ... env` rendered envFrom value
|
|
|
|
|
|
|
|
- `INFRA_MCP_TOKEN` — `sops -d ingress.enc.yaml` rendered the bearer in the configuration-snippet
|
|
|
|
|
|
|
|
- `brain_app` postgres DSN — pre-fix ingestion pod log (hyperguild#20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Three are pattern, not bug.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Rules — never do these
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Forbidden | Why | Use instead |
|
|
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
| `kubectl exec <pod> -- env` (or `printenv`) | Renders every envFrom + valueFrom.secretKeyRef plaintext | `kubectl describe pod <pod>` — k8s redacts secretKeyRef in describe |
|
|
|
|
|
|
|
|
| `sops -d <file>` to inspect | Whole-file decrypt dumps everything | `sops --extract '["key"]' <file>` for pinpoint reads |
|
|
|
|
|
|
|
|
| `kubectl get secret ... -o jsonpath='{.data}'` (whole map) | Returns base64 of every key; trivial to b64-decode the lot | `... -o jsonpath='{.data.SPECIFIC_KEY}'` for the one key needed |
|
|
|
|
|
|
|
|
| Echoing a generated token to verify length / content | Renders the new token into transcript, defeating the rotation | Verify functionally: `curl ... -H "Authorization: Bearer $(cat /tmp/tokfile)"` — never echo `$NEW_TOK` |
|
|
|
|
|
|
|
|
| Storing a token in a shell variable then running `echo` / `set` / `env` | Same as above | Pipe through file (mode 600) or stdin to the next consumer |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Rules — do these instead
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- **Verify by side-effect, not by value.** If a token rotation should make
|
|
|
|
|
|
|
|
the old token return 401 and the new one return 200, test those two
|
|
|
|
|
|
|
|
HTTP responses. Never check `$OLD == $LEAKED` by printing it.
|
|
|
|
|
|
|
|
- **Pinpoint SOPS reads.** When you need ONE key from a SOPS file, always
|
|
|
|
|
|
|
|
`sops --extract '["..."]'`. The shape of the key path is safe to log;
|
|
|
|
|
|
|
|
the value isn't.
|
|
|
|
|
|
|
|
- **Stash tokens in `/dev/shm/<unique>` mode 600** and pass via filename,
|
|
|
|
|
|
|
|
not env. `shred -u` after use. Avoid `/tmp` if the system has `/dev/shm`.
|
|
|
|
|
|
|
|
- **If you must dump a secret to confirm wiring**, immediately rotate it
|
|
|
|
|
|
|
|
in the same commit train. Treat the dump as "this is now compromised".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Rotation chain pattern when a token is already exposed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
If a token was leaked in a prior step of the same session, chain two
|
|
|
|
|
|
|
|
rotations:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1. **T1**: replace the leaked value with a new one. The verification step
|
|
|
|
|
|
|
|
may also leak T1 (acceptable risk).
|
|
|
|
|
|
|
|
2. **T2**: replace T1 with a fresh value. Run this without any decrypt
|
|
|
|
|
|
|
|
step that emits to stdout. The new value lives only in the encrypted
|
|
|
|
|
|
|
|
file + the consumer's secret store (`~/.llmkeys`, the k8s Secret).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Example T2 path (Python, never decrypts to stdout):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
|
|
new_t2 = subprocess.check_output(["openssl","rand","-hex","32"]).decode().strip()
|
|
|
|
|
|
|
|
plaintext = subprocess.check_output(["sops","-d", path]).decode() # in-memory only
|
|
|
|
|
|
|
|
patched = re.sub(r'Bearer [a-f0-9]+(?=")', f"Bearer {new_t2}", plaintext)
|
|
|
|
|
|
|
|
# write patched via /dev/shm tmp + sops -e → write back
|
|
|
|
|
|
|
|
# DO NOT print(patched), DO NOT print(new_t2)
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
After T2 lands via GitOps + Flux, update consumers from the file stash
|
|
|
|
|
|
|
|
without echoing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Detection ideas (not blocking — telemetry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- Add a `bash` pre-hook in Claude harness that refuses obvious patterns
|
|
|
|
|
|
|
|
(`kubectl exec * env*`, `sops -d *.enc.yaml`). Filed as a follow-up.
|
|
|
|
|
|
|
|
- Run a periodic grep over `~/.claude/projects/*/sessions/*.jsonl` for
|
|
|
|
|
|
|
|
high-entropy strings matching `[a-f0-9]{32,}` to catch new leak surface.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- `gitea.d-ma.be/mathias/infra#39` — umbrella credential rotation issue with confirmed-leak roster.
|
|
|
|
|
|
|
|
- `gitea.d-ma.be/mathias/infra#44` — POSTGRES_PASSWORD rotation procedure (template for future incidents).
|