#21 the bash normalizer decoded only \xHH, leaving ANSI-C octal/\u/\U forms literal so canonical rm/curl never surfaced; now decodes all three. #23 file-discovery keyed on extname so .env.local/.env.example (extname .local/.example) were silently skipped; now matches multi-part suffixes. #42 a legitimate leading UTF-8 BOM was flagged HIGH (and the tool's own auto-cleaner refused to strip it); pos-0 BOM now excepted. #52 collapseLetterSpacing used a literal space, letting multi-space/tab spacing evade; now [ \t]+. #55 redact(_,60,0) did slice(-0) and leaked the whole unredacted URL; showEnd===0 now means no tail. #30 embedded base64 (const x = "<base64>") never satisfied the whole-string decode, so the SIG identity engine never saw it; added decodeEmbeddedBase64 as an OPT-IN param on normalizeForScan (default off — appending a decoded copy would double-count per-match findings, e.g. content-extractor's injection scan) and enabled it only in signature-scanner, which dedups variants. #36 signature-scanner ignored the documented sig.custom_rules_path policy option; now loads+merges custom rules through the same family filter. Suite 2004/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
97 lines
3.4 KiB
JavaScript
97 lines
3.4 KiB
JavaScript
// unicode-bom.test.mjs — Regression tests for the leading-BOM exception in
|
|
// unicode-scanner (v7.8.3, #42).
|
|
//
|
|
// A legitimate UTF-8 BOM (U+FEFF at file position 0) was flagged as a HIGH
|
|
// zero-width finding — a finding the tool's own auto-cleaner refuses to strip
|
|
// (auto-cleaner.mjs preserves cp === 0xFEFF at line 0, pos 0). The scanner
|
|
// must apply the same file-start exception. A BOM anywhere else in the file
|
|
// is still a zero-width smuggling vector and must be flagged.
|
|
|
|
import { describe, it, beforeEach, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
import { scan } from '../../scanners/unicode-scanner.mjs';
|
|
|
|
const created = [];
|
|
|
|
/** Make a scan root containing a single file with the given content. */
|
|
async function makeRepo(content, fileName = 'app.mjs') {
|
|
const root = await mkdtemp(join(tmpdir(), 'llmsec-bom-'));
|
|
created.push(root);
|
|
await writeFile(join(root, fileName), content, 'utf8');
|
|
return root;
|
|
}
|
|
|
|
async function scanRepo(root) {
|
|
resetCounter();
|
|
const discovery = await discoverFiles(root);
|
|
return scan(root, discovery);
|
|
}
|
|
|
|
function zeroWidthFindings(result) {
|
|
return result.findings.filter(f =>
|
|
f.title.toLowerCase().includes('zero-width') ||
|
|
(f.evidence && f.evidence.toUpperCase().includes('U+FEFF'))
|
|
);
|
|
}
|
|
|
|
after(async () => {
|
|
for (const dir of created) await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('unicode-scanner — leading UTF-8 BOM exception (v7.8.3, #42)', () => {
|
|
beforeEach(() => {
|
|
resetCounter();
|
|
});
|
|
|
|
it('does not flag a legitimate BOM at file position 0', async () => {
|
|
const root = await makeRepo('\uFEFFconst x = 1;\nexport default x;\n');
|
|
const result = await scanRepo(root);
|
|
assert.equal(result.status, 'ok');
|
|
const hits = zeroWidthFindings(result);
|
|
assert.equal(
|
|
hits.length, 0,
|
|
`leading BOM must not be flagged, got: ${hits.map(f => f.title).join('; ')}`
|
|
);
|
|
});
|
|
|
|
it('still flags a BOM at the start of a later line', async () => {
|
|
const root = await makeRepo('const x = 1;\n\uFEFFconst y = 2;\n');
|
|
const result = await scanRepo(root);
|
|
assert.equal(result.status, 'ok');
|
|
const hits = zeroWidthFindings(result);
|
|
assert.ok(
|
|
hits.length >= 1,
|
|
`mid-file BOM must be flagged, got ${hits.length} findings`
|
|
);
|
|
assert.equal(hits[0].line, 2);
|
|
});
|
|
|
|
it('still flags a BOM mid-line on the first line', async () => {
|
|
const root = await makeRepo('const x\uFEFF = 1;\n');
|
|
const result = await scanRepo(root);
|
|
assert.equal(result.status, 'ok');
|
|
const hits = zeroWidthFindings(result);
|
|
assert.ok(
|
|
hits.length >= 1,
|
|
`first-line non-leading BOM must be flagged, got ${hits.length} findings`
|
|
);
|
|
});
|
|
|
|
it('still flags other zero-width chars on the first line after a BOM', async () => {
|
|
const root = await makeRepo('\uFEFFconst x\u200B = 1;\n');
|
|
const result = await scanRepo(root);
|
|
assert.equal(result.status, 'ok');
|
|
const hits = result.findings.filter(f =>
|
|
f.evidence && f.evidence.toUpperCase().includes('U+200B')
|
|
);
|
|
assert.ok(
|
|
hits.length >= 1,
|
|
`U+200B after a leading BOM must still be flagged, got ${hits.length} findings`
|
|
);
|
|
});
|
|
});
|