Audit log and metrics¶
The controller keeps an audit log of the security-relevant changes and serves
Prometheus metrics on /metrics. Both read the database shared with the MCP server: every
replica answers the same thing.
Audit log¶
Every change below writes an event in the same transaction as the change itself: an event exists exactly when the change was committed.
| Action | Written when |
|---|---|
finding.status_changed |
A person records a triage decision in the web interface (PATCH /api/v1/findings/{id}): previous status, new status, reason, expiry date and agent proposal it settled |
finding.status_proposed |
An agent proposes a status with propose_finding_status (never applied) |
finding.proposal_rejected |
A triager rejects the proposal of an agent |
finding.commented |
A person comments a finding (POST /api/v1/findings/{id}/comments): the comment, secrets masked |
finding.severity_changed |
A security analyst raises the severity of a finding, or restores the report's one (PATCH /api/v1/findings/{id}/severity): previous, new and original severity, justification |
token.created |
A token is created (CLI token create, Tokens screen) |
token.rotated |
The secret of a token is replaced (token rotate, Rotate button), with the grace period |
token.revoked |
A token is revoked |
token.projects_changed |
An administrator changes the projects of a token (Projects button of the Tokens screen), with the previous projects (null: every project) |
enrollment.requested |
A client requests a project (enrollment), with the requester, its address and whether a new request was filed (requested) |
enrollment.approved |
A user with projects.create approves a pending project (its group in the details) |
enrollment.rejected |
A user rejects a pending project, or it expires (actor enrollment, reason: expired) |
role.created, role.updated, role.deleted |
An administrator creates, changes (with the previous permissions) or deletes a role |
group.created, group.updated, group.deleted |
An administrator creates, renames or changes, or deletes a group; the approval of an enrollment creates a missing project group (project in the details) |
group.roles_changed, group.members_changed |
An administrator replaces the roles (with their projects) or the members (added and removed user ids) of a group |
project.created |
A user with projects.create creates a project (its project group and the user id in the details) |
project.deleted |
A user with projects.delete deletes a project (counts of reports, findings and tokens, groups deleted or changed in the details) |
user.groups_changed |
An administrator replaces the groups given to an account, for instance to add it to a project group (added and removed group ids) |
user.roles_changed |
Former direct roles of an account (roles: role or role:project,project): no longer recorded since accounts have no role of their own, kept in the history |
user.login |
A web user logs in (method password or oidc, address) |
user.login_failed |
A login fails: wrong password, refused OIDC login (method, address, and the user name only when it is valid) |
user.logout |
A web user logs out with Log out (address) |
audit_syslog.changed |
The syslog server is changed on the Audit log page (address and facility set, null: stored value deleted) |
Each event holds its date, its actor, its target (finding and its id, token and its id,
project and its name), the project when there is one, and details as JSON. The actor
is:
user:<name>: a person logged in to the web interface (local or OIDC account);token:<label>: a bearer token (agent, API client, administrator token);cli: asecurity-review-controllercommand;anonymous: the public enrollment endpoint, or a failed login;stdio: an agent connected to the MCP server in stdio.
The details never hold a secret: no token, no password, and the free text (reasons, requester) is masked like the reports. Nobody edits an event: neither MCP tool nor route changes them.
Reading the log¶
Administrators read it in the Audit log screen of the web interface (filters by event and
project), with GET /api/v1/audit (audit.read permission; action, project, actor, since,
limit, offset parameters, newest first) or on the command line:
security-review-controller audit list --action token.rotated --since 2026-10-01
security-review-controller audit list --project shop-api --actor user:alice --limit 20
# {"total": 1, "events": [{"id": 12, "created_at": "…", "action": "finding.status_changed",
# "actor": "user:alice", "target_type": "finding", "target_id": "42", "project": "shop-api",
# "details": {"previous_status": "open", "status": "accepted", "reason": "…", …}}]}
--limit (and the limit parameter) is capped by SECREVIEW_MAX_LIST_LIMIT.
Retention¶
serve deletes the events older than SECREVIEW_AUDIT_RETENTION_DAYS days (default 365), at
start then every day; 0 keeps every event. With SECREVIEW_AUDIT_MAX_EVENTS (default 0, no
limit), the same purge then keeps at most that many events, deleting the oldest. The same purge
runs on demand:
security-review-controller audit prune # SECREVIEW_AUDIT_RETENTION_DAYS and SECREVIEW_AUDIT_MAX_EVENTS
security-review-controller audit prune --days 730 # another retention, for this run only
# {"retention_days": 730, "max_events": 0, "deleted": 0}
security-review-controller audit prune --max-events 100000
Forwarding to syslog¶
The controller can also send each event to a syslog server, to keep it outside the database (SIEM, central log). Disabled by default:
SECREVIEW_AUDIT_SYSLOG_ADDRESS=udp://syslog.example.com:514 # or tcp://host:port, unix:///dev/log
SECREVIEW_AUDIT_SYSLOG_FACILITY=local0 # default
Administrators (settings.manage permission) can also set it on the Audit log page of the
web interface, with the Syslog button next to Refresh (a send icon checked when the
events are forwarded, crossed out when they are not; its tooltip gives the server). It opens the
Syslog forwarding dialog: No forwarding or Set here (udp://host:port or
tcp://host:port), and the facility. A variable that is set takes
precedence: its field is then locked, and a value set there before is ignored (Delete
removes it); an empty SECREVIEW_AUDIT_SYSLOG_ADDRESS counts as unset. A value set there
applies within about ten seconds, without restart; the change itself is written to the audit log
(audit_syslog.changed). When the forwarding is stopped, the pending events, this change
included, are first sent to the previous server: the SIEM sees who stopped it. A Unix socket is a
file of the deployment: it is set with the variable only. Through the API: GET and
PUT /api/v1/settings/audit-syslog with address and facility (null deletes the stored
value, a missing field is unchanged, a field whose variable is set is refused with 422).
Each event is one message with the info severity, tagged secreview-audit, its body the event
as JSON, the same fields as audit list:
<134>secreview-audit: {"action":"user.login","actor":"user:alice","created_at":"…","details":{"address":"10.0.0.8","method":"password"},"id":12,"project":null,"target_id":"2","target_type":"user"}
- The controller forwards the events of every process, MCP server and CLI included, from the database: the MCP server keeps making no outgoing connection. An event leaves about 5 to 15 seconds after it is written; the history before the forwarding is enabled (at start or in the web interface) is not sent.
- Each event is sent once, even with several replicas of the controller: the id of the last forwarded event is kept in the database, and a replica moves it forward before sending. A batch whose sending fails is logged and not sent again (at most once).
- No TLS nor proxy: to encrypt, send to a local relay (
rsyslog,syslog-ng, Fluent Bit) that forwards over TLS. An invalid variable stopsserveat start; an invalid address is refused by the web interface.
Prometheus metrics¶
GET /metrics on the controller answers in the Prometheus text format (0.0.4). The MCP
server has no metrics endpoint. It needs a token of role metrics, which reaches nothing else
(neither the REST API nor MCP), or admin:
security-review-controller token create --label prometheus --role metrics --expires-in-days 365
curl -H "Authorization: Bearer $TOKEN" https://secreview.example.com/metrics
# prometheus.yml
scrape_configs:
- job_name: security-review
scheme: https
authorization:
credentials_file: /etc/prometheus/secreview-token
static_configs:
- targets: ["secreview.example.com"]
| Metric | Labels | Value |
|---|---|---|
secreview_build_info |
version |
1 |
secreview_projects |
Projects with reports | |
secreview_reports |
Stored reports | |
secreview_findings |
status |
Findings by effective status (an expired dismissal counts as open) |
secreview_open_findings |
severity |
Open findings by severity |
secreview_project_open_findings |
project, severity |
Open findings of each project |
secreview_finding_proposals_pending |
Agent proposals waiting for a person | |
secreview_tokens |
role, state |
Tokens by role and state (active, expired) |
secreview_tokens_expiring_soon |
Active tokens that expire within 14 days | |
secreview_project_enrollments_pending |
Projects requested by an enrollment, waiting for an approval | |
secreview_web_users |
auth_source |
Web accounts (local, oidc) |
secreview_advisories |
Advisories in the database | |
secreview_advisory_last_update_timestamp_seconds |
End of the latest successful OSV download (absent before the first one) | |
secreview_auth_rejections_total |
reason |
Requests rejected by the authentication of the process that answers, since its start: invalid_token, token_expired, insufficient_scope |
Every value except secreview_auth_rejections_total comes from the database. The metrics hold
counts and project names, never a finding, a token label or a secret; label values are
escaped, so a project name cannot add lines to the answer. SECREVIEW_METRICS_ENABLED=false
turns the endpoint off (404).
Examples of alerts: secreview_tokens_expiring_soon > 0,
secreview_open_findings{severity="critical"} > 0,
time() - secreview_advisory_last_update_timestamp_seconds > 2 * 86400,
rate(secreview_auth_rejections_total{reason="invalid_token"}[5m]) > 1.