Skip to content

fix(config): harden RAFTER_API_KEY handling — 0600 perms, redaction, wire backend.apiKey (sable-q9to) - #173

Merged
Rome-1 merged 2 commits into
mainfrom
sable-q9to-apikey-hardening
Jun 20, 2026
Merged

fix(config): harden RAFTER_API_KEY handling — 0600 perms, redaction, wire backend.apiKey (sable-q9to)#173
Rome-1 merged 2 commits into
mainfrom
sable-q9to-apikey-hardening

Conversation

@Rome-1

@Rome-1 Rome-1 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes sable-q9to. Three credential-handling gaps in RAFTER_API_KEY handling (both Node + Python). The intended env-var / --api-key path was already solid (HTTPS, x-api-key header only, not logged); these are the storage/display/dead-path gaps surfaced during the secbolt credential spike.

Gaps fixed

  1. Plaintext, world-readable. ConfigManager.save() wrote ~/.rafter/config.json with the default umask (typically 0644). Now writes 0600 (dir 0700) and chmods an existing looser-perm file on the next write.
  2. Unredacted echo. config show/get, the config set confirmation, and the MCP get_config tool + rafter://config / rafter://policy resources serialized the full config verbatim — handing any stored key to the terminal or MCP client. Added redactConfigSecrets/maskSecretValue (mask values under keys matching api_?key|token|secret|password|credentialabcd****), applied at every render path. Pure — the stored config is never mutated.
  3. Dead credential path. backend.apiKey existed in the schema but resolveKey never read it. Wired as the lowest-precedence source: --api-key flag → RAFTER_API_KEY env → global config backend.apiKey.

Security design

  • Why 0600 plaintext, not encryption: it's a bearer token (presented verbatim → can't hash); encrypting a local CLI config needs a KEK the CLI reads unattended, sitting right next to the ciphertext ("keys next to data"). So the boundary is file perms + display redaction — same as gh/aws/npm.
  • Trust boundary (verified): the config fallback is read only from the global ~/.rafter/config.json via load()loadWithPolicy() never merges backend.*, so a hostile project-local .rafter.yml cannot inject an API key that redirects scans to an attacker's account. Confirmed empirically (a planted .rafter.yml backend.apiKey does not appear in config show and resolveKey refuses it).
  • Ran rafter-secure-design before coding (decisions recorded on the bead); refuse-list clean (no homegrown crypto, opaque bearer token, revocation is backend-side).

Tests

New config-secret-handling suites in both languages: redaction helpers (mask/leaf-match/no-mutation), save() writes 0600 on fresh and tightens an existing 0644 file, and resolveKey precedence flag > env > config. Node 67 / Python 19 config tests pass; broader config + mcp + api suites green. Secrets scan clean on all changed files. rafter run (remote SAST) not run — no RAFTER_API_KEY in this env; recommend CI.

Notes

  • Node key is backend.apiKey; Python is backend.api_key (dataclass snake_case) — same value, per-language path; the redaction regex (api_?key) covers both.
  • CLI_SPEC.md documents the resolution order, redaction, 0600, and the global-only trust boundary.

🤖 Generated with Claude Code

Rome-1 and others added 2 commits June 18, 2026 00:27
…ackend.apiKey (sable-q9to)

Three credential-handling gaps (both Node + Python; intended env/flag path was
already fine):

1. Plaintext, world-readable. ConfigManager.save() wrote ~/.rafter/config.json
   with the default umask (typically 0644). Now writes 0600 (dir 0700) and
   chmods an existing looser file on the next write — it can hold a backend key.

2. Unredacted echo. `config show`/`get`, the `config set` confirmation, and the
   MCP get_config tool + rafter://config / rafter://policy resources serialized
   the full config verbatim, handing any stored key to the terminal / MCP client.
   Added redactConfigSecrets / maskSecretValue (mask values under keys matching
   api_?key|token|secret|password|credential → "abcd****"); applied at every
   render path. Pure — the stored config is never mutated.

3. Dead credential path. backend.apiKey existed in the schema but resolveKey
   never read it. Wired it as the LOWEST-precedence source: --api-key flag >
   RAFTER_API_KEY env > global config backend.apiKey. Read only from the global
   ~/.rafter/config.json (load(), never loadWithPolicy/.rafter.yml — verified it
   does not merge backend.*), so a hostile repo cannot inject a key that
   redirects scans to another account.

Secure-design pass recorded on the bead: it's a bearer token, so 0600 + display
redaction is the right boundary (encrypting a local config just moves a KEK next
to the ciphertext); precedence mirrors gh/aws/npm.

Tests (both suites): redaction helpers, 0600 on fresh + tightened-on-rewrite,
and resolveKey precedence flag>env>config. Node 67 / Python 19 config tests
pass; broader config+mcp+api suites green. CLI_SPEC.md documents the resolution
order, redaction, perms, and the global-only trust boundary.

Note: Node key is backend.apiKey, Python is backend.api_key (dataclass
snake_case) — same value, per-language path; redaction regex covers both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…le-q9to)

The redaction work added redactConfigSecrets/isSecretConfigKey/maskSecretValue
to config-manager, which server.ts now imports — but the vi.mock in
mcp-server-integration.test.ts only stubbed ConfigManager, so those imports
resolved to undefined and 4 get_config/resource tests threw. Spread the real
module (keeping the pure redaction helpers real) and override only ConfigManager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Rome-1
Rome-1 merged commit 809133e into main Jun 20, 2026
@Rome-1
Rome-1 deleted the sable-q9to-apikey-hardening branch June 20, 2026 00:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant