Defeats split-and-substitute evasion where attackers split a destructive
command name across an assignment and a variable reference (X=rm; later
$X) so downstream regex gates miss the literal command name. T9 collects
prefix assignments (VAR=value at start of string or after ; & |) and
substitutes ${VAR} / $VAR forms with the captured value. One-level
forward-flow only — chained vars are not followed.
Documented limits in JSDoc:
- Quoted assignments (X="rm -rf") not parsed (whitespace stops capture)
- Substitution is global within string, not scoped. Acceptable because
T3 strips unknown ${VAR} to '' afterwards.
Single-quoted literals are masked before T9 runs, so legitimate
strings are preserved (FP probe in tests).
7 new tests in bash-normalize-t7-t9.test.mjs.
Closes E10 in critical-review-2026-04-20.md.
78 lines
3.8 KiB
JavaScript
78 lines
3.8 KiB
JavaScript
// bash-normalize-t7-t9.test.mjs — Tests for T7 (process substitution),
|
|
// T9 (eval-via-variable) normalizations added in v7.3.0 (Batch C).
|
|
//
|
|
// T7 lives in bash-normalize.mjs (this test exercises it via
|
|
// normalizeBashExpansion). T8 (base64-pipe-shell) lives in
|
|
// pre-bash-destructive.mjs and is covered by that hook's test file —
|
|
// not here.
|
|
//
|
|
// Includes false-positive probes to guard against over-broad expansion.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
|
|
|
|
describe('bash-normalize T9 — eval-via-variable evasion', () => {
|
|
it('substitutes "$X" reference after X=rm assignment', () => {
|
|
// Attacker splits the destructive command name across an assignment
|
|
// and an eval. Without T9, downstream regex sees only `eval "$X"`.
|
|
const input = 'X=rm; eval "$X" -rf /';
|
|
const normalized = normalizeBashExpansion(input);
|
|
assert.match(normalized, /\brm\b/, `expected 'rm' in normalized output: ${normalized}`);
|
|
});
|
|
|
|
it('substitutes ${X} curly form: X=rm; eval "${X}" -rf', () => {
|
|
const input = 'X=rm; eval "${X}" -rf /';
|
|
const normalized = normalizeBashExpansion(input);
|
|
assert.match(normalized, /\brm\b/, `expected 'rm' in normalized output: ${normalized}`);
|
|
});
|
|
|
|
it('substitutes bare $X form (no quotes): X=rm; eval $X -rf', () => {
|
|
const input = 'X=rm; eval $X -rf /';
|
|
const normalized = normalizeBashExpansion(input);
|
|
assert.match(normalized, /\brm\b/, `expected 'rm' in normalized output: ${normalized}`);
|
|
});
|
|
|
|
it('one-level only — does NOT follow chained vars (Y=X; X=rm; eval "$Y")', () => {
|
|
// Multi-level chained vars are explicitly NOT followed in T9.
|
|
// Y resolves to literal "X", not to "rm". This is a documented limit;
|
|
// the test guards against accidental recursion.
|
|
const input = 'Y=X; X=rm; eval "$Y" -rf /';
|
|
const normalized = normalizeBashExpansion(input);
|
|
// After substitution: Y=X; X=rm; eval "X" -rf /
|
|
// $Y resolves one level only — it becomes the literal "X", NOT the
|
|
// value of $X. Multi-level chained vars are not followed.
|
|
assert.match(normalized, /eval "?X"?\b/, `expected one-level eval target = literal 'X': ${normalized}`);
|
|
});
|
|
|
|
it('leaves unrelated $UNKNOWN_VAR alone (handled by T3)', () => {
|
|
// No assignment for $TARGET; T9 does not touch it. T3 will strip
|
|
// ${TARGET} to '' but the bare $TARGET is left as-is by T3 too
|
|
// (T3 only handles ${...} forms). T9 is a no-op for unknowns.
|
|
const input = 'eval "$TARGET" -rf /';
|
|
const normalized = normalizeBashExpansion(input);
|
|
// T9 leaves $TARGET alone (no assignment exists). Result still
|
|
// contains the literal $TARGET reference (no substitution happened).
|
|
assert.match(normalized, /\$TARGET/, `expected unresolved $TARGET: ${normalized}`);
|
|
});
|
|
});
|
|
|
|
describe('bash-normalize T9 — false-positive probes', () => {
|
|
it('does not substitute inside single-quoted literals: echo \'$X\' stays as-is', () => {
|
|
// Single-quoted literals are masked before T9 runs, so $X inside
|
|
// them is preserved. Guards the destructive-name detector from
|
|
// corrupting legitimate strings that mention bash variables.
|
|
const input = "X=rm; echo '$X is dangerous'";
|
|
const normalized = normalizeBashExpansion(input);
|
|
assert.match(normalized, /'\$X is dangerous'/,
|
|
`expected single-quoted literal preserved: ${normalized}`);
|
|
});
|
|
|
|
it('handles command with no assignments — pure passthrough', () => {
|
|
const input = 'eval "$X" -rf /';
|
|
const normalized = normalizeBashExpansion(input);
|
|
// No X=... seen, so $X is left alone. (Consumer of this layer
|
|
// sees the unresolved reference and decides what to do.)
|
|
assert.match(normalized, /\$X/, `expected $X unresolved: ${normalized}`);
|
|
});
|
|
});
|