fix(security): event-store retention + race + total-cap defects (DC-116) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- query().total: full-scan true count (was capped at offset+limit by an
  early break — dashboard 24h stat read ≤1000 vs real ~46k)
- trim trigger/curer mismatch: byte trigger + line curer never converged
  when avg line > ~524B (live avg 355B); now keeps maxDisk lines OR
  ≤80% byte budget, whichever retains fewer (TRIM_TARGET_FACTOR);
  budget env-overridable via SECURITY_EVENT_TRIM_BYTES / opts.trimSizeLimit
- trim/append race: trim renamed over the file mid-append losing events
  to the unlinked inode; now single-flight, queue-empty gated, holds the
  write lock, error paths always release + lazily re-kick (no hot loop)

Judge: glm-4.6@zai-coding-paas adversarial cold-read, grade B clean
(0 blocking, 4 polish — 2 folded, 1 already-satisfied, 1 deferred as
judge-endorsed safer). 127 suites / 2849 tests green.
This commit is contained in:
Hermes
2026-08-23 15:02:00 -07:00
parent 65457ff8e0
commit 2dce6dca5e
2 changed files with 325 additions and 28 deletions
+94 -28
View File
@@ -34,6 +34,12 @@ const EVENT_STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE
const MAX_EVENTS_IN_MEMORY = parseInt(process.env.SECURITY_EVENT_MAX_MEMORY || '10000', 10);
const MAX_EVENTS_ON_DISK = parseInt(process.env.SECURITY_EVENT_MAX_DISK || '100000', 10);
// DC-116: size trigger for disk trim. Env-overridable so operators (and tests)
// can tighten it without rebuilding. Default unchanged: 50MB.
const TRIM_TARGET_FACTOR = 0.8; // post-trim target: ≤80% of the byte budget
const DEFAULT_TRIM_SIZE_LIMIT = parseInt(
process.env.SECURITY_EVENT_TRIM_BYTES || String(50 * 1024 * 1024), 10);
const VALID_SOURCE_TYPES = new Set(['api', 'caddy', 'fail2ban', 'shared-bans', 'syslog', 'agent']);
const VALID_SEVERITIES = new Set(['info', 'notice', 'warn', 'error', 'critical']);
const VALID_OUTCOMES = new Set(['success', 'failure', 'denied', 'blocked', 'rate-limited', 'error', 'unknown']);
@@ -44,12 +50,15 @@ class SecurityEventStore extends EventEmitter {
this.filePath = opts.filePath || EVENT_STORE_FILE;
this.maxMemory = opts.maxMemory || MAX_EVENTS_IN_MEMORY;
this.maxDisk = opts.maxDisk || MAX_EVENTS_ON_DISK;
// DC-116: byte budget for the on-disk file (trigger AND target of _trim)
this.trimSizeLimit = opts.trimSizeLimit != null ? opts.trimSizeLimit : DEFAULT_TRIM_SIZE_LIMIT;
this.log = opts.log || console;
this.events = []; // newest first
this.byId = new Map();
this.lastWriteLine = 0; // byte offset of last successfully-written line
this.writeQueue = []; // serialized write buffer
this.writing = false;
this._trimScheduled = false; // DC-116: one in-flight trim at a time
this._load();
}
@@ -170,59 +179,108 @@ class SecurityEventStore extends EventEmitter {
/**
* Serialize appends to disk. Writes one line at a time, doesn't truncate.
* Disk trimming happens separately via _trim().
* Disk trimming happens separately via _maybeTrim() — and only while the
* write queue is empty, so an append can never land on the unlinked
* pre-trim inode (DC-116).
*/
_flushQueue() {
if (this.writing) return;
const next = this.writeQueue.shift();
if (!next) return;
if (!next) {
// Write path idle — safe point to run a pending trim (no in-flight
// append can race the rename; new appends queue behind this.writing).
this._maybeTrim();
return;
}
this.writing = true;
const line = JSON.stringify(next) + '\n';
fs.appendFile(this.filePath, line, 'utf8', (err) => {
this.writing = false;
if (err) {
this.log.error?.('security', 'write failed', { error: err.message });
// Re-queue so we don't lose the event on transient errors
// Re-queue so we don't lose the event on transient errors. Do NOT
// auto-retry here — a persistent failure (disk full, perms) would
// turn setImmediate into a hot loop. The next append() re-kicks
// the flush (same semantics as before DC-116).
this.writeQueue.unshift(next);
return;
}
// Drain the rest of the queue before considering a trim. (Note: this
// merely defers to the next tick — it batches, it does not throttle;
// with an instantly-draining queue each drain can still end in a trim.)
if (this.writeQueue.length > 0) {
setImmediate(() => this._flushQueue());
} else {
// Try next
if (this.writeQueue.length > 0) setImmediate(() => this._flushQueue());
this._maybeTrim();
}
});
}
/**
* Trim disk log if it exceeds maxDisk lines. Done in the background — never
* blocks an append(). Strategy: rewrite the file keeping the most recent
* Trim disk log when it exceeds the byte budget. Runs on the write path
* only when the queue is empty (see _flushQueue), and at most one trim is
* in flight at a time. Strategy: rewrite the file keeping the most recent
* maxDisk lines, atomically (write tmp + rename).
*
* DC-116 fix — two prior bugs:
* 1. Trigger/curer mismatch: the trigger was size-based (>50MB) but the
* curer was line-count-based (keep maxDisk=100k lines). If the average
* line exceeds SIZE_LIMIT/maxDisk (~524B) the trim no-ops forever while
* the size trigger keeps firing — unbounded file + a full-file stat
* (and potentially re-read) on every append. Now: trim fires on size
* and keeps the most recent maxDisk LINES OR enough BYTES to get under
* 80% of the budget, whichever retains fewer lines — the file always
* shrinks back below the trigger.
* 2. Trim/append race: trim renamed over the file while unrelated appends
* were in flight, silently losing them to the unlinked inode. Now trim
* runs only between writes (queue empty, this.writing false) and holds
* the write lock for its duration.
*/
_maybeTrim() {
if (this._trimScheduled) return; // one at a time
fs.stat(this.filePath, (err, st) => {
if (err || !st) return;
// Cheap heuristic: if file is > 50MB we always trim. Otherwise count lines.
const SIZE_LIMIT = 50 * 1024 * 1024;
if (st.size < SIZE_LIMIT) return;
this._trim();
if (st.size < this.trimSizeLimit) return;
this._trimScheduled = true;
this.writing = true; // hold the write lock for the whole trim
this._trim(st.size);
});
}
_trim() {
this.log.info?.('security', 'trimming event store', { file: this.filePath });
_trim(fileSizeBytes) {
this.log.info?.('security', 'trimming event store', {
file: this.filePath,
size_bytes: fileSizeBytes,
keep_lines: this.maxDisk,
budget_bytes: this.trimSizeLimit,
});
fs.readFile(this.filePath, 'utf8', (err, content) => {
if (err) return;
const done = (e) => {
this._trimScheduled = false;
this.writing = false; // release the write lock
if (this.writeQueue.length > 0) setImmediate(() => this._flushQueue());
if (e) this.log.error?.('security', 'trim failed', { error: e.message });
};
if (err) return done(err);
const lines = content.split('\n').filter(l => l.trim());
if (lines.length <= this.maxDisk) return;
const kept = lines.slice(-this.maxDisk).join('\n') + '\n';
if (lines.length === 0) return done();
// Byte-aware reconciliation: keep the most recent maxDisk lines, but if
// that slice alone still exceeds ~80% of the byte budget, drop further
// lines (oldest first) until it fits. Always retains at least one line.
const keep = lines.slice(-this.maxDisk);
let keepBytes = Buffer.byteLength(keep.join('\n') + '\n', 'utf8');
const byteCeiling = Math.floor(this.trimSizeLimit * TRIM_TARGET_FACTOR);
while (keep.length > 1 && keepBytes > byteCeiling) {
keepBytes -= Buffer.byteLength(keep.shift() + '\n', 'utf8');
}
const kept = keep.join('\n') + '\n';
const tmp = this.filePath + '.tmp';
fs.writeFile(tmp, kept, 'utf8', (e) => {
if (e) {
this.log.error?.('security', 'trim write failed', { error: e.message });
return;
}
fs.rename(tmp, this.filePath, (e2) => {
if (e2) this.log.error?.('security', 'trim rename failed', { error: e2.message });
});
if (e) return done(e);
fs.rename(tmp, this.filePath, done);
});
});
}
@@ -230,6 +288,13 @@ class SecurityEventStore extends EventEmitter {
/**
* Query events. All filters are AND-combined. Results are newest-first.
*
* `total` is the true count of ALL matching events in the memory window
* (DC-116 fix: the loop previously broke at offset+limit, so `total` was
* silently capped at the page size — the dashboard's "N events (24h)" stat
* and hosts/:id/health events_24h read 1000 when the real count was tens
* of thousands). The scan now always completes; per-page cost is bounded
* by the in-memory cap (maxMemory, default 10k).
*
* @param {object} q - query
* limit : number, default 100, max 1000
* offset : number, default 0
@@ -249,7 +314,8 @@ class SecurityEventStore extends EventEmitter {
const severities = this._toArr(q.severity);
const outcomes = this._toArr(q.outcome);
const matches = [];
let total = 0;
const page = [];
for (const ev of this.events) {
if (sourceTypes.length && !sourceTypes.includes(ev.source_type)) continue;
if (q.source_host && ev.source_host !== q.source_host) continue;
@@ -261,13 +327,13 @@ class SecurityEventStore extends EventEmitter {
if (q.since && ev.ts < q.since) continue;
if (q.until && ev.ts >= q.until) continue;
if (q.target && ev.target !== q.target) continue;
matches.push(ev);
if (matches.length >= offset + limit) break; // avoid scanning further
total++;
if (total > offset && page.length < limit) page.push(ev);
}
return {
total: matches.length,
events: matches.slice(offset, offset + limit),
total,
events: page,
};
}