fix: record the calling client and only schema-known names in audit entries (SEC-12, SEC-18)

This commit is contained in:
Latte committed 2026-10-14 17:14:13 +02:00
1 parent 43eebd3089
commit fd5e4b71f3
5 files changed
+141 -10

No files matched your search

+8 -1
View File
@@ -129,13 +129,17 @@ class AuditLogger:
identity_login: str | None = None,
identity_mode: str | None = None,
sudo: str | None = None,
client_id: str | None = None,
session: str | None = None,
client_ip: str | None = None,
outcome: str | None = None,
duration_ms: float | None = None,
) -> str:
"""Log an MCP tool invocation.
The keyword-only fields describe a server tool call (tool domain, HTTP
method, path template, identity, sudo target, outcome and duration). They
method, path template, identity, sudo target, the MCP client id, a short hash
of the grant's session and the source address, outcome and duration). They
are only written when given. Callers must pass argument names, never
argument values, in ``params``.
"""
@@ -154,6 +158,9 @@ class AuditLogger:
"identity_login": identity_login,
"identity_mode": identity_mode,
"sudo": sudo,
"client_id": client_id,
"session": session,
"client_ip": client_ip,
"outcome": outcome,
"duration_ms": duration_ms,
}
+49 -4
View File
@@ -11,6 +11,8 @@ values are never logged or echoed in validation errors.
from __future__ import annotations
import hashlib
import ipaddress
import json
import logging
import time
@@ -78,7 +80,7 @@ _MAX_ALLOWED_NAMES = 40
_MAX_NAME_CHARS = 64
_MAX_AUDIT_ARGUMENT_NAMES = 64
_MAX_AUDIT_PATH_CHARS = 200
_MAX_UNKNOWN_NAME_CHARS = 128
_MAX_CLIENT_ID_CHARS = 128
#: Slack added to the upload-derived HTTP body cap for JSON framing and other arguments.
BODY_OVERHEAD_BYTES = 1024 * 1024
@@ -357,6 +359,34 @@ class _Record:
is_error: bool = True
http_status: int | None = None
argument_names: list[str] = field(default_factory=list)
rejected_arguments: int = 0
client_id: str | None = None
session: str | None = None
client_ip: str | None = None
def _source_ip(ctx: ServerRequestContext[dict[str, Any]]) -> str | None:
"""Client address of an HTTP request (None on stdio); only a valid IP address is recorded."""
client = getattr(getattr(ctx, "request", None), "client", None)
host = getattr(client, "host", None)
if not isinstance(host, str):
return None
try:
return str(ipaddress.ip_address(host.split("%")[0]))
except ValueError:
return None
def _caller_fields(record: _Record, ctx: ServerRequestContext[dict[str, Any]]) -> None:
"""Fill the client, session and source address of the call from the verified token."""
access = get_access_token()
if access is not None:
record.client_id = access.client_id[:_MAX_CLIENT_ID_CHARS]
session_id = (access.claims or {}).get("session_id")
if isinstance(session_id, str) and session_id:
# A short hash: enough to correlate entries of one grant, useless as a credential.
record.session = hashlib.sha256(session_id.encode()).hexdigest()[:12]
record.client_ip = _source_ip(ctx)
def _error_payload(kind: str, message: str, **extra: str) -> dict[str, Any]:
@@ -409,8 +439,12 @@ class _Dispatcher:
request_id = uuid.uuid4().hex
set_request_id(request_id)
arguments = params.arguments or {}
record = _Record(tool=params.name[:_MAX_UNKNOWN_NAME_CHARS], request_id=request_id)
record.argument_names = sorted(_clip(key) for key in arguments)[:_MAX_AUDIT_ARGUMENT_NAMES]
# The tool name and the argument names come from the caller: only names the
# registry knows are recorded, as the audit entry must never hold a secret a
# model pasted into a name.
record = _Record(tool=f"(unknown tool, {len(params.name)} chars)", request_id=request_id)
record.rejected_arguments = len(arguments)
_caller_fields(record, ctx)
# Fail closed: the audit logger is obtained BEFORE anything runs, so a call is
# never executed (and its side effects never happen) without a way to record it.
try:
@@ -428,8 +462,14 @@ class _Dispatcher:
entry = self._registry.get(params.name)
if entry is None:
record.outcome = "unknown_tool"
raise MCPError(types.INVALID_PARAMS, f"Unknown tool: {record.tool}")
raise MCPError(types.INVALID_PARAMS, "Unknown tool")
record.tool = entry.tool.name
record.domain = entry.domain
known = set(entry.tool.input_schema.get("properties", {}))
record.argument_names = sorted(key for key in arguments if key in known)[
:_MAX_AUDIT_ARGUMENT_NAMES
]
record.rejected_arguments = len(arguments) - len(record.argument_names)
# Write-ahead entry: a call whose start cannot be recorded does not run.
try:
audit.log_tool_call_started(entry.tool.name, request_id, entry.domain)
@@ -529,6 +569,8 @@ class _Dispatcher:
execution, in :meth:`call_tool`.
"""
params: dict[str, Any] = {"argument_names": record.argument_names}
if record.rejected_arguments:
params["rejected_argument_count"] = record.rejected_arguments
if record.http_status is not None:
params["http_status"] = record.http_status
try:
@@ -543,6 +585,9 @@ class _Dispatcher:
identity_login=record.identity.login if record.identity else None,
identity_mode=record.identity.mode if record.identity else None,
sudo=record.sudo,
client_id=record.client_id,
session=record.session,
client_ip=record.client_ip,
outcome=record.outcome,
duration_ms=round(duration_ms, 2),
)