Phase 1 of the MCP Control Plane Web Console (#631) defines the model that future gated writes must pass through, and enables none of them. The read-only MVP (#426-#436) ships with no authentication; protection comes from network placement alone (#435). That is adequate while every route is a GET and inadequate the moment Phase 2 wires a write. This lands the authority first, so no write can later be added without something to check it against. webui/console_authz.py Identity sources (none / local-dev / access-proxy), a four-role matrix (viewer, operator, controller, admin), the privileged-action list, and a fail-closed authorize(). Every action maps to a task_key in task_capability_map, so the console cannot invent an authority the MCP layer does not already define. Roles are always server-side configuration, never a client assertion. Deny reasons are closed and enumerated; there is no implicit allow branch, and even an allow reports execution_enabled=false while ACTIVE_PHASE is 1. webui/console_redaction.py One redaction pass for API payloads, rendered HTML, logs, and audit records. Reuses gitea_audit.redact as the shared authority rather than forking it, then adds console patterns for keychain references, credential assignments, PEM private-key blocks, and JWTs. Never raises: an unredactable value degrades to the placeholder rather than being emitted raw. webui/console_audit.py Console-side audit records, which gitea_audit cannot supply: it records MCP mutations and carries no console actor, identity source, correlation id, or retention class, and an authorization denial is not a mutation at all. The two are additive and join on correlation.request_id. Records are redacted at build time, re-scanned at write time, and dropped rather than persisted if they still trip a detector. Retention is per-record; an unknown action is retained as privileged rather than standard. webui/app.py Attaches an authorization block to the existing preview and attempt routes and records the decision. The terminal outcome is unchanged - gated_actions still fails closed for every action - so this cannot loosen anything. Adds GET /api/console/security-model publishing the three policies as JSON. Probe authentication is deliberately declarative in this slice: probe_auth_required() reports operator intent and no route consults it. The documentation says so plainly and a regression test pins the not-enforced status, so wiring it in Phase 2 is a deliberate change rather than a silent one. An operator who sets the variable believing it protects a probe would be worse off than one who knows it does not. Tests: tests/test_webui_console_authz_audit.py - 75 passed, 93 subtests, covering each acceptance criterion and each test the issue requires (redaction units, default-deny for unauthenticated write stubs, audit record creation for a simulated privileged preview). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
170 lines
5.9 KiB
Python
170 lines
5.9 KiB
Python
"""Secret redaction policy for every console surface (#633).
|
|
|
|
The MVP already redacts MCP-side mutation records through ``gitea_audit``.
|
|
This module is the console-facing policy: one redaction pass applied to API
|
|
payloads, rendered HTML, log lines, and audit records *before* they leave the
|
|
server or reach persistent storage.
|
|
|
|
Design constraints:
|
|
|
|
- **Reuse, never fork.** ``gitea_audit.redact`` remains the authority for
|
|
secret-looking dict keys, ``Authorization`` material, and raw URLs. This
|
|
module runs that pass first and then applies console-specific patterns for
|
|
keychain references, key/value assignments, private-key blocks, and JWTs.
|
|
- **Never raises.** Redaction is a safety control; a malformed payload must
|
|
degrade to a redacted placeholder rather than propagate an exception.
|
|
- **Redact before persist.** ``webui.console_audit`` calls this module before
|
|
writing, so an unredacted record is never durable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
|
|
import gitea_audit
|
|
|
|
REDACTED = gitea_audit.REDACTED
|
|
|
|
# Console-specific patterns applied after the shared ``gitea_audit`` pass.
|
|
# Each keeps the identifying key so an operator can still tell *what* was
|
|
# removed, and replaces only the secret run itself.
|
|
_KEYCHAIN_REF = re.compile(r"(?i)\bkeychain:[\w.\-/@]+")
|
|
_KEYCHAIN_CMD = re.compile(
|
|
r"(?i)\bsecurity\s+find-(?:generic|internet)-password\b[^\n]*"
|
|
)
|
|
_ASSIGNMENT = re.compile(
|
|
r"(?i)\b(token|password|passwd|secret|api[_-]?key|access[_-]?key|"
|
|
r"client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)"
|
|
r"(\"[^\"]*\"|'[^']*'|\S+)"
|
|
)
|
|
_ENV_ASSIGNMENT = re.compile(
|
|
r"(?i)\b(GITEA_(?:TOKEN|PASS|PASSWORD)[A-Z0-9_]*)(\s*=\s*)"
|
|
r"(\"[^\"]*\"|'[^']*'|\S+)"
|
|
)
|
|
_PRIVATE_KEY_BLOCK = re.compile(
|
|
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
re.S,
|
|
)
|
|
_JWT = re.compile(
|
|
r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b"
|
|
)
|
|
|
|
# Shapes that mean a payload still carries a secret. ``scan_for_secrets`` uses
|
|
# these to assert a surface is clean.
|
|
_DETECTORS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
("keychain_reference", _KEYCHAIN_REF),
|
|
("keychain_command", _KEYCHAIN_CMD),
|
|
("credential_assignment", _ASSIGNMENT),
|
|
("credential_env_assignment", _ENV_ASSIGNMENT),
|
|
("private_key_block", _PRIVATE_KEY_BLOCK),
|
|
("json_web_token", _JWT),
|
|
("bearer_credential", re.compile(r"(?i)\b(?:bearer|basic)\s+\S{8,}")),
|
|
)
|
|
|
|
|
|
def _mask_assignment(match: re.Match[str]) -> str:
|
|
"""Keep the key and separator, replace the value."""
|
|
return f"{match.group(1)}{match.group(2)}{REDACTED}"
|
|
|
|
|
|
def redact_text(text: Any) -> Any:
|
|
"""Redact secret material from a single string.
|
|
|
|
Non-strings are returned unchanged so this is safe to map over mixed
|
|
payloads. Runs the shared ``gitea_audit`` pass first, then the
|
|
console-specific patterns.
|
|
"""
|
|
if not isinstance(text, str) or not text:
|
|
return text
|
|
try:
|
|
out = gitea_audit.redact(text)
|
|
if not isinstance(out, str): # defensive; redact() returns str for str
|
|
return REDACTED
|
|
out = _PRIVATE_KEY_BLOCK.sub(f"{REDACTED}_PRIVATE_KEY", out)
|
|
out = _ENV_ASSIGNMENT.sub(_mask_assignment, out)
|
|
out = _ASSIGNMENT.sub(_mask_assignment, out)
|
|
out = _KEYCHAIN_CMD.sub(f"{REDACTED}_KEYCHAIN_COMMAND", out)
|
|
out = _KEYCHAIN_REF.sub(f"{REDACTED}_KEYCHAIN_REF", out)
|
|
out = _JWT.sub(f"{REDACTED}_JWT", out)
|
|
return out
|
|
except Exception:
|
|
# Fail closed: an unredactable string is dropped rather than emitted raw.
|
|
return REDACTED
|
|
|
|
|
|
def redact_payload(value: Any) -> Any:
|
|
"""Recursively redact a JSON-able payload for any console surface.
|
|
|
|
Secret-looking dict keys are replaced wholesale by the shared
|
|
``gitea_audit`` policy; every remaining string is run through
|
|
:func:`redact_text`.
|
|
"""
|
|
try:
|
|
shared = gitea_audit.redact(value)
|
|
except Exception:
|
|
return REDACTED
|
|
return _walk(shared)
|
|
|
|
|
|
def _walk(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {k: _walk(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_walk(v) for v in value]
|
|
if isinstance(value, str):
|
|
return redact_text(value)
|
|
return value
|
|
|
|
|
|
def scan_for_secrets(value: Any) -> list[str]:
|
|
"""Return detector names that still match *value* after serialization.
|
|
|
|
Used to assert an outbound payload or rendered page is clean. An empty
|
|
list means no known secret shape was found. Already-redacted hits are not
|
|
findings.
|
|
"""
|
|
if isinstance(value, str):
|
|
text = value
|
|
else:
|
|
try:
|
|
text = json.dumps(value, default=str)
|
|
except Exception:
|
|
text = str(value)
|
|
findings: list[str] = []
|
|
for name, pattern in _DETECTORS:
|
|
for match in pattern.finditer(text):
|
|
if REDACTED in match.group(0):
|
|
continue
|
|
findings.append(name)
|
|
break
|
|
return findings
|
|
|
|
|
|
def redaction_policy() -> dict[str, Any]:
|
|
"""Machine-readable statement of the redaction rules (never secrets)."""
|
|
return {
|
|
"policy_version": 1,
|
|
"applies_to": [
|
|
"json_api_responses",
|
|
"rendered_html",
|
|
"server_logs",
|
|
"audit_records",
|
|
],
|
|
"ordering": "shared gitea_audit pass, then console patterns",
|
|
"redact_before_persist": True,
|
|
"shared_rules": {
|
|
"source": "gitea_audit.redact",
|
|
"secret_key_hints": list(gitea_audit._SECRET_KEY_HINTS),
|
|
"secret_value_prefixes": list(gitea_audit._SECRET_VALUE_PREFIXES),
|
|
"urls": "credentials, secret query parameters, and real hosts redacted",
|
|
},
|
|
"console_rules": [
|
|
{"name": name, "pattern": pattern.pattern}
|
|
for name, pattern in _DETECTORS
|
|
],
|
|
"placeholder": REDACTED,
|
|
"failure_mode": "fail closed — unredactable values become the placeholder",
|
|
}
|