- 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
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Audit logging for Gitea MCP mutating actions (issue #18).
|
|
|
|
Emits one structured JSON record per mutating action so an operator can see
|
|
*which execution profile and authenticated Gitea user* performed (or was
|
|
blocked from / failed) each mutation.
|
|
|
|
Design constraints:
|
|
|
|
- **Off by default.** Records are written only when ``GITEA_AUDIT_LOG`` names a
|
|
file path. With it unset, ``write_event`` is a no-op and callers add zero
|
|
behaviour — existing tool behaviour and API call patterns are unchanged.
|
|
- **Never raises.** Auditing must never break the action it records; all sink
|
|
I/O is best-effort and swallows errors.
|
|
- **No secrets.** Tokens / Authorization material are redacted from request
|
|
metadata and reason strings before a record is written.
|
|
- **No network.** This module performs no HTTP; the caller supplies identity
|
|
and profile metadata it already resolved.
|
|
"""
|
|
import os
|
|
import json
|
|
import datetime
|
|
import re
|
|
import urllib.parse
|
|
|
|
# Result states for an audited action.
|
|
ALLOWED = "allowed"
|
|
BLOCKED = "blocked"
|
|
FAILED = "failed"
|
|
SUCCEEDED = "succeeded"
|
|
REQUESTED = "requested"
|
|
ACCEPTED = "accepted"
|
|
PENDING = "pending"
|
|
|
|
REDACTED = "[REDACTED]"
|
|
|
|
# A dict key containing any of these (case-insensitive) has its value redacted.
|
|
_SECRET_KEY_HINTS = (
|
|
"token",
|
|
"password",
|
|
"passwd",
|
|
"pwd",
|
|
"secret",
|
|
"authorization",
|
|
"auth",
|
|
"api_key",
|
|
"apikey",
|
|
"access_key",
|
|
"private_key",
|
|
"client_secret",
|
|
"credential",
|
|
)
|
|
# A string value starting with one of these has the following run redacted.
|
|
# Space-terminated scheme prefixes only. Colon forms (``token:`` / ``api_key:``)
|
|
# and ``Authorization: Bearer …`` are handled by ``_ASSIGNMENT_SECRET_PATTERN``
|
|
# so policy/docs text that merely *names* a scheme is not itself flagged as a
|
|
# live secret by console detectors.
|
|
_SECRET_VALUE_PREFIXES = (
|
|
"token ",
|
|
"Basic ",
|
|
"Bearer ",
|
|
)
|
|
|
|
# Bare token-shaped values only — never a broad ``sec-`` prefix that erases
|
|
# ordinary words (#664 B8 over-redaction).
|
|
_BARE_SECRET_PATTERN = re.compile(
|
|
r'(?i)\b(?:'
|
|
r'ghp_[A-Za-z0-9_]{16,}'
|
|
r'|gho_[A-Za-z0-9_]{16,}'
|
|
r'|ghu_[A-Za-z0-9_]{16,}'
|
|
r'|ghs_[A-Za-z0-9_]{16,}'
|
|
r'|ghr_[A-Za-z0-9_]{16,}'
|
|
r'|sk-live-[A-Za-z0-9_-]{16,}'
|
|
r'|sk-proj-[A-Za-z0-9_-]{16,}'
|
|
r'|sk-[A-Za-z0-9_-]{20,}'
|
|
r'|glpat-[A-Za-z0-9_-]{16,}'
|
|
r')\b'
|
|
)
|
|
|
|
# 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([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')'
|
|
)
|
|
|
|
# Connection-string style credentials: Password=...; User ID=...; etc.
|
|
_CONN_STRING_SECRET_PATTERN = re.compile(
|
|
r'(?i)\b((?:password|pwd|user\s*id|uid|username|account)\s*=\s*)([^\s;\'"]+)'
|
|
)
|
|
|
|
|
|
# Known synthetic test-only domains/hostnames to preserve
|
|
_SYNTHETIC_HOSTS = {
|
|
"example.com",
|
|
"example.test",
|
|
"example.invalid",
|
|
"internal.example",
|
|
"localhost",
|
|
"gitea.example.com",
|
|
"x"
|
|
}
|
|
|
|
# Known real service hostnames to redact even if not part of a full URL
|
|
_REAL_HOSTS = {"gitea.prgs.cc", "gitea.dadeschools.net"}
|
|
|
|
|
|
def redact_urls(text: str) -> str:
|
|
"""Redact raw URLs, query-string secrets, and URL credentials.
|
|
|
|
Synthetic example/test-only domains are preserved to keep test fixtures functional
|
|
except for any embedded credentials or query parameters containing secrets.
|
|
"""
|
|
if not isinstance(text, str) or not text:
|
|
return text
|
|
|
|
# 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)
|
|
try:
|
|
parsed = urllib.parse.urlsplit(url_str)
|
|
host = (parsed.hostname or "").lower()
|
|
|
|
is_synthetic = False
|
|
for sh in _SYNTHETIC_HOSTS:
|
|
if host == sh or host.endswith("." + sh):
|
|
is_synthetic = True
|
|
break
|
|
|
|
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 or ""
|
|
if parsed.port:
|
|
netloc_clean = f"{netloc_clean}:{parsed.port}"
|
|
new_netloc = f"[REDACTED_USER]:[REDACTED_PASS]@{netloc_clean}"
|
|
|
|
new_query = parsed.query
|
|
if parsed.query:
|
|
params = urllib.parse.parse_qsl(parsed.query)
|
|
clean_params = []
|
|
for k, v in params:
|
|
if any(hint in k.lower() for hint in ("token", "password", "secret", "auth", "key")):
|
|
clean_params.append((k, "[REDACTED]"))
|
|
else:
|
|
clean_params.append((k, v))
|
|
new_query = urllib.parse.urlencode(clean_params)
|
|
|
|
return urllib.parse.urlunsplit((
|
|
parsed.scheme,
|
|
new_netloc,
|
|
parsed.path,
|
|
new_query,
|
|
parsed.fragment
|
|
))
|
|
else:
|
|
return "[REDACTED_URL]"
|
|
except Exception:
|
|
return "[REDACTED_URL]"
|
|
|
|
out = url_pattern.sub(replace_url, text)
|
|
|
|
# Redact raw occurrences of real service hostnames afterward
|
|
for host in _REAL_HOSTS:
|
|
out = out.replace(host, "[REDACTED_HOST]")
|
|
|
|
return out
|
|
|
|
|
|
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}"
|
|
|
|
|
|
def _redact_str(text):
|
|
"""Redact credentials, bare token shapes, and raw URLs in *text* (#664 B8).
|
|
|
|
Covers key/value credentials, authorization/bearer material, bare
|
|
token-shaped values, connection-string credentials, and secrets embedded
|
|
in larger sentences. Deliberately does **not** erase ordinary words that
|
|
merely begin with a broad ``sec-`` prefix.
|
|
"""
|
|
if not isinstance(text, str) or not text:
|
|
return 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()
|
|
for prefix in _SECRET_VALUE_PREFIXES:
|
|
prefix_lower = prefix.lower()
|
|
idx = 0
|
|
while True:
|
|
i = out_lower.find(prefix_lower, idx)
|
|
if i == -1:
|
|
break
|
|
j = i + len(prefix)
|
|
while j < len(out) and not out[j].isspace():
|
|
j += 1
|
|
out = out[:i] + prefix + REDACTED + out[j:]
|
|
out_lower = out.lower()
|
|
idx = i + len(prefix) + len(REDACTED)
|
|
return out
|
|
|
|
|
|
def redact(value):
|
|
"""Recursively redact secret-looking keys/values from a JSON-able value."""
|
|
if isinstance(value, dict):
|
|
result = {}
|
|
for k, v in value.items():
|
|
if isinstance(k, str) and any(h in k.lower() for h in _SECRET_KEY_HINTS):
|
|
result[k] = REDACTED
|
|
else:
|
|
result[k] = redact(v)
|
|
return result
|
|
if isinstance(value, (list, tuple)):
|
|
return [redact(v) for v in value]
|
|
if isinstance(value, str):
|
|
return _redact_str(value)
|
|
return value
|
|
|
|
|
|
def audit_log_path():
|
|
"""Return the configured audit log file path, or None if auditing is off."""
|
|
return (os.environ.get("GITEA_AUDIT_LOG") or "").strip() or None
|
|
|
|
|
|
def audit_enabled():
|
|
"""True when a sink is configured. When False, callers should skip auditing."""
|
|
return audit_log_path() is not None
|
|
|
|
|
|
def build_event(*, action, result, remote=None, server=None, repository=None,
|
|
issue_number=None, pr_number=None, profile_name=None,
|
|
audit_label=None, authenticated_username=None, target_branch=None,
|
|
head_sha=None, reason=None, request_metadata=None, now=None,
|
|
mcp_namespace=None, task_role=None, operation=None):
|
|
"""Build a redacted, JSON-able audit record for a mutating action."""
|
|
ts = now or datetime.datetime.now(datetime.timezone.utc)
|
|
if isinstance(ts, datetime.datetime):
|
|
ts = ts.isoformat()
|
|
event = {
|
|
"timestamp": ts,
|
|
"action": action,
|
|
"action_type": "mutating",
|
|
"result": result,
|
|
"remote": remote,
|
|
"server": server,
|
|
"repository": repository,
|
|
"issue_number": issue_number,
|
|
"pr_number": pr_number,
|
|
"profile_name": profile_name,
|
|
"audit_label": audit_label,
|
|
"authenticated_username": authenticated_username,
|
|
"target_branch": target_branch,
|
|
"head_sha": head_sha,
|
|
"reason": _redact_str(reason) if reason else reason,
|
|
"request_metadata": redact(request_metadata) if request_metadata is not None else None,
|
|
}
|
|
if mcp_namespace is not None:
|
|
event["mcp_namespace"] = mcp_namespace
|
|
if task_role is not None:
|
|
event["task_role"] = task_role
|
|
if operation is not None:
|
|
event["operation"] = operation
|
|
return event
|
|
|
|
|
|
def write_event(event, path=None):
|
|
"""Append *event* as one JSON line to the audit sink. Never raises.
|
|
|
|
Returns True if a line was written, False if auditing is off or the write
|
|
failed (auditing is best-effort and must not break the caller).
|
|
"""
|
|
path = path or audit_log_path()
|
|
if not path:
|
|
return False
|
|
try:
|
|
line = json.dumps(event, default=str, sort_keys=True)
|
|
with open(path, "a", encoding="utf-8") as fh:
|
|
fh.write(line + "\n")
|
|
return True
|
|
except Exception:
|
|
return False
|