mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user