docs/security/console-hardening-plan.md
Working document for branch
security/console-defense-in-depth. Targets the audit finding in the 2026-04-18 advisory (EAV credential disclosure viaconsole_redacted_columnsshape gap, plus the meta-insight about safety checks at a layer that can't be bypassed by adding new data).
Make the Woods Console MCP server safe to re-enable by default, without requiring operators to enumerate every sensitive column, EAV key, or credential-bearing table in their schema. Operator-supplied allow/deny lists remain available for precision, but are not the last line of defense.
| # | Scenario | Example | Currently caught by |
|---|---|---|---|
| T1 | Typed credential column | users.crypted_password leaks | console_redacted_columns ✓ |
| T2 | EAV credential row | authorizations.key='stripe_access_token' leaks value | console_redacted_key_values (shipped 24690a5) ✓ |
| T3 | Newly-added EAV key the operator hasn't registered yet | A PR adds key='stripe_refresh_token' but forgets the redaction config | No coverage today |
| T4 | JSONB / serialized column carrying secrets | users.settings = {"stripe_key": "sk_live_..."} | No coverage (column name settings isn't in the list) |
| T5 | Associated record pulled via console_data_snapshot | Nested serialization of a credential-bearing model | Partial (column redaction walks nested hashes, but same shape gap) |
| T6 | Raw SQL bypass | SELECT * FROM authorizations | SqlValidator doesn't know about credential tables |
| T7 | Log / audit output containing secrets | console_sql failure message echoes the bound parameter | No coverage |
The audit's architectural insight: all current defenses key off identity (column name, model name, row's key column). Every one of those is an allow- list that needs updating when new data appears. A durable defense has to key off content shape — a thing that looks like a Stripe secret is redacted regardless of which column or row it arrived in.
Redaction happens in this order on every response before it leaves the Console server:
Woods.configuration.console_mcp_enabled, default false.exe/woods-console-mcp, exe/woods-console,
RackMiddleware#call) check this flag and refuse unless explicitly set.Woods.configuration.console_blocked_tables — array of table
names (case-insensitive).console_sql: parse the SQL for FROM <name> / JOIN <name> tokens
(word-boundary-anchored, quote-aware) and reject when any token matches.model.table_name via the model registry;
reject the tool call when the resolved table is on the list.New module: Woods::Console::CredentialScanner.
Runs over the entire serialized response tree (strings, nested hashes, nested arrays, including positional row arrays from sql/pluck).
Each string value is scanned with a library of high-specificity regexes
targeting well-known credential formats. Matches are replaced with
[REDACTED], preserving surrounding non-secret context where possible.
Initial pattern library (all word-boundary-anchored):
| Pattern name | Regex | Blast target |
|---|---|---|
stripe_secret_key | `\b(sk | rk)_(live |
stripe_publishable_key | `\bpk_(live | test)_[A-Za-z0-9]{24,}\b` |
stripe_webhook_secret | \bwhsec_[A-Za-z0-9]{24,}\b | Stripe webhooks |
aws_access_key_id | `\b(AKIA | ASIA)[0-9A-Z]{16}\b` |
github_token | \bgh[pousr]_[A-Za-z0-9]{36,}\b | GitHub |
google_oauth_token | \bya29\.[A-Za-z0-9_-]{20,}\b | |
jwt_token | \beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b | Any JWT |
pem_private_key_block | -----BEGIN [A-Z ]*PRIVATE KEY----- | Any asymmetric key |
slack_bot_token | \bxox[abpr]-[A-Za-z0-9-]{10,}\b | Slack |
generic_high_entropy | (opt-in, conservative) long alphanumeric tokens adjacent to labels like password, secret, api_key | Catch-all |
Default enabled (console_credential_scanning_enabled = true). Operators
can disable per-pattern via console_disabled_scanner_patterns.
Emits a structured "pattern fired" log entry per response so operators can audit whether Layer 2 ever fires in production (should be rare in steady state — if it fires, the column/EAV configs are incomplete).
console_redacted_columns, console_redacted_key_values.SafeContext transaction rollback, SqlValidator DML/DDL rejection.Woods::Observability::StructuredLogger:
{ layer:, tool:, pattern: (Layer 2 only), count:, table: (Layer 1/3 only) }._meta.redactions field with a per-layer count
summary so the calling agent (and operators inspecting traffic) can
verify defenses are active.console_mcp_enabled = true AND
credential_scanning_enabled = false AND both column/EAV lists are empty.
"You've enabled the Console MCP with every redaction layer off — this is
almost certainly a configuration mistake."spec/console/credential_scanner_spec.rb
spec/console/blocked_tables_spec.rb
FROM, nested JOIN, quoted identifiers (`authorizations`,
"authorizations"), case-insensitive match.Authorization resolves to authorizations, blocked.spec/console/feature_gate_spec.rb
console_mcp_enabled = false: every entry point refuses.console_mcp_enabled = true: tools dispatch normally.spec/console/integration/credential_redaction_integration_spec.rb
authorizations table, a users table with a
credential column, a settings JSONB-equivalent column carrying
sk_live_* values.console_sql, console_query, console_pluck,
console_sample, console_recent, console_find): assert the
rendered response contains zero raw credential-shaped tokens.acct_*, user
emails) flows through untouched._meta.redactions counts match the expected number of
redactions per call.spec/console/integration/layer_composition_spec.rb
console_redacted_key_values (shipped in 24690a5)
continue to pass.authorizations table:
console_sql sql: "SELECT id, \key`, value FROM authorizations"`console_query model: "Authorization", select: ["id", "key", "value"]console_pluck model: "Authorization", columns: ["key", "value"]console_sample model: "Authorization", limit: 5sk_test_* and sk_live_* value in the output is [REDACTED].acct_* values (non-secret Stripe account IDs) flow through.authorizations to console_blocked_tables causes all four
tools to reject the call with a structured error._meta.redactions field is present and non-zero on the
non-blocked calls.Ordered so each commit is independently reviewable:
docs/security/console-hardening-plan.md — this file.Woods::Console::CredentialScanner + unit specs.console_blocked_tables + Layer 1 enforcement + unit specs.console_mcp_enabled feature gate + unit specs.apply_redaction runs Layer 2 after Layer 3; Layer 1
fires before any tool dispatch._meta.redactions envelope.exe/woods-console-mcp, exe/woods-console,
and RackMiddleware#call check console_mcp_enabled instead of being
hard-coded to refuse.docs/CONSOLE_MCP_SETUP.md update — operator-facing
summary of the five layers and how to configure each.Before merging this branch:
_meta.redactions reports the expected
layer counts.docs/CONSOLE_MCP_SETUP.md updated with the new configuration surface.89db38f is replaced by the
console_mcp_enabled gate (not deleted — formalized).Executed against the woods-r8-smoke container (Rails 8.0.2, SQLite, woods
gem mounted from the security/console-integration-clean branch at
/woods-gem), driving /mcp/console over HTTP with a session-aware curl
harness (/tmp/woods_mcp_test/{client.sh,run_matrix.sh,analyze.py}). The
51-case matrix exercises every defense layer plus EvalGuard, the renderer,
and operational tools; analyzer reports 49/51 assertions passed, with
the two non-passes being overly-narrow assertion-script matches rather than
real security regressions (notes below).
Layer 0 — Feature gate. With console_mcp_enabled = false, the
/mcp/console endpoint returns HTTP 410 and body
{"error":"woods_console_disabled","message":"Woods Console MCP is disabled…"}
before any MCP handshake. Re-enabling and restarting returns a clean
initialize response with serverInfo.name = "woods-console",
version = "1.2.0".
Layer 1 — Blocked tables. console_blocked_tables = %w[users audit.users].
All 11 probes rejected with structured errors naming the table:
console_find/sample/count/schema on User → blocked at the
model-based gate.console_sql variants on users: direct FROM, explicit JOIN,
comma-join, subquery, quoted identifier ("users"), CTE, and UNION
— all rejected at the SQL-token gate.Micropost.find(1), Credential.count = 41) succeed.Layer 2 — Credential-shape content scanner. Against the 41-row
credentials seed fixture (22 detected patterns + adversarial fixtures +
17 documented gaps):
sk-ant-…,
OpenAI project key, PEM private-key marker, JWT signature) redacted to
[REDACTED]; zero fingerprint leaks in the rendered response.stripe_secret inside
a narrative notes field), multi-secret (stripe_secret + aws_key in
one field), and JSON-blob secret (both keys inside a serialized JSON
string) — each scanner walks the nested string contents and redacts
without touching the surrounding structure.console_pluck full scan over
columns: [id, provider, value, notes], limit: 50 returns zero
fingerprint leaks from the full detected-pattern library.POSTMARK_API_TEST), Shippo (shippo_test_abcdef), Resend
(re_123456789_…). These are the untested-shape markers listed in
Coverage Gaps — current behavior is deliberate pass-through, not a
regression.Layer 3 — Column/EAV redaction. Exercised implicitly through Layer 2
composition (the value column is not in console_redacted_columns for
the smoke app; the scanner is the sole line of defense in this fixture).
Direct Layer 3 coverage is in credential_redaction_integration_spec.
Layer 4 — SQL validator + SafeContext. All 9 destructive-shape probes rejected with specific error messages:
| Attempt | Rejection |
|---|---|
INSERT INTO credentials … | DML not permitted |
UPDATE credentials SET value = … | DML not permitted |
DELETE FROM credentials | DML not permitted |
DROP TABLE credentials | DDL not permitted |
ATTACH DATABASE "/tmp/x.db" … | ATTACH not permitted |
PRAGMA table_info(credentials) | PRAGMA not permitted |
VACUUM | VACUUM not permitted |
/* trick */ DELETE /* */ FROM credentials | Comment-stripped; DML rejected |
SELECT 1; DELETE FROM credentials | Multi-statement rejected |
Positive SELECT id, provider, value FROM credentials ORDER BY id LIMIT 5
succeeds and the value column is fully redacted by Layer 2 in the render.
EvalGuard. All 7 escape-attempt probes refused with guard messages
naming the denied call chain (Rails.application.credentials.secret_key_base,
ENV["SECRET_KEY_BASE"], instance_variable_get(:@credentials),
File.read("config/credentials.yml.enc"), File.open(…).read,
ENV.send(:[], …), Object.const_get("ENV")[…]). Benign 1 + 1
returns **value:** 2. Zero credential fingerprints in any refusal
message.
Renderer. console_query and console_aggregate return rendered
payloads; the "N items" collapse bug from PR #34 does not reproduce.
Observability. docker logs woods-r8-smoke-app stderr shows
structured console.table_gate.rejected and console.credential_scan.hits
events on every gated call, carrying tool, model/table, and pattern-hit
counts.
eval_file_read: guard response is
"payload reads credential file via File.read" — correct refusal, but
the assertion script scanned for keywords like denied/refuse/unsafe
rather than the actual File.read / credential file phrasing. The
denial is working; the assertion hint list is too narrow.render_aggregate: tool invocation with
group_by: "provider", function: "count" returned the scalar
**value:** 41 (total count) rather than a per-provider table. This is
an input-shape question about the aggregate tool, not a renderer
regression — the "N items" collapse that the assertion guards against
did not occur.lib/woods/console/rack_middleware.rb had a latent namespace-collision
bug: inside Woods::Console::RackMiddleware, the unqualified reference
MCP::Server::Transports::StreamableHTTPTransport resolved to
Woods::MCP::Server (the Index Server module) after
Rails.application.eager_load! populated the constant table, raising
NameError (uninitialized constant Woods::MCP::Server::Transports) on the
first /mcp/console request in any eager-loaded Rails app. Fixed with a
:: prefix to force top-level resolution. This fix is needed on
main as well — filed as a follow-up commit alongside the hardening
merge.
bundle exec rake spec green prior to
live run).spec/console/.users across model
and SQL entry points (11/11 variants rejected).console_mcp_enabled = false.1.2.0 / Unreleased).docs/CONSOLE_MCP_SETUP.md documents the configuration surface.console_mcp_enabled gate.