fix(llm-security): HIGH — obfuscated injections were reported but not stripped

stripInjection is the remote-scan indirection layer: its `sanitized`
output is what an LLM agent actually reads, verbatim, via
sanitized_content in the evidence package. It scanned two variants of
each file — the raw text and normalizeForScan(text) — but removed
matches with `sanitized.replace(match[0], ...)` against the RAW text
only.

For a match found in the decoded variant, match[0] IS the decoded
string, which by construction does not occur in the raw text. The
replace was therefore a silent no-op: the finding was reported while the
encoded payload was passed to the agent untouched. Every obfuscation the
normalizer exists to defeat — HTML entities, URL encoding, \u escapes,
hex, base64, letter-spacing, Unicode tags — reached the agent intact.
The worst case is the intended one: detection said "critical injection
found" and shipped the injection along with the verdict.

- Pass 1 redacts the whole source LINE whose own normalized form carries
  the pattern. Line granularity is deliberate: decoding is not
  length-preserving, so decoded match offsets cannot be mapped back onto
  the original text.
- Pass 2 keeps the existing literal replacement and finding collection.
- Residual gap, made explicit rather than silent: a payload encoded
  across MULTIPLE lines matches whole-text normalization but no single
  line, so it cannot be attributed. Those findings now carry
  `unstripped: true`. Whole-file redaction was considered and rejected —
  normalizeForScan base64-decodes any long blob, so a benign asset could
  blank an entire file's evidence.
- stripInjection exported via __testing, and main() is now behind the
  standard isMain guard (copied from dashboard-aggregator.mjs) so
  importing the module does not execute the CLI. CLI verified unchanged
  against the evil-project-health fixture: 7 files, 6 injection
  findings, risk_level critical.

This boundary had no direct test coverage before this commit.

npm test: 1890/1890 green (1884 + 6 new).

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:
Kjell Tore Guttormsen 2026-07-18 09:18:56 +02:00
commit 4f21374e8c
2 changed files with 150 additions and 12 deletions

View file

@ -7,6 +7,7 @@
import { writeFileSync } from 'node:fs';
import { resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { discoverFiles, readTextFile } from './lib/file-discovery.mjs';
import { CRITICAL_PATTERNS, HIGH_PATTERNS } from './lib/injection-patterns.mjs';
import { normalizeForScan } from './lib/string-utils.mjs';
@ -94,10 +95,36 @@ function parseArgs(argv) {
return args;
}
/** Strip injection patterns from text, return sanitized text + findings */
const STRIP_MARKER = 'INJECTION-PATTERN-STRIPPED';
/** Fresh global regex per use — the shared pattern objects carry /g lastIndex state. */
function toGlobal(pattern) {
return new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
}
/**
* Strip injection patterns from text, return sanitized text + findings.
*
* `sanitized` is what an LLM agent ultimately sees (via sanitized_content in
* the evidence package), so detection alone is not enough a pattern that is
* reported but left in the text defeats the whole indirection layer.
*
* Two removal mechanisms, because matches arrive in two forms:
* 1. Raw matches match[0] occurs literally in the text, replaced in place.
* 2. Decoded-only matches the pattern is visible only after normalizeForScan
* (HTML entities, URL encoding, \u escapes, base64, letter-spacing). Here
* match[0] is the DECODED string, which by definition does NOT occur in the
* raw text, so a literal replace silently does nothing. These are removed by
* redacting the whole source LINE whose own normalized form carries the
* pattern line granularity avoids mapping decoded offsets back onto the
* original, which decoding makes non-invertible.
*
* Residual gap: a payload encoded ACROSS several lines matches the whole-text
* normalization but no individual line, so it cannot be attributed and is
* reported with `unstripped: true` rather than silently left behind.
*/
function stripInjection(text, file) {
const findings = [];
let sanitized = text;
const normalized = normalizeForScan(text);
const isDifferent = normalized !== text;
@ -106,17 +133,40 @@ function stripInjection(text, file) {
...HIGH_PATTERNS.map(p => ({ ...p, severity: 'high' })),
];
for (const { pattern, label, severity } of allPatterns) {
// Need fresh regex per match (some have /g, some don't)
const globalPattern = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
// --- Pass 1: line redaction for decoded-only matches -------------------
// Runs first so that line indices still line up with the original text.
const lines = text.split('\n');
const normalizedLines = isDifferent ? lines.map(l => normalizeForScan(l)) : [];
const attributed = new Set();
if (isDifferent) {
for (const { pattern, label } of allPatterns) {
for (let i = 0; i < lines.length; i++) {
// Line unchanged by decoding → any match is literal, Pass 2 handles it.
if (normalizedLines[i] === lines[i]) continue;
if (lines[i].includes(STRIP_MARKER)) continue;
if (toGlobal(pattern).test(normalizedLines[i])) {
lines[i] = `[${STRIP_MARKER}: ${label}]`;
attributed.add(label);
}
}
}
}
let sanitized = lines.join('\n');
// --- Pass 2: literal replacement + finding collection ------------------
for (const { pattern, label, severity } of allPatterns) {
for (const variant of (isDifferent ? [text, normalized] : [text])) {
const globalPattern = toGlobal(pattern);
let match;
while ((match = globalPattern.exec(variant)) !== null) {
const line = variant.substring(0, match.index).split('\n').length;
findings.push({ file, line, label, severity });
// Replace in sanitized text (use original pattern position)
sanitized = sanitized.replace(match[0], `[INJECTION-PATTERN-STRIPPED: ${label}]`);
const finding = { file, line, label, severity };
const before = sanitized;
sanitized = sanitized.replace(match[0], `[${STRIP_MARKER}: ${label}]`);
// Neither a literal replace nor a line redaction removed this one.
if (sanitized === before && !attributed.has(label)) finding.unstripped = true;
findings.push(finding);
}
}
}
@ -237,6 +287,12 @@ function scanDescForInjection(text) {
// Main
// ---------------------------------------------------------------------------
// Internal exports for unit testing only — not a stable API.
// stripInjection is the remote-scan injection boundary: everything an LLM agent
// sees passes through it. It is exported so regression tests can drive it
// directly rather than inferring its behaviour from CLI output.
export const __testing = { stripInjection };
async function main() {
const startTime = Date.now();
const { target, outputFile } = parseArgs(process.argv.slice(2));
@ -417,7 +473,12 @@ async function main() {
}
}
main().catch(err => {
console.error(`content-extractor: ${err.message}`);
process.exit(1);
});
// Only run the CLI when invoked directly — importing this module (tests) must
// not execute main(). Same guard as dashboard-aggregator.mjs.
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
main().catch(err => {
console.error(`content-extractor: ${err.message}`);
process.exit(1);
});
}