diff --git a/src/agents/security/glasswally/index.ts b/src/agents/security/glasswally/index.ts index 33135a6..ce0efb6 100644 --- a/src/agents/security/glasswally/index.ts +++ b/src/agents/security/glasswally/index.ts @@ -108,6 +108,14 @@ const MAX_BYTES_PER_TICK = 1024 * 1024; // 1 MB // Maximum JSONL line size accepted — well below Glasswally's 4 MB internal limit const MAX_LINE_BYTES = 65_536; // 64 KB +// Maximum bytes the partial-line buffer may hold between ticks. A single +// physical line larger than this can never be a valid record (records are +// capped at MAX_LINE_BYTES), so it is treated as unrecoverable. Without this +// cap a Glasswally write that never emits a newline — a truncated mid-entry +// write, or a compromised Glasswally — grows lineBuffer by up to +// MAX_BYTES_PER_TICK every tick, unbounded, exhausting memory. (STRIDE D-6) +const MAX_LINE_BUFFER = 1024 * 1024; // 1 MB + // Maximum IOC bundle file size const MAX_BUNDLE_BYTES = 50 * 1024 * 1024; // 50 MB @@ -150,6 +158,9 @@ export default class GlasswallyAgent extends Agent { // File tailing state private enforcementOffset = 0; private lineBuffer = ''; // accumulates partial lines between reads + // When an oversized physical line is discarded, bytes are dropped until the + // next newline so parsing resumes cleanly at the following record. + private skipOversizedLine = false; // Per-minute rate limiting for alert ingestion private alertCount = 0; @@ -528,6 +539,7 @@ export default class GlasswallyAgent extends Agent { if (currentSize < offset) { this.log('info', `File rotation detected, resetting offset: ${filePath}`); this.lineBuffer = ''; + this.skipOversizedLine = false; offset = 0; } @@ -546,13 +558,48 @@ export default class GlasswallyAgent extends Agent { if (bytesRead === 0) return { lines: [], newOffset: offset }; - // Prepend any partial line held from the previous tick - const text = this.lineBuffer + buf.subarray(0, bytesRead).toString('utf-8'); + const chunk = buf.subarray(0, bytesRead).toString('utf-8'); + + let text: string; + if (this.skipOversizedLine) { + // Discarding the tail of an oversized physical line. Drop everything up + // to and including the next newline, then resume parsing after it. + const nl = chunk.indexOf('\n'); + if (nl === -1) { + // Still inside the oversized line — discard this entire chunk. + return { lines: [], newOffset: offset + bytesRead }; + } + this.skipOversizedLine = false; + text = chunk.slice(nl + 1); + } else { + // Prepend any partial line held from the previous tick + text = this.lineBuffer + chunk; + } + const parts = text.split('\n'); // The last segment may be an incomplete line — hold it for the next tick this.lineBuffer = parts.pop() ?? ''; + // Cap the partial-line buffer. A line larger than MAX_LINE_BUFFER can + // never be a valid record (records are capped at MAX_LINE_BYTES), so the + // remainder of this physical line is unrecoverable: discard the buffer and + // skip bytes until the next newline. Complete lines parsed above are + // unaffected — only the oversized trailing fragment is dropped. (STRIDE D-6) + if (this.lineBuffer.length > MAX_LINE_BUFFER) { + this.log('warn', 'Glasswally partial-line buffer exceeded cap — line discarded as unrecoverable', { + size: this.lineBuffer.length, + cap: MAX_LINE_BUFFER, + }); + AuditLogger.log({ + agentId: this.id, + event: 'security.glasswally_line_buffer_overflow', + metadata: { size: this.lineBuffer.length, cap: MAX_LINE_BUFFER }, + }); + this.lineBuffer = ''; + this.skipOversizedLine = true; + } + const lines = parts.filter((l) => l.trim().length > 0); return { lines, newOffset: offset + bytesRead }; } diff --git a/src/security/audit-log.ts b/src/security/audit-log.ts index ce9c1e6..80bee46 100644 --- a/src/security/audit-log.ts +++ b/src/security/audit-log.ts @@ -52,6 +52,7 @@ export type AuditEventType = | 'safety.emergency_stop' | 'incident.detected' | 'security.glasswally_rate_limited' + | 'security.glasswally_line_buffer_overflow' | 'security.ioc_bundle_tampered'; export interface AuditEntry { diff --git a/tests/security/glasswally-line-buffer.test.ts b/tests/security/glasswally-line-buffer.test.ts new file mode 100644 index 0000000..2f14bb6 --- /dev/null +++ b/tests/security/glasswally-line-buffer.test.ts @@ -0,0 +1,149 @@ +/** + * GlasswallyAgent — Partial-Line Buffer Cap (STRIDE D-6) + * + * NIST AI RMF 1.0 — MANAGE (MG-4.1) — denial-of-service resilience + * + * Run: npx jest tests/security/glasswally-line-buffer.test.ts --verbose + * + * Threat: Glasswally is a separate privileged process. If it writes to + * enforcement_actions.jsonl without ever emitting a newline — a write + * truncated mid-entry, or a compromised Glasswally — GlasswallyAgent's + * partial-line buffer would grow by up to MAX_BYTES_PER_TICK every tick, + * unbounded, exhausting the EverythingOS process memory. STRIDE D-6 declared + * this mitigated ("lineBuffer capped"); this suite proves the cap exists, + * bounds memory, and recovers cleanly at the next valid record. + */ + +import { mkdtempSync, writeFileSync, appendFileSync, rmSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import GlasswallyAgent from '../../src/agents/security/glasswally/index'; +import { AuditLogger } from '../../src/security/audit-log'; + +const MAX_LINE_BUFFER = 1024 * 1024; // mirrors the constant in glasswally/index.ts + +// readNewLines is private; exercise it directly without starting the agent +// (it performs no EventBus publishes, so no auth token is required). +type TailResult = { lines: string[]; newOffset: number }; +interface TailInternals { + readNewLines(filePath: string, offset: number): TailResult; + lineBuffer: string; + skipOversizedLine: boolean; +} +const internals = (a: GlasswallyAgent): TailInternals => a as unknown as TailInternals; + +describe('GlasswallyAgent partial-line buffer cap (STRIDE D-6)', () => { + let outputDir: string; + let filePath: string; + let agent: GlasswallyAgent; + let auditSpy: jest.SpyInstance; + + beforeEach(() => { + outputDir = mkdtempSync(join(tmpdir(), 'glasswally-test-')); + filePath = join(outputDir, 'enforcement_actions.jsonl'); + agent = new GlasswallyAgent({ outputDir, iocSecret: 'test-secret' }); + auditSpy = jest.spyOn(AuditLogger, 'log').mockImplementation((e) => e as never); + }); + + afterEach(() => { + auditSpy.mockRestore(); + rmSync(outputDir, { recursive: true, force: true }); + }); + + // Drains the file through readNewLines exactly as onTick would, one + // MAX_BYTES_PER_TICK read at a time, until the offset reaches EOF. + function drain(start: number): { lines: string[]; offset: number } { + let offset = start; + const lines: string[] = []; + // Bounded loop — each iteration advances by ≥1 byte or stops at EOF. + for (let i = 0; i < 64; i++) { + const size = statSync(filePath).size; + if (offset >= size) break; + const r = internals(agent).readNewLines(filePath, offset); + lines.push(...r.lines); + if (r.newOffset === offset) break; + offset = r.newOffset; + } + return { lines, offset }; + } + + test('an unterminated multi-megabyte line never grows the buffer past the cap', () => { + // 4 MB of non-newline bytes — a Glasswally write that never terminates. + writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x41)); + + const { lines, offset } = drain(0); + + expect(lines).toHaveLength(0); // no complete record was ever emitted + expect(internals(agent).lineBuffer.length).toBeLessThanOrEqual(MAX_LINE_BUFFER); + // The whole 4 MB blob was consumed without retaining it. + expect(offset).toBe(statSync(filePath).size); + expect(internals(agent).skipOversizedLine).toBe(true); + + const overflow = auditSpy.mock.calls.find( + (c) => c[0]?.event === 'security.glasswally_line_buffer_overflow', + ); + expect(overflow).toBeDefined(); + expect(overflow![0].metadata.cap).toBe(MAX_LINE_BUFFER); + }); + + test('parsing recovers at the next valid record after an oversized line is discarded', () => { + writeFileSync(filePath, Buffer.alloc(3 * 1024 * 1024, 0x41)); + const afterGarbage = drain(0).offset; + expect(internals(agent).skipOversizedLine).toBe(true); + + const record = JSON.stringify({ + action_type: 'RateLimit', + account_id: 'acct-recovered', + reason: 'velocity threshold exceeded', + composite_score: 0.61, + timestamp: '2026-05-18T00:00:00Z', + }); + // The oversized line is finally terminated, followed by a clean record. + appendFileSync(filePath, '\n' + record + '\n'); + + const { lines } = drain(afterGarbage); + + expect(lines).toContain(record); + expect(internals(agent).skipOversizedLine).toBe(false); + expect(internals(agent).lineBuffer).toBe(''); + }); + + test('complete records preceding an oversized fragment are still delivered', () => { + const good = JSON.stringify({ + action_type: 'FlagForReview', + account_id: 'acct-good', + reason: 'flagged', + composite_score: 0.4, + timestamp: '2026-05-18T00:00:00Z', + }); + // valid line + newline, then a 3 MB unterminated fragment in one file + writeFileSync(filePath, good + '\n' + 'B'.repeat(3 * 1024 * 1024)); + + const { lines } = drain(0); + + expect(lines).toContain(good); + expect(internals(agent).lineBuffer.length).toBeLessThanOrEqual(MAX_LINE_BUFFER); + expect(internals(agent).skipOversizedLine).toBe(true); + }); + + test('normal tailing is unaffected — buffer stays empty between whole lines', () => { + const rec = (id: string) => + JSON.stringify({ + action_type: 'FlagForReview', + account_id: id, + reason: 'r', + composite_score: 0.4, + timestamp: '2026-05-18T00:00:00Z', + }); + writeFileSync(filePath, rec('a') + '\n' + rec('b') + '\n'); + + const { lines } = drain(0); + + expect(lines).toEqual([rec('a'), rec('b')]); + expect(internals(agent).lineBuffer).toBe(''); + expect(internals(agent).skipOversizedLine).toBe(false); + expect( + auditSpy.mock.calls.some((c) => c[0]?.event === 'security.glasswally_line_buffer_overflow'), + ).toBe(false); + }); +});