llm-security/tests/lib/skill-registry-atomic.test.mjs
Kjell Tore Guttormsen 207385fbbe fix(llm-security): scanner robustness — ReDoS, MCP-stdout DoS, redirect loop, atomic writes (#24,#53,#31,#25,#51)
#24 the HTML-obfuscation injection patterns had overlapping unbounded runs plus a required closing quote, so a non-closing input backtracked O(N^2) (~28.7s at the 512KB cap); quantifiers bounded, pathological input now 4ms. #53 mcp-live-inspect buffered MCP-server stdout via readline with no cap, so a hostile stdio server could exhaust memory / throw an uncaught RangeError; replaced with manual line buffering capped at 4MB that rejects pending RPCs and destroys stdout. #31 vsix-fetch's same-host redirect follower had no depth cap (loop hang); added depth>=5 cap mirroring the sibling fetcher.

#25/#51 mcp-description-cache and skill-registry wrote JSON via bare writeFileSync (non-atomic: concurrent load-modify-save loses updates, a torn read silently yields an empty registry); both now write a temp file then renameSync. Suite 1931/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
2026-07-18 10:15:11 +02:00

39 lines
1.9 KiB
JavaScript

// skill-registry-atomic.test.mjs — #51 (v7.8.3): saveRegistry must write
// atomically (temp file + rename). A torn write would parse-fail in
// loadRegistry and silently reset to an empty registry (permanent data loss).
// Zero external dependencies: node:test + node:assert only.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadRegistry, saveRegistry, registryPath } from '../../scanners/lib/skill-registry.mjs';
describe('skill-registry — atomic saveRegistry (v7.8.3 #51)', () => {
it('replaces the registry file via temp+rename (write survives a read-only target)', () => {
const root = mkdtempSync(join(tmpdir(), 'llmsec-registry-atomic-'));
try {
const registry = loadRegistry(root);
registry.entries['f'.repeat(64)] = { name: 'first', last_scanned: new Date().toISOString() };
const filePath = saveRegistry(registry, root);
assert.equal(filePath, registryPath(root));
// A direct writeFileSync to a read-only file throws EACCES. An atomic
// temp+rename replaces the file because only directory write permission
// is required.
chmodSync(filePath, 0o444);
registry.entries['a'.repeat(64)] = { name: 'second', last_scanned: new Date().toISOString() };
saveRegistry(registry, root);
const raw = JSON.parse(readFileSync(filePath, 'utf8'));
assert.equal(raw.entry_count, 2, 'expected atomic rename to replace the read-only file');
assert.ok(raw.entries['a'.repeat(64)], 'second entry persisted');
const leftovers = readdirSync(join(root, 'reports')).filter((f) => f !== 'skill-registry.json');
assert.deepEqual(leftovers, [], 'no temp files left behind');
} finally {
try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});