Promote railiance-backup-offsite-lane to active/resolvable after capabilities-safe re-verify. Add catalog risk=high, agent read-boundary (exit 7 + OpenBao policy companion), EXPOSED taint via warden taint, and close WP-0026.
261 lines
12 KiB
Markdown
261 lines
12 KiB
Markdown
---
|
|
id: WARDEN-WP-0026
|
|
type: workplan
|
|
title: "Credential disclosure hygiene + rotation guidance (Strand A)"
|
|
domain: infotech
|
|
repo: ops-warden
|
|
status: finished
|
|
owner: codex
|
|
topic_slug: custodian
|
|
planning_priority: high
|
|
planning_order: 26
|
|
created: "2026-07-16"
|
|
updated: "2026-07-16"
|
|
state_hub_workstream_id: "1adb34af-d5f8-42c5-89b4-44593995a281"
|
|
---
|
|
|
|
# Credential disclosure hygiene + rotation guidance (Strand A)
|
|
|
|
## Origin
|
|
|
|
Follow-up to a credential-disclosure incident on 2026-07-16 while verifying
|
|
`CCR-2026-0004` (railiance offsite backup lane). A negative policy test was run
|
|
as `BAO_TOKEN=$(bao token create -policy=default -field=token) bao kv get <path>`.
|
|
The `token create` was denied (workload role lacks it), `BAO_TOKEN` fell back to
|
|
the caller's privileged login token, and `bao kv get` printed all three field
|
|
values (`NC_WEBDAV_TOKEN`, `NC_WEBDAV_URL`, `AGE_PRIVATE_KEY`) into an agent
|
|
session transcript. Buildup mode — exposure accepted, learnings captured.
|
|
|
|
**Root causes:** (1) a deny-test that read the secret *data* path at all;
|
|
(2) a silent privileged-token fallback; (3) the read landed in a logged context.
|
|
|
|
This is **Strand A** (disclosure hygiene). Tamper-resistant policy governance and
|
|
one-command mass rotation/lockdown (Strand B) are deliberately **deferred** — see
|
|
"Out of scope" below.
|
|
|
|
## Goal
|
|
|
|
Make accidental secret disclosure structurally hard, and make ops-warden the
|
|
authoritative source for **how each secret is rotated or re-established** —
|
|
advisory knowledge held next to the routing catalog, not in OpenBao.
|
|
|
|
## Design guardrails (binding on acceptance)
|
|
|
|
- **Verification never reads secret data.** Use `bao token capabilities` (allow/deny)
|
|
instead of `kv get` for positive/negative lane tests.
|
|
- **Masking is defense-in-depth, not a boundary.** Any display filter is a wrapper
|
|
convenience; raw `bao kv get <path>` remains the documented anti-pattern.
|
|
- **Rotation guidance covers both `rotate` (provider re-mint) and `re-establish`
|
|
(regenerate from source, e.g. a new age keypair).**
|
|
- **Coverage gate:** every `active` (and newly promoted) catalog lane must carry
|
|
rotation guidance, enforced by a scorecard check.
|
|
|
|
## Out of scope (Strand B — deferred, not built here)
|
|
|
|
- Executable one-command rotation of all tainted secrets.
|
|
- Graded lockdown / break-glass seal + re-key with a designed trust-root.
|
|
- Policy time-travel / policy-as-code reconcile-to-past-commit. (Drift *detection*
|
|
may be revisited separately; the reconcile machinery is out.)
|
|
|
|
## Task: Capabilities-based lane verification
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T01
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "9329e72d-c07c-41ce-88ce-e8602eb72c43"
|
|
```
|
|
|
|
Done 2026-07-16: canonical capabilities-safe verification pattern added to
|
|
`wiki/playbooks/catalog-lane-promotion.md` (fleet promotion checklist criterion 8
|
|
+ dedicated section), and applied to the `railiance-backup-offsite-lane` and
|
|
`forgejo-admin-api-token` playbook verify sections. Positive/negative proven via
|
|
`bao token capabilities` against the KV v2 data path — never `bao kv get`; the
|
|
denied `default` token-create is documented as a pass, not a fallback trigger.
|
|
Live CCR-2026-0004 re-verify carried under T07.
|
|
|
|
Replace secret-reading verify flows with capability checks. Positive test:
|
|
approved identity has `read` on the KV data path. Negative test: a `default`-only
|
|
identity is `deny`. Both via `bao token capabilities <token> <path>` (or the
|
|
self endpoint) — **never** `bao kv get`. Update
|
|
`wiki/playbooks/*lane*.md` verify sections and any lane-verify helper.
|
|
|
|
**Done when:** the documented and tooled verify path for any lane proves
|
|
allow/deny without reading a secret value, and the CCR-2026-0004 re-verify uses
|
|
it (see T07).
|
|
|
|
## Task: Safe access transport (no stdout values)
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T02
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "3f28c573-8e58-4851-8aa0-925f9367f266"
|
|
```
|
|
|
|
Done 2026-07-16: sanctioned transports added to `warden access` so a value never
|
|
lands on stdout — `--out FILE` (mode-0600 file), `--exec` (child env, pre-existing),
|
|
and `--wrap` (single-use OpenBao response-wrapping token via `bao kv get -wrap-ttl`,
|
|
caller `bao unwrap`s in their own context). Raw `--fetch` to a non-TTY stdout is now
|
|
refused (exit 6) unless `--unsafe-stdout` is passed (interactive human only).
|
|
`proxy_fetch_to_file`/`proxy_fetch_wrapped`/`build_wrapped_fetch` in `proxy.py`; tests
|
|
in `tests/test_proxy.py`. Anti-pattern + transports documented fleet-wide in
|
|
`.claude/rules/credential-routing.md` and `wiki/OperatorAccessAssist.md` (G2).
|
|
|
|
`warden access` / fetch paths must emit values only into an env var, a file, or
|
|
a **response-wrapping token** (`bao … -wrap-ttl`), never a stdout table. Add a
|
|
wrapping-token transport for values that must move between processes. Record in
|
|
canon (`credential-routing` rules) that raw `bao kv get <path>` (full table) is
|
|
the anti-pattern; the sanctioned path is `warden access … --field <F>` into env.
|
|
|
|
**Done when:** the sanctioned fetch path cannot print a value to a terminal, and
|
|
the anti-pattern is documented fleet-wide.
|
|
|
|
## Task: Masking display filter (defense-in-depth)
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T03
|
|
status: done
|
|
priority: medium
|
|
state_hub_task_id: "21ab08d5-7782-4567-a08d-980211dd7851"
|
|
```
|
|
|
|
Done 2026-07-16: `warden/mask.py` (`fingerprint`/`mask_value` — presence, length,
|
|
8-char sha256 prefix; never the value) + `proxy_fetch_fingerprint` and a
|
|
`warden access … --fingerprint` masked status view (bypasses the stdout guard
|
|
because it emits no value). Lets two parties compare sha256 prefixes to confirm a
|
|
shared value (e.g. rotation landed) without disclosure. Explicitly labelled
|
|
defense-in-depth — raw `bao kv get` bypasses it — in `wiki/OperatorAccessAssist.md`
|
|
and the module docstring. Tests in `tests/test_mask.py` + a CLI test in
|
|
`tests/test_proxy.py`.
|
|
|
|
In the warden wrapper, mask KV data values by default when any listing/status is
|
|
shown — display presence, length, and a short non-reversible hash instead of the
|
|
value. Explicitly labelled as defense-in-depth (raw bao bypasses it).
|
|
|
|
**Done when:** wrapper-mediated output never shows a raw KV value, with the
|
|
limitation documented.
|
|
|
|
## Task: Agent read-boundary on high-risk lanes
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T04
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "f95d4381-f995-4c9d-b438-70b4f0ed90c5"
|
|
```
|
|
|
|
Done 2026-07-16: Catalog `risk: high|standard` (default standard). High-risk:
|
|
`railiance-backup-offsite-lane`, `forgejo-admin-api-token`, `openrouter-llm-connect`.
|
|
OpenBao policy `agent-high-risk-boundary` (railiance-platform + live write) grants
|
|
metadata/capabilities only and **denies** data-read on those paths — verified with
|
|
minted agent token (data=deny, metadata=read). `warden access` with
|
|
`WARDEN_AGENT_ID` set refuses raw value stream on high-risk lanes (exit 7);
|
|
`--out`/`--exec`/`--wrap`/`--fingerprint` remain. Playbook:
|
|
`wiki/playbooks/agent-read-boundary.md`. Tests in `tests/test_routing.py` +
|
|
`tests/test_proxy.py`.
|
|
|
|
**Repo: railiance-platform (OpenBao policy/roles).** Agent identities receive
|
|
`capabilities`/metadata and wrapping tokens on high-risk lanes, not raw data
|
|
reads. Align with the existing credential-routing rule ("ops-warden proxies reads
|
|
as the caller and must not retain values"). Classify which lanes are high-risk
|
|
(recovery escrow like `AGE_PRIVATE_KEY`, upload tokens).
|
|
|
|
**Done when:** at least the high-risk lanes deny raw data reads to agent roles
|
|
while still allowing wrapped/proxied access, verified via capabilities checks.
|
|
|
|
## Task: EXPOSED taint convention
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T05
|
|
status: done
|
|
priority: medium
|
|
state_hub_task_id: "e989f8f0-930e-4d2f-9037-221e04f34199"
|
|
```
|
|
|
|
Done 2026-07-16: Convention documented in `wiki/playbooks/exposed-taint.md`
|
|
(`exposed_at`, `exposed_version`, `exposed_reason`, `exposed_ref` on KV v2
|
|
custom_metadata). First worked mark applied to
|
|
`platform/workloads/railiance/backup/offsite-lane` version 2 (disclosure
|
|
incident). `warden taint <id>` (+ `--json`) reports taint via metadata-only
|
|
`bao kv metadata get` — never secret data (`src/warden/taint.py`). Tests in
|
|
`tests/test_taint.py`.
|
|
|
|
**Repo: railiance-platform (OpenBao) + ops-warden surface.** Establish a KV v2
|
|
`custom_metadata` convention to mark a tainted secret: `exposed_at=<datetime>`
|
|
and the affected `version`. Identify semi-automatic candidates from the OpenBao
|
|
audit log (reads from agent/shared contexts). `warden` surfaces taint status for
|
|
a lane (advisory; no auto-rotation here).
|
|
|
|
**Done when:** a secret can be marked EXPOSED via a documented convention and
|
|
`warden` reports whether a lane is currently tainted.
|
|
|
|
## Task: Rotation / re-establishment guidance registry
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T06
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "b8a43aee-18c7-46cd-9cfe-1f73234746d3"
|
|
```
|
|
|
|
Done 2026-07-16: `rotation:` block (method rotate|re-establish, ordered steps,
|
|
owner, automatable) added to the routing model/parser (`RotationGuide`,
|
|
`RouteEntry.rotation`, `vends_secret`), screened for secret material in a prose-safe
|
|
mode. `warden rotate-guide <id>` (human + `--json`) surfaces the guidance; `warden
|
|
route show --json` carries `has_rotation` + `rotation`. Coverage enforced by the new
|
|
`catalog_rotation_coverage` scorecard check (every active secret-vending lane must
|
|
have a block) and promotion checklist criterion 9. Rotation blocks authored for all
|
|
7 active vending lanes + the draft railiance-backup lane (re-establish example: age
|
|
keypair regen + re-encrypt). Tests in `tests/test_routing.py`. Also fixed a
|
|
pre-existing keyword collision (bare `npm` on the forgejo-admin lane → `forgejo-npm`)
|
|
so "npm token" routes to the generic lane again.
|
|
|
|
Give every catalog lane **structured-but-advisory** renewal guidance, held in the
|
|
ops-warden registry (not in OpenBao). Add a `rotation:` block per catalog entry
|
|
capturing: `method` (rotate | re-establish), ordered `steps` (provider re-mint /
|
|
keygen / OpenBao write / re-encrypt-and-reupload where relevant), `owner`, and
|
|
`automatable` (bool, for future Strand-B). Surface via `warden rotate-guide <id>`
|
|
(and/or `warden route … --rotate`). Add a **scorecard coverage check**: every
|
|
`active` lane must have a `rotation:` block; flag any that don't.
|
|
|
|
**Done when:** `warden rotate-guide <id>` returns actionable renewal steps for
|
|
every active lane, and the scorecard fails if any active lane lacks guidance.
|
|
|
|
## Task: Incident lessons + first worked lane (CCR-2026-0004)
|
|
|
|
```task
|
|
id: WARDEN-WP-0026-T07
|
|
status: done
|
|
priority: medium
|
|
state_hub_task_id: "9944f46d-3706-43a4-9300-7f63bf87c9ff"
|
|
```
|
|
|
|
Done 2026-07-16: Lessons note present. Capabilities-safe live re-verify on
|
|
`bao.coulomb.social`: lane-policy token → `read` on data path; default + agent
|
|
boundary → `deny`; field keys present (no values printed). Catalog promoted
|
|
`draft`→`active`, `fetch_command` pinned to `NC_WEBDAV_TOKEN` (no placeholders)
|
|
so `resolvable: true`; `risk: high`; rotation guidance + EXPOSED taint on v2.
|
|
CCR-2026-0004 evidence + `access_frontdoor.resolvable: true` / `readiness: ready`.
|
|
Playbook + `wiki/CredentialRouting.md` updated. Operator may still rotate the
|
|
exposed values optionally (buildup) — not a promotion blocker.
|
|
|
|
Write a short lessons-learned note (buildup context; exposure accepted; the three
|
|
root causes). Apply T01 + T06 to `CCR-2026-0004` as the first worked lane:
|
|
re-verify it the capabilities-safe way so it can finally promote to
|
|
`resolvable: true` (unblocking `RAILIANCE-WP-0015`), and ensure its `rotation:`
|
|
block (rotate Nextcloud token; re-establish age keypair + re-encrypt artifacts)
|
|
is present. **Rotation of the exposed values is the operator's optional call, not
|
|
a blocker** (buildup).
|
|
|
|
**Done when:** the lessons note exists, CCR-2026-0004 has capabilities-based
|
|
verify + rotation guidance, and its promotion path is unblocked.
|
|
|
|
## References
|
|
|
|
- `CCR-2026-0004-railiance-backup-offsite-lane.yaml` (railiance-platform)
|
|
- `wiki/playbooks/railiance-backup-offsite-lane.md`
|
|
- `.claude/rules/credential-routing.md` (the-custodian, fleet-inlined)
|
|
- `RAILIANCE-WP-0015` (railiance-apps) — cnpg backup coverage, gated on CCR-2026-0004
|
|
- Strand B (deferred): tamper-resistant governance, one-command rotation/lockdown,
|
|
policy time-travel — capture separately if/when justified.
|