ktg-plugin-marketplace/plugins/llm-security/tests/scanners/taint-destructuring.test.mjs
Kjell Tore Guttormsen 68b9ea2692 fix(taint-tracer): B6 — recognize destructuring + spread + rest patterns
Critical-review §2 B6 finding: extractAssignedVariable handled
`const X = ...` and `X = ...` but missed every modern JS/TS
destructuring pattern. Sinks downstream of destructured/spread vars
produced false negatives at the propagation step.

Patterns now recognized:
- `const { x } = source`               object destructuring
- `const { x, y } = source`            multi-key
- `const { secret: alias } = source`   renamed (key NOT bound)
- `const { x, ...spread } = source`    object rest
- `const { a, b: { c } } = source`     nested object (key NOT bound)
- `const [a, b] = source`              array destructuring
- `const [first, ...rest] = source`    array rest
- `const [a, [b, c]] = source`         nested array
- `const { user: { id }, ...rest }`    mixed nested

Implementation: regex-based two-pass walker. Pass 1 detects whether
the LHS is a destructuring pattern (`{...}` or `[...]`). If yes, the
new `extractDestructuredNames` helper walks the pattern body via a
balanced-bracket depth counter, recurses into nested patterns, and
distinguishes keys (`key:`) from bindings. If no, the plain-decl
branch matches `\b(?:const|let|var)\s+(\w+)`.

Plain-assignment branch (`X = ...` without keyword) and Python-style
patterns are unchanged.

The function is now exported for direct unit testing — same pattern
as `_resetCacheForTest` in policy-loader. The internal walker
(`extractDestructuredNames`) remains module-private.

Tests: +19 cases in tests/scanners/taint-destructuring.test.mjs:
  - 5 pre-B6 patterns (regression guard: plain decl, plain assign,
    no-match on equality)
  - 12 destructuring patterns covering object/array/rest/nested
  - 2 non-destructuring regressions (return literal, arrow param)

Existing taint-tracer.test.mjs and taint.test.mjs unchanged — both
green (14 → 14, fixture-based integration tests not affected).

Suite: 1551 → 1570 (+19). All green.
2026-04-29 14:05:34 +02:00

128 lines
5.5 KiB
JavaScript

// taint-destructuring.test.mjs — B6 (v7.2.0) — taint-tracer destructuring + spread
//
// Critical-review §2 B6 finding: extractAssignedVariable handled `const X`
// and `X = ...` but missed every modern destructuring pattern. As a result,
// taint flows through destructured/spread variables produced false negatives
// at the propagation step (same-line sink detection still worked).
//
// This test pins the B6 fix at the unit-test level. The taint-tracer now
// exports `extractAssignedVariable` for direct testing — same pattern as
// policy-loader's `_resetCacheForTest`.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractAssignedVariable } from '../../scanners/taint-tracer.mjs';
describe('extractAssignedVariable — pre-B6 patterns (regression guard)', () => {
it('plain const declaration: const x = req.body', () => {
const names = extractAssignedVariable('const x = req.body;');
assert.deepEqual(names, ['x']);
});
it('plain let declaration: let y = process.argv[1]', () => {
const names = extractAssignedVariable('let y = process.argv[1];');
assert.deepEqual(names, ['y']);
});
it('plain var declaration: var z = user_input', () => {
const names = extractAssignedVariable('var z = user_input;');
assert.deepEqual(names, ['z']);
});
it('plain assignment: tainted = req.body', () => {
const names = extractAssignedVariable('tainted = req.body;');
assert.deepEqual(names, ['tainted']);
});
it('does not match equality: x == req.body', () => {
const names = extractAssignedVariable('if (x == req.body) {}');
assert.equal(names.length, 0);
});
});
describe('extractAssignedVariable — B6 destructuring patterns', () => {
it('object destructuring: const { x } = req.body', () => {
const names = extractAssignedVariable('const { x } = req.body;');
assert.deepEqual(names.sort(), ['x']);
});
it('object destructuring multi-key: const { x, y } = req.body', () => {
const names = extractAssignedVariable('const { x, y } = req.body;');
assert.deepEqual(names.sort(), ['x', 'y']);
});
it('renamed destructuring: const { secret: alias } = req.body', () => {
const names = extractAssignedVariable('const { secret: alias } = req.body;');
assert.deepEqual(names.sort(), ['alias']);
// The key `secret` is NOT a binding — only `alias` is.
assert.ok(!names.includes('secret'), 'key (secret) must not be a binding');
});
it('object rest: const { x, ...spread } = req.body', () => {
const names = extractAssignedVariable('const { x, ...spread } = req.body;');
assert.deepEqual(names.sort(), ['spread', 'x']);
});
it('nested object destructuring: const { a, b: { c } } = req.body', () => {
const names = extractAssignedVariable('const { a, b: { c } } = req.body;');
assert.deepEqual(names.sort(), ['a', 'c']);
// `b` is a key — not a binding.
assert.ok(!names.includes('b'), 'key (b) must not be a binding');
});
it('array destructuring: const [a, b] = process.argv', () => {
const names = extractAssignedVariable('const [a, b] = process.argv;');
assert.deepEqual(names.sort(), ['a', 'b']);
});
it('array rest: const [first, ...rest] = process.argv', () => {
const names = extractAssignedVariable('const [first, ...rest] = process.argv;');
assert.deepEqual(names.sort(), ['first', 'rest']);
});
it('nested array destructuring: const [a, [b, c]] = source', () => {
const names = extractAssignedVariable('const [a, [b, c]] = source;');
assert.deepEqual(names.sort(), ['a', 'b', 'c']);
});
it('mixed destructuring: const { user: { id }, ...rest } = req.body', () => {
const names = extractAssignedVariable('const { user: { id }, ...rest } = req.body;');
assert.deepEqual(names.sort(), ['id', 'rest']);
// `user` is a key — not a binding.
assert.ok(!names.includes('user'), 'key (user) must not be a binding');
});
it('let with destructuring: let { x } = source', () => {
const names = extractAssignedVariable('let { x } = source;');
assert.deepEqual(names.sort(), ['x']);
});
it('var with destructuring: var [a] = source', () => {
const names = extractAssignedVariable('var [a] = source;');
assert.deepEqual(names.sort(), ['a']);
});
it('default value in destructuring: const { x = 5 } = source', () => {
// The destructured binding `x` is still bound; the default `= 5` is on
// the RHS of `:` if absent or just trails the ident. The walker treats
// `=` and any literal that follows as separators.
const names = extractAssignedVariable('const { x = 5 } = source;');
assert.ok(names.includes('x'), `expected x in ${JSON.stringify(names)}`);
});
});
describe('extractAssignedVariable — non-destructuring pre-B6 regressions', () => {
it('does not falsely add destructure-shaped tokens from non-decl lines', () => {
// `if ({ x } === ...)` is invalid JS but the regex must not match it
// as a binding. Same for object literals on the RHS without `const`.
const names = extractAssignedVariable('return { x: 1 };');
assert.equal(names.length, 0, `non-decl produced unexpected names: ${JSON.stringify(names)}`);
});
it('does not match arrow function param destructuring without const', () => {
// `({x}) => ...` is a destructure but not an assignment — skip.
const names = extractAssignedVariable('const fn = ({x}) => x;');
// The plain-decl branch still picks `fn` (the function-binding name).
assert.deepEqual(names, ['fn']);
});
});