fix(mcp): remediate B8, B6/B11, and B15 break-glass restart blockers (#664)

- B8: Correct redaction boundary for GITEA_TOKEN= and URI userinfo without destroying adjacent audit evidence or benign sec- text
- B6/B11: Remove false restart execution claims from default executor when GITEA_SANCTIONED_RESTART_HOOK is non-empty
- B15: Document deployable production grant set (runtime.break_glass_restart and gitea.issue.create) for prgs-controller
- Preserve B13, B1, B14 and previously accepted corrections
This commit is contained in:
2026-07-29 01:23:10 -04:00
parent c67f39b40e
commit e423dd5870
4 changed files with 163 additions and 36 deletions
+22 -15
View File
@@ -76,17 +76,16 @@ _BARE_SECRET_PATTERN = re.compile(
r')\b'
)
# Key/value credentials embedded in free text (password=..., api_key: ..., etc.).
# Value group accepts quoted strings, Authorization scheme + credential
# (Bearer/Basic/Token + token), or a single non-space run.
# Key/value credentials embedded in free text (password=..., api_key: ..., GITEA_TOKEN=..., etc.).
# Group 1 captures the key name (e.g. GITEA_TOKEN, password, api_key).
# Group 2 captures delimiter/whitespace (=, : ).
# Group 3 captures the secret value, stopping at whitespace or non-secret delimiters (&, ;, ,, quotes, closing brackets).
_ASSIGNMENT_SECRET_PATTERN = re.compile(
r'(?i)\b('
r'token|password|passwd|pwd|secret|api[_-]?key|access[_-]?key|'
r'client[_-]?secret|private[_-]?key|authorization|credential'
r')\b(\s*[:=]\s*)('
r'(?i)\b([A-Za-z0-9_]*?(?:token|password|passwd|pwd|secret|api[_-]?key|access[_-]?key|'
r'client[_-]?secret|private[_-]?key|authorization|credential))\b(\s*[:=]\s*)('
r'"[^"]*"|\'[^\']*\'|'
r'(?:Bearer|Basic|Token)\s+\S+|'
r'\S+'
r'(?:Bearer|Basic|Token)\s+[^\s;&,"\'\)\}\]\>]+|'
r'[^\s;&,"\'\)\}\]\>]+'
r')'
)
@@ -120,7 +119,8 @@ def redact_urls(text: str) -> str:
if not isinstance(text, str) or not text:
return text
url_pattern = re.compile(r'(https?://[^\s)>\]}]+)', re.IGNORECASE)
# Match any URI scheme (http, https, postgres, mysql, mongodb, redis, etc.)
url_pattern = re.compile(r'([a-z0-9\+\.\-]+://[^\s)>\]}]+)', re.IGNORECASE)
def replace_url(match):
url_str = match.group(1)
@@ -134,11 +134,11 @@ def redact_urls(text: str) -> str:
is_synthetic = True
break
if is_synthetic:
# Rebuild synthetic URL to redact any credentials or query secrets
if is_synthetic or (parsed.username or parsed.password) or parsed.scheme.lower() not in ("http", "https"):
# Rebuild URL to redact any credentials or query secrets
new_netloc = parsed.netloc
if parsed.username or parsed.password:
netloc_clean = parsed.hostname
netloc_clean = parsed.hostname or ""
if parsed.port:
netloc_clean = f"{netloc_clean}:{parsed.port}"
new_netloc = f"[REDACTED_USER]:[REDACTED_PASS]@{netloc_clean}"
@@ -177,11 +177,17 @@ def redact_urls(text: str) -> str:
def _mask_assignment(match: re.Match) -> str:
"""Keep the key and separator; replace only the secret value."""
val = match.group(3)
if val.startswith(REDACTED) or val.startswith("%5BREDACTED") or val.startswith("[REDACTED"):
return f"{match.group(1)}{match.group(2)}{val}"
return f"{match.group(1)}{match.group(2)}{REDACTED}"
def _mask_conn_secret(match: re.Match) -> str:
"""Keep the connection-string key; replace only the credential value."""
val = match.group(2)
if val.startswith(REDACTED) or val.startswith("%5BREDACTED") or val.startswith("[REDACTED"):
return f"{match.group(1)}{val}"
return f"{match.group(1)}{REDACTED}"
@@ -195,7 +201,8 @@ def _redact_str(text):
"""
if not isinstance(text, str) or not text:
return text
out = _BARE_SECRET_PATTERN.sub(REDACTED, text)
out = redact_urls(text)
out = _BARE_SECRET_PATTERN.sub(REDACTED, out)
out = _ASSIGNMENT_SECRET_PATTERN.sub(_mask_assignment, out)
out = _CONN_STRING_SECRET_PATTERN.sub(_mask_conn_secret, out)
out_lower = out.lower()
@@ -212,7 +219,7 @@ def _redact_str(text):
out = out[:i] + prefix + REDACTED + out[j:]
out_lower = out.lower()
idx = i + len(prefix) + len(REDACTED)
return redact_urls(out)
return out
def redact(value):