107 lines
5.0 KiB
Markdown
107 lines
5.0 KiB
Markdown
# Audit logging
|
|
|
|
Every tool call produces exactly one result event (`tool_invocation`) in an append-only,
|
|
hash-chained JSON-lines log, whether it succeeded, failed at Gitea, failed validation or hit an
|
|
internal error. A call to a known tool is preceded by a small write-ahead event
|
|
(`tool_call_started`, see Location and permissions). Security events are logged in the same
|
|
chain.
|
|
|
|
## What an event contains
|
|
|
|
Each line is one JSON object:
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `timestamp` | UTC, ISO 8601 |
|
|
| `event_type` | `tool_invocation`, `tool_call_started`, `access_denied` or `security_event` |
|
|
| `request_id` | Correlation id of the request |
|
|
| `payload` | The event data, see below |
|
|
| `prev_hash` | `entry_hash` of the previous line (`GENESIS` for the first) |
|
|
| `entry_hash` | SHA-256 over the previous hash and this entry |
|
|
|
|
A tool invocation payload records:
|
|
|
|
- the tool name, its domain, the HTTP method and the path template (for example
|
|
`/repos/{owner}/{repo}/issues`, never the filled-in values);
|
|
- the identity: Gitea login and mode (`admin-token` or `oauth`), and the sudo user when one was
|
|
given;
|
|
- the HTTP status when Gitea answered, and the outcome: success, or the error kind
|
|
(`not_found`, `forbidden`, `invalid_argument`, `internal_error`, ...);
|
|
- the duration in milliseconds and the correlation id;
|
|
- who called: the MCP client id of the verified token (`aegis-static-bearer` for the static
|
|
bearer), a 12 character hash of the OAuth grant's session id (`session`, only for OAuth
|
|
tokens, never the id or a token) and the source address (`client_ip`, HTTP only; behind a
|
|
proxy it is the forwarded address only when the proxy is in `FORWARDED_ALLOW_IPS`);
|
|
- the argument names, sorted, and only names that are properties of the tool's schema; any other
|
|
name is counted in `rejected_argument_count`. Argument values are never logged, so request
|
|
bodies, secret values and file contents do not reach the log. A call to a tool that does not
|
|
exist is recorded as `(unknown tool, N chars)`: the caller's string is neither logged nor
|
|
echoed in the error.
|
|
|
|
All payloads also pass through secret masking before they are written
|
|
(`SECRET_DETECTION_MODE` other than `off`).
|
|
|
|
## Integrity
|
|
|
|
Each entry hashes the previous one, so editing, deleting or reordering a line breaks the chain
|
|
from that point on. Validate it with:
|
|
|
|
```bash
|
|
make validate-audit # default path
|
|
uv run python scripts/validate_audit_log.py --path /var/lib/aegis-mcp/audit.log
|
|
```
|
|
|
|
Exit code 0 means the chain is intact; 1 lists the lines that do not verify. The default path is
|
|
`AUDIT_LOG_PATH`, else `DATA_DIR/audit.log`.
|
|
|
|
The chain proves that history was not altered after the fact. It does not stop someone with
|
|
write access to the whole file from rewriting it consistently from the start, so also ship the
|
|
log to storage the server cannot modify, and compare the latest `entry_hash` against a copy you
|
|
keep elsewhere.
|
|
|
|
## Location and permissions
|
|
|
|
The log is written to `AUDIT_LOG_PATH`, default `DATA_DIR/audit.log` (`/var/lib/aegis-mcp` in the
|
|
image). Keep it on durable storage, readable and writable only by the service account.
|
|
The server creates a missing state directory with mode 0700 and the log with mode 0600, tightens
|
|
an older log it owns that is group or world readable, and refuses to open the log through a
|
|
symlink (startup exits with code 2). A keyed (HMAC) chain is not offered: the chain is
|
|
tamper-evident only, so ship the log off the host as the integrity control.
|
|
|
|
The server opens the file for append at startup and exits with code 2 (message on stderr) when
|
|
that fails, so it never runs unaudited. The check does not stop at startup: each call to a known
|
|
tool first writes a `tool_call_started` entry (tool name, domain and correlation id), and when
|
|
that write fails (full disk, revoked mount, closed file) the call is refused with
|
|
`audit_unavailable` and never reaches Gitea. Calls resume as soon as writes work again. A write
|
|
failure of the final `tool_invocation` entry after a call has executed is only logged to stderr:
|
|
the Gitea side effect already happened and the caller must still receive the result. The
|
|
`correlation_id` of both entries is the request id, which is also the entry's `request_id`.
|
|
|
|
## Rotation
|
|
|
|
The server appends to one file and does not rotate it, because rotating mid-stream would break
|
|
the chain. Rotate externally with `logrotate` and `copytruncate`, so the open file handle keeps
|
|
appending:
|
|
|
|
```
|
|
/var/lib/aegis-mcp/audit.log {
|
|
weekly
|
|
rotate 12
|
|
compress
|
|
missingok
|
|
notifempty
|
|
copytruncate
|
|
}
|
|
```
|
|
|
|
The validator expects a chain to start at `GENESIS`, so only the first segment verifies as it
|
|
is. For a later segment, check that its first `prev_hash` equals the last `entry_hash` of the
|
|
previous segment. Validate segments before archiving them, and keep them in order.
|
|
|
|
## Operating it
|
|
|
|
- Alert on bursts of `forbidden` or `unauthorized` outcomes and on tool calls by unexpected
|
|
identities.
|
|
- Review calls to admin, secret and delete tools. The tool name makes them easy to filter.
|
|
- Run the integrity check regularly and during incident response.
|