fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history (#1259)

* fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history

Harden the Admiral audit log without changing its tier or hub-only gating.

- CSV export now defuses formula injection: any field that a spreadsheet
  would evaluate as a formula (leading = + - @, or a trigger behind leading
  whitespace, or a leading tab/CR) is prefixed with a single quote before
  RFC 4180 quoting. Audit summaries embed user-controlled resource names, so
  this closes a path where a crafted name could execute on export open.
- Clamp page and limit to positive bounds on the list endpoint so a negative
  limit can no longer reach SQLite as "unlimited" and dump the whole table.
- Bound the anomaly and stats history reads to a capped slice of recent rows
  so the analysis paths stay within fixed memory and latency on large
  histories instead of scanning the full retention window per request.
- Surface failed audit list and stats fetches through the standard error
  toast instead of leaving the view silently stale.
- Add a developer-mode-gated diagnostic log to the stats endpoint for parity
  with the list and export handlers.

Covered by new unit and HTTP-integration tests (CSV neutralization through
the real export route, pagination clamps, bounded history, stats endpoint,
anomaly annotation) and verified end to end in the browser.

* fix(audit-log): compute signal-rail stats with exact SQL aggregates

Address review feedback on the earlier history-cap change. The cap was
correct for the anomaly baseline but made the stats tiles (events, actors,
failure rate, hourly series) silently undercount on a hub with more than the
cap's worth of rows in the window, since they were derived from the capped
row slice.

- Add DatabaseService.getAuditStatsInputs: exact counts via SQL COUNT /
  COUNT(DISTINCT) / GROUP BY hour, and new-ip detection over the small
  DISTINCT (user, ip) pair sets. No row cap, so the tiles stay exact at any
  window size while memory stays bounded.
- Reduce computeAuditStats to a pure formatter over those aggregates.
- Keep the bounded history read only for the list endpoint's anomaly
  annotation, where a recent-activity baseline is an acceptable heuristic.
- Skip the redundant load-failure toast when a fetch fails with a handled
  401, so an expired session does not stack toasts on top of logout.

Adds exactness tests for the aggregate counts, distinct-actor handling, and
new-ip detection, and strengthens the pagination-clamp tests.

* fix(audit-log): exclude future-dated rows and make the new-ip sample deterministic

Two small parity fixes on the stats aggregates: upper-bound every current
window by `now` so a future-dated row (clock skew or a fixture) cannot inflate
the live counts, and order the new-ip pair scan so the sample actor shown in
the tile detail is stable. Adds a test asserting a future row is excluded.
This commit is contained in:
Anso
2026-05-31 16:36:07 -04:00
committed by GitHub
parent dbb7fe8215
commit 5e66b54153
9 changed files with 553 additions and 98 deletions
+23 -40
View File
@@ -68,49 +68,32 @@ export interface AuditStats {
failures_by_hour: number[];
}
export function computeAuditStats(input: {
now: number;
last24h: AuditLogEntry[];
last7d: AuditLogEntry[];
last30d: AuditLogEntry[];
}): AuditStats {
const { now, last24h, last7d, last30d } = input;
const events24 = last24h.length;
const prior7d = last7d.length - events24;
/**
* Format the signal-rail stats from exact aggregate inputs (see
* DatabaseService.getAuditStatsInputs). This is pure presentation: counts,
* hourly series, and new-ip detection are computed exactly upstream in SQL so
* the tiles never undercount on a large window.
*/
export interface AuditStatsInput {
events24: number;
events7d: number;
actors24: number;
failures24: number;
activityByHour: number[];
failuresByHour: number[];
newIpCount: number;
sampleNewIpActor: string | null;
}
export function computeAuditStats(input: AuditStatsInput): AuditStats {
const { events24, events7d, actors24, failures24, activityByHour, failuresByHour, newIpCount, sampleNewIpActor } = input;
const prior7d = events7d - events24;
const avg7dPerDay = Math.max(0, prior7d) / 6;
const deltaPct = avg7dPerDay > 0 ? Math.round(((events24 - avg7dPerDay) / avg7dPerDay) * 100) : null;
const actors24 = new Set(last24h.map(e => e.username).filter(Boolean));
const olderIpByActor = new Map<string, Set<string>>();
for (const e of last30d) {
if (!e.username || !e.ip_address) continue;
if (e.timestamp >= now - 24 * 60 * 60 * 1000) continue;
let set = olderIpByActor.get(e.username);
if (!set) { set = new Set(); olderIpByActor.set(e.username, set); }
set.add(e.ip_address);
}
let newIpCount = 0;
let sampleNewIpActor: string | null = null;
for (const e of last24h) {
if (!e.username || !e.ip_address) continue;
const prior = olderIpByActor.get(e.username);
if (prior && prior.size > 0 && !prior.has(e.ip_address)) {
newIpCount++;
if (!sampleNewIpActor) sampleNewIpActor = e.username;
}
}
const failureCount = last24h.filter(e => e.status_code >= 400).length;
const failureRate = events24 > 0 ? failureCount / events24 : 0;
const failureRate = events24 > 0 ? failures24 / events24 : 0;
const failurePct = Math.round(failureRate * 100);
const activityByHour = Array.from({ length: 24 }, () => 0);
const failuresByHour = Array.from({ length: 24 }, () => 0);
for (const e of last24h) {
const hour = new Date(e.timestamp).getHours();
activityByHour[hour]++;
if (e.status_code >= 400) failuresByHour[hour]++;
}
const peakHour = activityByHour.reduce(
(best, count, hour) => (count > best.count ? { count, hour } : best),
{ count: -1, hour: 0 }
@@ -125,7 +108,7 @@ export function computeAuditStats(input: {
severity: deltaPct !== null && deltaPct > 150 ? 'warn' : 'ok',
},
actors_24h: {
value: actors24.size,
value: actors24,
label: 'actors',
detail: newIpCount > 0
? `${newIpCount} new ip${newIpCount === 1 ? '' : 's'}${sampleNewIpActor ? ` · ${sampleNewIpActor}` : ''}`
@@ -135,7 +118,7 @@ export function computeAuditStats(input: {
failure_rate: {
value: failurePct,
label: 'failure rate',
detail: `${failureCount} of ${events24} request${events24 === 1 ? '' : 's'}`,
detail: `${failures24} of ${events24} request${events24 === 1 ? '' : 's'}`,
severity: failurePct >= 20 ? 'alert' : failurePct >= 5 ? 'warn' : 'ok',
},
unusual_hour: {
+82 -1
View File
@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
import type { AuditStatsInput } from './AuditAnomalyService';
function isPilotMode(): boolean {
return process.env.SENCHO_MODE === 'pilot';
@@ -612,6 +613,14 @@ export interface ScanSummary {
const AUDIT_LOG_FLUSH_INTERVAL_MS = 1_000;
const AUDIT_LOG_FLUSH_THRESHOLD = 100;
// Upper bound on the rows the anomaly baseline / stats computations pull into
// memory for a single request. The audit table grows unbounded within the
// retention window, and the analysis paths run on every list page and every
// stats refresh, so without a cap a busy fleet would scan the whole window into
// a JS array each time. When the window holds more than this, the most-recent
// rows are used and baselines become an approximation over recent activity.
export const AUDIT_ANOMALY_HISTORY_CAP = 20_000;
export const PILOT_METRICS_COUNTERS_KEY = 'pilot_metrics_counters';
export class DatabaseService {
@@ -2984,13 +2993,85 @@ export class DatabaseService {
this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff);
}
public getAuditLogsInRange(from: number, to: number): AuditLogEntry[] {
public getAuditLogsInRange(from: number, to: number, limit?: number): AuditLogEntry[] {
this.flushAuditLogBuffer();
if (limit !== undefined) {
// Cap to the most-recent `limit` rows in the window, then return
// them in ascending order to preserve this method's contract.
return this.db.prepare(
`SELECT * FROM (
SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ?
ORDER BY timestamp DESC LIMIT ?
) ORDER BY timestamp ASC`
).all(from, to, limit) as AuditLogEntry[];
}
return this.db.prepare(
'SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC'
).all(from, to) as AuditLogEntry[];
}
/**
* Exact aggregate inputs for the audit signal-rail stats, computed with SQL
* COUNT / GROUP BY rather than materializing rows. The counts and hourly
* series stay exact regardless of window size (no row cap), while the
* new-ip detection works over the small DISTINCT (user, ip) pair sets.
*/
public getAuditStatsInputs(now: number): AuditStatsInput {
this.flushAuditLogBuffer();
const cutoff24h = now - 24 * 60 * 60 * 1000;
const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
// Every current-window query is upper-bounded by `now` so a future-dated
// row (clock skew, a test fixture) never inflates the live counts.
const countOf = (sql: string, ...params: number[]): number =>
(this.db.prepare(sql).get(...params) as { c: number }).c;
const events24 = countOf('SELECT COUNT(*) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ?', cutoff24h, now);
const events7d = countOf('SELECT COUNT(*) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ?', cutoff7d, now);
const actors24 = countOf("SELECT COUNT(DISTINCT username) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND username != ''", cutoff24h, now);
const failures24 = countOf('SELECT COUNT(*) AS c FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND status_code >= 400', cutoff24h, now);
const activityByHour = Array.from({ length: 24 }, () => 0);
const failuresByHour = Array.from({ length: 24 }, () => 0);
const hourRows = this.db.prepare(
`SELECT CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour,
COUNT(*) AS total,
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS failures
FROM audit_log WHERE timestamp >= ? AND timestamp < ? GROUP BY hour`,
).all(cutoff24h, now) as { hour: number; total: number; failures: number }[];
for (const r of hourRows) {
if (r.hour >= 0 && r.hour < 24) {
activityByHour[r.hour] = r.total;
failuresByHour[r.hour] = r.failures;
}
}
// ORDER BY makes both the new-ip scan and the sample actor deterministic.
const recentPairs = this.db.prepare(
"SELECT DISTINCT username, ip_address FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND username != '' AND ip_address != '' ORDER BY username, ip_address",
).all(cutoff24h, now) as { username: string; ip_address: string }[];
const priorPairs = this.db.prepare(
"SELECT DISTINCT username, ip_address FROM audit_log WHERE timestamp >= ? AND timestamp < ? AND username != '' AND ip_address != ''",
).all(cutoff30d, cutoff24h) as { username: string; ip_address: string }[];
const priorByActor = new Map<string, Set<string>>();
for (const p of priorPairs) {
let set = priorByActor.get(p.username);
if (!set) { set = new Set(); priorByActor.set(p.username, set); }
set.add(p.ip_address);
}
let newIpCount = 0;
let sampleNewIpActor: string | null = null;
for (const p of recentPairs) {
const prior = priorByActor.get(p.username);
if (prior && prior.size > 0 && !prior.has(p.ip_address)) {
newIpCount++;
if (!sampleNewIpActor) sampleNewIpActor = p.username;
}
}
return { events24, events7d, actors24, failures24, activityByHour, failuresByHour, newIpCount, sampleNewIpActor };
}
// --- API Tokens ---
public addApiToken(token: Omit<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): number {