fix(llm-security): normalization/discovery evasion + SIG embedded-base64 & custom rules (#21,#23,#30,#36,#42,#52,#55)
#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
This commit is contained in:
parent
b224e18b42
commit
21c6c2b534
11 changed files with 602 additions and 42 deletions
76
tests/lib/file-discovery.test.mjs
Normal file
76
tests/lib/file-discovery.test.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// file-discovery.test.mjs — Tests for scanners/lib/file-discovery.mjs
|
||||
// Zero external dependencies: node:test + node:assert only.
|
||||
//
|
||||
// Regression focus (v7.8.3, #23): discovery keyed on extname(name), but
|
||||
// extname('.env.local') === '.local' and extname('.env.example') === '.example',
|
||||
// so the multi-part entries in TEXT_EXTENSIONS were dead and these common
|
||||
// secret files were silently skipped.
|
||||
|
||||
import { describe, it, 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 { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
|
||||
const created = [];
|
||||
|
||||
async function makeRepo(files) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'llmsec-disc-'));
|
||||
created.push(root);
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
await writeFile(join(root, name), content, 'utf8');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
for (const dir of created) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('file-discovery — multi-part extensions (v7.8.3, #23)', () => {
|
||||
it('discovers .env.local and .env.example', async () => {
|
||||
const root = await makeRepo({
|
||||
'.env.local': 'API_KEY=sk-local-secret\n',
|
||||
'.env.example': 'API_KEY=changeme\n',
|
||||
'.env': 'API_KEY=sk-real-secret\n',
|
||||
});
|
||||
const { files } = await discoverFiles(root);
|
||||
const relPaths = files.map(f => f.relPath);
|
||||
assert.ok(relPaths.includes('.env'), '.env must be discovered (control)');
|
||||
assert.ok(relPaths.includes('.env.local'), '.env.local must be discovered');
|
||||
assert.ok(relPaths.includes('.env.example'), '.env.example must be discovered');
|
||||
});
|
||||
|
||||
it('discovers a prefixed multi-part name like backend.env.local', async () => {
|
||||
const root = await makeRepo({
|
||||
'backend.env.local': 'DB_PASSWORD=hunter2\n',
|
||||
});
|
||||
const { files } = await discoverFiles(root);
|
||||
assert.ok(
|
||||
files.some(f => f.relPath === 'backend.env.local'),
|
||||
'backend.env.local must be discovered'
|
||||
);
|
||||
});
|
||||
|
||||
it('still skips unknown binary-ish extensions', async () => {
|
||||
const root = await makeRepo({
|
||||
'photo.png': 'not really a png but the extension rules it out\n',
|
||||
'archive.tar.gz': 'binary-ish\n',
|
||||
});
|
||||
const { files, skipped } = await discoverFiles(root);
|
||||
assert.equal(files.length, 0, 'unknown extensions must be skipped');
|
||||
assert.ok(skipped >= 2);
|
||||
});
|
||||
|
||||
it('still discovers extensionless files and plain known extensions', async () => {
|
||||
const root = await makeRepo({
|
||||
'Makefile': 'all:\n\ttrue\n',
|
||||
'index.mjs': 'export default 1;\n',
|
||||
});
|
||||
const { files } = await discoverFiles(root);
|
||||
const relPaths = files.map(f => f.relPath);
|
||||
assert.ok(relPaths.includes('Makefile'));
|
||||
assert.ok(relPaths.includes('index.mjs'));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue