From f0de8f454110a39ba54b405df1a080ef27b64e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 02:52:32 +0000 Subject: [PATCH] security: enforce GlasswallyAgent partial-line buffer cap (STRIDE D-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRIDE D-6 declared the GlasswallyAgent line buffer "capped", but no cap existed. Glasswally is a separate privileged process; a write that never emits a newline (truncated mid-entry, or a compromised Glasswally) grew lineBuffer by up to MAX_BYTES_PER_TICK every tick, unbounded, exhausting the EverythingOS process memory — a fail-open DoS on a security control. Add MAX_LINE_BUFFER (1 MB): a partial line exceeding it can never be a valid record (records cap at 64 KB), so it is discarded as unrecoverable and bytes are skipped until the next newline, resuming cleanly at the following record. Complete lines preceding the oversized fragment are still delivered. The discard is audit-logged. Adds a regression suite proving the attack path is bounded and recovers. https://claude.ai/code/session_01ArAvRMiZgCwF5oNj3r94Ap --- src/agents/security/glasswally/index.ts | 51 +++++- src/security/audit-log.ts | 1 + tests/security/glasswally-line-buffer.test.ts | 149 ++++++++++++++++++ 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tests/security/glasswally-line-buffer.test.ts 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); + }); +});