Files

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.