ubon
Version:
Security scanner for AI-generated apps (Cursor, Lovable, Windsurf, v0). Catches hardcoded secrets, prompt injection, hallucinated imports, Server Actions / Edge runtime mistakes, and the vibe-coded vulnerabilities traditional linters miss.
373 lines • 19.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReactPatternsScanner = void 0;
const base_scanner_1 = require("./base-scanner");
const rules_1 = require("../rules");
const redact_1 = require("../utils/redact");
/**
* ReactPatternsScanner — the "vibe-coded React" class of bugs.
*
* Targets AI-generated React output: array-index keys, render-time handler
* calls, in-place state mutation, useEffect hazards (missing cleanup, async
* body, no AbortController, stale closures), lazy-init mistakes, conditional
* hooks, and JWTs in localStorage.
*
* All detections are regex/heuristic. Each finding carries a confidence in
* [0.6, 0.95] and a `confidenceReason` so the reporter can triage noise.
*/
class ReactPatternsScanner extends base_scanner_1.BaseScanner {
name = 'React Patterns Scanner';
async scan(options) {
const results = [];
this.initCache(options, 'react-patterns:1');
for await (const ctx of this.iterateFiles(options, '**/*.{jsx,tsx}', ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'coverage/**'])) {
if (this.hasFileSuppression(ctx.lines))
continue;
const cached = this.getCached(ctx.file, ctx.contentHash);
if (cached) {
results.push(...cached);
continue;
}
const fileResults = [];
fileResults.push(...this.detectIndexAsKey(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectHandlerInvokedAtRender(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectStateMutation(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectUseEffectHazards(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectUseStateEagerInit(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectConditionalHook(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectRefAssignDuringRender(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectTokenInLocalStorage(ctx.file, ctx.content, ctx.lines));
this.setCached(ctx.file, ctx.contentHash, fileResults);
results.push(...fileResults);
}
this.saveCache();
return results;
}
push(out, ruleId, file, lineIndex, lines, confidence, confidenceReason, extraMessage) {
if (this.isSuppressed(lines, lineIndex, ruleId))
return;
const meta = (0, rules_1.getRule)(ruleId)?.meta;
if (!meta)
return;
const lineText = lines[lineIndex] ?? '';
out.push(this.createResult({
type: meta.severity === 'high' ? 'error' : 'warning',
category: meta.category,
severity: meta.severity,
ruleId: meta.id,
message: extraMessage ? `${meta.message} — ${extraMessage}` : meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lineText).trim().slice(0, 200)),
confidence,
confidenceReason
}, lineText));
}
// ---- REACT001: `key={i}` / `key={index}` inside .map((_, i) => …) -----
detectIndexAsKey(file, content, lines) {
const out = [];
// Find .map((x, i) => …). Treat the identifier captured in position 2
// as the index variable; if a later `key={ident}` appears before the
// closing `)`, flag it.
const mapRegex = /\.map\s*\(\s*(?:\(|)\s*[^,)]+?\s*,\s*([A-Za-z_$][\w$]*)\s*\)?\s*=>/g;
let m;
while ((m = mapRegex.exec(content))) {
const indexVar = m[1];
// Scan forward until we find a matching `)` at depth 0 — good enough
// for JSX which nests `<Comp key={i} />` inside the arrow body.
const start = m.index + m[0].length;
let depth = 1;
let j = start;
while (j < content.length && depth > 0) {
const ch = content[j];
if (ch === '(')
depth++;
else if (ch === ')')
depth--;
j++;
}
const chunk = content.slice(start, j);
const keyRegex = new RegExp(`\\bkey\\s*=\\s*\\{\\s*${indexVar}\\s*\\}`, 'g');
let km;
while ((km = keyRegex.exec(chunk))) {
const abs = start + km.index;
const lineIndex = content.slice(0, abs).split('\n').length - 1;
this.push(out, 'REACT001', file, lineIndex, lines, 0.9, `React.map callback exposes index \`${indexVar}\` and uses it as \`key={${indexVar}}\`.`);
}
}
return out;
}
// ---- REACT002: onClick={fn()} / onChange={fn()} -----------------------
// Event attribute value is a call, not an identifier or arrow function.
detectHandlerInvokedAtRender(file, content, lines) {
const out = [];
const handlerRegex = /\bon(?:Click|Change|Submit|Blur|Focus|MouseEnter|MouseLeave|KeyDown|KeyUp|KeyPress|Input|Scroll|Touch[A-Z]\w*)\s*=\s*\{\s*([^}]+)\s*\}/g;
let m;
while ((m = handlerRegex.exec(content))) {
const inner = m[1].trim();
// Ignore: identifier, property access (this.foo), arrow functions, `()=>…`, bind/call forms.
if (!inner.includes('('))
continue;
if (/^[A-Za-z_$][\w$.]*$/.test(inner))
continue;
if (/=>/.test(inner))
continue;
if (/\.bind\(/.test(inner))
continue;
// Pattern: identifier(optionalArgs) with no arrow
if (!/^[A-Za-z_$][\w$.]*\s*\(/.test(inner))
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
this.push(out, 'REACT002', file, lineIndex, lines, 0.85, 'Event attribute binds a call expression (invoked at render) instead of a function reference.');
}
return out;
}
// ---- REACT003: arr.push(x); setArr(arr) or obj.foo = …; setObj(obj) ---
detectStateMutation(file, content, lines) {
const out = [];
// Find `const [arr, setArr] = useState(...)` pairs, then look for
// `arr.push(...)` / `arr[i] = ...` / `arr.foo = ...` followed by a
// setArr(arr) call within ~20 lines.
const pairRegex = /const\s*\[\s*([A-Za-z_$][\w$]*)\s*,\s*([A-Za-z_$][\w$]*)\s*\]\s*=\s*useState\b/g;
let m;
while ((m = pairRegex.exec(content))) {
const value = m[1];
const setter = m[2];
const searchStart = m.index + m[0].length;
const searchEnd = Math.min(content.length, searchStart + 4000);
const window = content.slice(searchStart, searchEnd);
const mutationRegex = new RegExp(`\\b${value}\\.(push|pop|shift|unshift|splice|sort|reverse)\\(|` +
`\\b${value}\\[[^\\]]+\\]\\s*=|` +
`\\b${value}\\.[A-Za-z_$][\\w$]*\\s*=(?!=)`, 'g');
let mut;
while ((mut = mutationRegex.exec(window))) {
const after = window.slice(mut.index, Math.min(window.length, mut.index + 1000));
const setterRegex = new RegExp(`\\b${setter}\\(\\s*${value}\\b`);
if (!setterRegex.test(after))
continue;
const abs = searchStart + mut.index;
const lineIndex = content.slice(0, abs).split('\n').length - 1;
this.push(out, 'REACT003', file, lineIndex, lines, 0.8, `\`${value}\` is mutated in place, then passed to its own setter \`${setter}\` — React will not re-render.`);
}
}
return out;
}
// ---- REACT004-007: useEffect hazards ---------------------------------
detectUseEffectHazards(file, content, lines) {
const out = [];
// Walk each `useEffect(` and capture the callback + dep array (best-effort).
// Not full AST — good enough for flagging the common shapes.
const effectRegex = /\buseEffect\s*\(\s*/g;
let m;
while ((m = effectRegex.exec(content))) {
const startBody = m.index + m[0].length;
// REACT007: async function passed directly. Check the first ~20 chars.
const lead = content.slice(startBody, startBody + 40).trim();
if (/^async\s*(\(|function)/.test(lead)) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
this.push(out, 'REACT007', file, lineIndex, lines, 0.95, 'Callback starts with `async` — useEffect treats the returned Promise as a cleanup function.');
}
// Find the matching close-paren of the useEffect(...) call.
let depth = 1;
let j = startBody;
let inString = null;
let inTemplate = false;
while (j < content.length && depth > 0) {
const ch = content[j];
const prev = content[j - 1];
if (inString) {
if (ch === inString && prev !== '\\')
inString = null;
}
else if (inTemplate) {
if (ch === '`' && prev !== '\\')
inTemplate = false;
}
else {
if (ch === '"' || ch === "'")
inString = ch;
else if (ch === '`')
inTemplate = true;
else if (ch === '(')
depth++;
else if (ch === ')')
depth--;
}
j++;
}
const body = content.slice(startBody, j - 1);
// Split into `callback, deps` by walking the top-level comma.
const callbackEnd = this.findTopLevelComma(body);
const callback = callbackEnd >= 0 ? body.slice(0, callbackEnd) : body;
const depsRaw = callbackEnd >= 0 ? body.slice(callbackEnd + 1).trim() : '';
const hasEmptyDeps = /^\[\s*\]/.test(depsRaw);
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
// REACT005: timer/listener without cleanup.
const startsTimer = /\b(setInterval|setTimeout|addEventListener)\s*\(/.test(callback);
if (startsTimer) {
const returnsCleanup = /\breturn\s*(?:\(\s*\)\s*=>|function\s*\()/.test(callback)
|| /\breturn\s+[A-Za-z_$][\w$]*\s*;?\s*[}\n]/.test(callback); // return handlerRef
if (!returnsCleanup) {
this.push(out, 'REACT005', file, lineIndex, lines, 0.85, 'Effect starts a timer or listener but the callback does not return a cleanup function.');
}
}
// REACT006: fetch without AbortController signal.
if (/\bfetch\s*\(/.test(callback) && !/\bsignal\s*:/.test(callback) && !/AbortController/.test(callback)) {
this.push(out, 'REACT006', file, lineIndex, lines, 0.8, 'Effect issues fetch without an AbortController signal — stray responses after unmount.');
}
// REACT004: stale closure — empty deps, callback reads an identifier
// that looks like a state/prop captured from scope (simple heuristic:
// any identifier also referenced by a `const [x, setX] = useState(...)`
// in this file).
if (hasEmptyDeps) {
const statePairs = new Set();
const pairRegex = /const\s*\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\b/g;
let pm;
while ((pm = pairRegex.exec(content)))
statePairs.add(pm[1]);
for (const name of statePairs) {
const isReferenced = new RegExp(`\\b${name}\\b`).test(callback);
const isSet = new RegExp(`\\bset${name[0].toUpperCase()}${name.slice(1)}\\s*\\(\\s*(?:prev|p|c)\\s*=>`).test(callback);
if (isReferenced && !isSet) {
this.push(out, 'REACT004', file, lineIndex, lines, 0.65, `Empty deps, but callback reads state \`${name}\` — effect snapshots the first-render value.`);
break;
}
}
}
}
return out;
}
findTopLevelComma(src) {
let depth = 0;
let inString = null;
let inTemplate = false;
for (let i = 0; i < src.length; i++) {
const ch = src[i];
const prev = src[i - 1];
if (inString) {
if (ch === inString && prev !== '\\')
inString = null;
continue;
}
if (inTemplate) {
if (ch === '`' && prev !== '\\')
inTemplate = false;
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (ch === '`') {
inTemplate = true;
continue;
}
if (ch === '(' || ch === '[' || ch === '{')
depth++;
else if (ch === ')' || ch === ']' || ch === '}')
depth--;
else if (ch === ',' && depth === 0)
return i;
}
return -1;
}
// ---- REACT008: useState(call()) (non-lazy initializer) ----------------
detectUseStateEagerInit(file, content, lines) {
const out = [];
// Flag `useState(<identifier>(...))` or `useState(<expr>.<fn>(...))`.
// Skip literals, template literals, arrow-wrapped initializers.
const regex = /useState\s*\(\s*([^)]+?)\s*\)/g;
let m;
while ((m = regex.exec(content))) {
const arg = m[1].trim();
if (!arg)
continue;
if (/^\(\s*\)\s*=>/.test(arg))
continue; // () => ...
if (/^\w+\s*=>/.test(arg))
continue; // x => ...
if (/^function\b/.test(arg))
continue;
if (/^['"`]/.test(arg))
continue; // string literal
if (/^[0-9]/.test(arg))
continue; // numeric
if (/^(true|false|null|undefined)\b/.test(arg))
continue;
if (/^\[/.test(arg) || /^\{/.test(arg))
continue; // array / object literal
// Heuristic: looks like an identifier-call (`x()` / `x.y()` / `new X()`).
if (!/[A-Za-z_$][\w$.]*\s*\(/.test(arg))
continue;
// Exclude readonly pure calls commonly seen as initializers (e.g. `Math.min(...)`)?
// We still flag — it's noise-but-correct, and we can suppress via confidence.
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
this.push(out, 'REACT008', file, lineIndex, lines, 0.65, 'Initializer is a function call — use `useState(() => ...)` so it runs only on mount.');
}
return out;
}
// ---- REACT009: conditional hook call ----------------------------------
detectConditionalHook(file, content, lines) {
const out = [];
// Very targeted: find `if (...) <ws> useX(` / `else if (...) <ws> useX(`.
// Also flag ternary hooks: `cond ? useX(...) : useY(...)`.
const ifRegex = /\b(?:if|else if|while|for)\b[^{]*?{[^}]*?\buse[A-Z]\w*\s*\(/g;
let m;
while ((m = ifRegex.exec(content))) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
this.push(out, 'REACT009', file, lineIndex, lines, 0.85, 'Hook is called inside a conditional/loop block — React requires hooks to run in the same order every render.');
}
const ternaryRegex = /\?\s*use[A-Z]\w*\s*\(/g;
while ((m = ternaryRegex.exec(content))) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
this.push(out, 'REACT009', file, lineIndex, lines, 0.85, 'Hook is called inside a ternary — React requires hooks to run in the same order every render.');
}
return out;
}
// ---- REACT010: ref.current = … during render --------------------------
detectRefAssignDuringRender(file, content, lines) {
const out = [];
// Find function components (`function Foo(...) { ... }` / `const Foo = (...) => { ... }`)
// and look for `<ident>.current = ` outside any useEffect/handler.
// Simpler heuristic: find `.current = ` lines and check that the
// nearest enclosing arrow or function on the same scope doesn't start
// with a `useEffect(` wrapper within 200 chars above.
const regex = /\b([A-Za-z_$][\w$]*)\.current\s*=(?!=)/g;
let m;
while ((m = regex.exec(content))) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
// Look backwards up to 400 chars for a enclosing callback marker.
const look = content.slice(Math.max(0, m.index - 400), m.index);
const inEffect = /useEffect\s*\(/.test(look) && !/useEffect\s*\([\s\S]*?\)\s*$/.test(look);
const inHandler = /on[A-Z]\w*\s*=\s*\{/.test(look) || /=>\s*{[^}]*$/.test(look);
const inRef = /useRef\s*\(/.test(lines[lineIndex] || '');
if (inEffect || inHandler || inRef)
continue;
// Also skip module-level top-level code (not inside a component function).
// Heuristic: must be inside a function whose body contains JSX (`return (`).
const after = content.slice(m.index, Math.min(content.length, m.index + 2000));
if (!/return\s*\(/.test(after) && !/<\w/.test(after))
continue;
this.push(out, 'REACT010', file, lineIndex, lines, 0.6, `Write to \`${m[1]}.current\` appears to run during render — mutate refs inside handlers or effects instead.`);
}
return out;
}
// ---- REACT011: JWT / token in localStorage ---------------------------
detectTokenInLocalStorage(file, content, lines) {
const out = [];
// localStorage.setItem('token', x) / sessionStorage.setItem('authToken', …)
const regex = /(?:local|session)Storage\.setItem\s*\(\s*['"`]([^'"`]+)['"`]/g;
let m;
while ((m = regex.exec(content))) {
const key = m[1].toLowerCase();
if (!/(token|jwt|access|refresh|bearer|authorization|auth)/.test(key))
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
this.push(out, 'REACT011', file, lineIndex, lines, 0.9, `Token-shaped key "${m[1]}" written to storage — move to an HttpOnly cookie.`);
}
return out;
}
}
exports.ReactPatternsScanner = ReactPatternsScanner;
//# sourceMappingURL=react-patterns-scanner.js.map