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
+19
View File
@@ -101,6 +101,13 @@ function dayKey(ts: number): string {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
// These views fetch localOnly, so a 401 is the user's own expired session,
// which apiFetch already turns into a global logout. Skip the redundant
// load-failure toast in that case so logout does not stack toasts.
function isExpiredSession(err: unknown): boolean {
return err instanceof Error && err.message === 'Unauthorized';
}
export function AuditLogView() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
@@ -151,9 +158,15 @@ export function AuditLogView() {
const data = await res.json();
setEntries(data.entries);
setTotal(data.total);
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to load audit log.');
}
} catch (err) {
console.error('[AuditLog] Failed to fetch:', err);
if (!isExpiredSession(err)) {
toast.error('Failed to load audit log.');
}
} finally {
setLoading(false);
}
@@ -164,9 +177,15 @@ export function AuditLogView() {
const res = await apiFetch('/audit-log/stats', { localOnly: true });
if (res.ok) {
setStats(await res.json());
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to load audit stats.');
}
} catch (err) {
console.error('[AuditLog] Failed to fetch stats:', err);
if (!isExpiredSession(err)) {
toast.error('Failed to load audit stats.');
}
}
}, []);