UNPKG

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.

774 lines 47.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AstSecurityScanner = void 0; const glob_1 = require("glob"); const fs_1 = require("fs"); const path_1 = require("path"); const rules_1 = require("../rules"); const file_source_cache_1 = require("../utils/file-source-cache"); class AstSecurityScanner { name = 'AST Security Scanner'; async scan(options) { // Lazily require TypeScript at runtime; if unavailable (global install without dev deps), skip AST checks gracefully let tsReal; try { tsReal = require('typescript'); } catch { return []; } const results = []; const files = await (0, glob_1.glob)('**/*.{js,jsx,ts,tsx,mjs,cjs}', { cwd: options.directory, ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'examples/**', ...(options.exclude || [])] }); const sourceCache = file_source_cache_1.FileSourceCache.forDirectory(options.directory); const maxSize = options.maxFileSize || file_source_cache_1.DEFAULT_MAX_FILE_SIZE; for (const file of files) { const absolute = (0, path_1.join)(options.directory, file); try { if ((0, fs_1.statSync)(absolute).size > maxSize) continue; } catch { continue; } const sourceText = sourceCache.read(absolute); if (sourceText === undefined) { if (options.verbose) { console.error(`🪷 AstSecurityScanner: failed to read ${file}`); } continue; } const sf = tsReal.createSourceFile(file, sourceText, tsReal.ScriptTarget.ES2020, true, file.endsWith('.tsx') ? tsReal.ScriptKind.TSX : file.endsWith('.ts') ? tsReal.ScriptKind.TS : tsReal.ScriptKind.JS); // Confidence reasons for AST-detected issues const confidenceReasons = { 'SEC016': 'AST confirms direct eval() call expression', 'SEC019': 'React.createElement with non-literal component - potential injection', 'SEC020': 'SQL sink receives a template literal with interpolations or a string concatenation — classic injection pattern', 'SEC021': 'Error stack serialised into an HTTP response body', 'SEC022': 'Promise chain swallows errors with a stub value', 'SEC023': 'Weak crypto hash (md5/sha1) used near a password/token identifier', 'SEC024': 'Math.random() feeds a token/session-id identifier', 'SEC025': 'redirect()/router.push()/NextResponse.redirect() takes user input directly', 'SEC026': 'child_process.exec/execSync called with a template literal or string concat', 'SEC027': 'path.join/resolve fed user input without a rootDir startsWith guard', 'SEC028': 'localStorage/sessionStorage reads/writes a token-shaped key', 'SEC029': 'Webhook route handler never verifies an incoming signature before mutating state', 'SEC030': 'fetch() target is user-controlled in a server route — potential SSRF', 'SEC031': 'Password/token compared with === / !== instead of a timing-safe compare', 'NEXT004': 'Dynamic import() with non-literal argument - potential code injection', 'JSNET001': 'fetch() call without AbortController signal in options', 'SEC017': 'dangerouslySetInnerHTML attribute detected in JSX', 'SEC008': 'process.env.X || "fallback" pattern detected via AST', 'MOD001': 'Module-level side-effect call runs at import time', 'MOD002': 'async function declared without any await/for await in body', 'MOD003': 'Silent .catch(() => stub) hides DB/fetch errors', 'MOD004': '≥3 `: any` annotations in a single file' }; // Track named imports from 'path' / 'node:path' so bare `join(...)` / // `resolve(...)` calls inherit SEC027 coverage. const pathImportNames = new Set(); // Track bare-identifier exec-family calls imported from child_process. const execFamily = new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync']); const execImportNames = new Set(); // Track bare-identifier fs read/write sinks imported from fs / fs/promises. const fsReadFamily = new Set(['readFile', 'readFileSync', 'createReadStream']); const fsWriteFamily = new Set(['writeFile', 'writeFileSync', 'appendFile', 'appendFileSync', 'createWriteStream']); const fsReadImportNames = new Set(); const fsWriteImportNames = new Set(); for (const stmt of sf.statements) { if (!tsReal.isImportDeclaration(stmt)) continue; if (!tsReal.isStringLiteralLike(stmt.moduleSpecifier)) continue; const spec = stmt.moduleSpecifier.text; const bindings = stmt.importClause?.namedBindings; if (!bindings || !tsReal.isNamedImports(bindings)) continue; for (const el of bindings.elements) { const imported = (el.propertyName ?? el.name).text; const local = el.name.text; if (spec === 'path' || spec === 'node:path') pathImportNames.add(local); if (spec === 'child_process' || spec === 'node:child_process') { if (execFamily.has(imported)) execImportNames.add(local); } if (spec === 'fs' || spec === 'node:fs' || spec === 'fs/promises' || spec === 'node:fs/promises') { if (fsReadFamily.has(imported)) fsReadImportNames.add(local); if (fsWriteFamily.has(imported)) fsWriteImportNames.add(local); } } } // Track `const pexec = promisify(exec)` aliases — treat `pexec(...)` as exec. for (const stmt of sf.statements) { if (!tsReal.isVariableStatement(stmt)) continue; for (const decl of stmt.declarationList.declarations) { if (!tsReal.isIdentifier(decl.name) || !decl.initializer) continue; const init = decl.initializer; if (!tsReal.isCallExpression(init)) continue; const callee = init.expression; const isPromisify = (tsReal.isIdentifier(callee) && callee.text === 'promisify') || (tsReal.isPropertyAccessExpression(callee) && tsReal.isIdentifier(callee.name) && callee.name.text === 'promisify'); if (!isPromisify) continue; const arg0 = init.arguments[0]; if (arg0 && tsReal.isIdentifier(arg0) && execImportNames.has(arg0.text)) { execImportNames.add(decl.name.text); } } } const isRouteFile = /\/route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file) || /\/pages\/api\//.test(file) || // Remix route files under app/routes/ (flat or nested). /(?:^|\/)app\/routes\/[^/]+\.(ts|tsx|js|jsx)$/.test(file) || // SvelteKit: +server.ts / +server.js /(?:^|\/)\+server\.(ts|js|mjs)$/.test(file); const isWebhookFile = /\/webhook(?:s)?\//i.test(file) || /webhook/i.test(file); // JSNET001 fires per-fetch, which was loud for files with several // calls. Collapse to one finding per file; the count is unchanged in // aggregate but a single badge is enough for the user to act on. let jsNetEmittedForFile = false; let anyCount = 0; // SQL sinks we consider. Property access `.prepare(` / `.query(` etc. // on ANY receiver (db, knex, pool, conn, prisma, supabase, ...) plus // bare calls that some ORMs expose. const sqlSinkNames = new Set([ 'prepare', 'query', 'exec', 'execute', 'run', 'raw', '$queryRawUnsafe', '$executeRawUnsafe', 'unsafe' ]); const isTaintedArg = (arg) => { if (tsReal.isTemplateExpression(arg) && arg.templateSpans.length > 0) return true; if (tsReal.isBinaryExpression(arg) && arg.operatorToken.kind === tsReal.SyntaxKind.PlusToken) { return true; } return false; }; const credentialNameRe = /(password|passwd|pwd|token|secret|bearer|session|sessionid|apikey|api_key|jwt|nonce)/i; const add = (metaKey, node, message, confidenceOverride) => { const meta = rules_1.RULES[metaKey]; const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart()); const lineStart = sf.getPositionOfLineAndCharacter(line, 0); const lineEnd = sf.getLineEndOfPosition(node.getStart()); const lineText = sourceText.slice(lineStart, lineEnd).trim(); results.push({ type: meta.severity === 'high' ? 'error' : 'warning', category: meta.category, message: message || meta.message, file, line: line + 1, range: { startLine: line + 1, startColumn: character + 1, endLine: line + 1, endColumn: character + 1 + Math.max(1, node.getWidth()) }, severity: meta.severity, ruleId: meta.id, match: lineText.slice(0, 200), confidence: confidenceOverride ?? 0.9, confidenceReason: confidenceReasons[meta.id] || 'AST analysis confirms pattern', fix: meta.fix }); }; // Helper: extract a target identifier name for variable-naming checks // (e.g. in `const sessionId = Math.random()`, pull `sessionId`). const targetName = (node) => { let cur = node.parent; while (cur) { if (tsReal.isVariableDeclaration(cur) && tsReal.isIdentifier(cur.name)) return cur.name.text; if (tsReal.isPropertyAssignment(cur) && tsReal.isIdentifier(cur.name)) return cur.name.text; if (tsReal.isBinaryExpression(cur) && cur.operatorToken.kind === tsReal.SyntaxKind.EqualsToken) { if (tsReal.isIdentifier(cur.left)) return cur.left.text; if (tsReal.isPropertyAccessExpression(cur.left) && tsReal.isIdentifier(cur.left.name)) return cur.left.name.text; } cur = cur.parent; } return undefined; }; // Broader credential-context check: scans ancestor chain (same statement, // enclosing function signature, surrounding chained call args) for any // identifier matching credentialNameRe. Catches patterns like // return createHash('md5').update(password).digest('hex') // where there's no direct variable target. const credentialContextNearby = (node) => { const tname = targetName(node); if (tname && credentialNameRe.test(tname)) return true; let stmt = node; while (stmt && !tsReal.isStatement(stmt)) stmt = stmt.parent; let hit = false; const stringIsCredential = (s) => { // Only accept tight credential-keyname shapes (e.g. 'password', // 'api_key', 'authToken') — not sentences or dotted event names // like 'checkout.session.completed' that happen to contain 'session'. if (s.length > 32) return false; if (!credentialNameRe.test(s)) return false; if (/[\s/.]/.test(s)) return false; return true; }; // Identifiers that happen to contain a credential-word but aren't // credentials — drops SEC031 false-positives in code that handles // storage or session infrastructure rather than the secret itself. const identIsCredential = (name) => { if (/^(localStorage|sessionStorage|sessionId|SessionStorage)$/.test(name)) return false; if (/Storage$/.test(name)) return false; return credentialNameRe.test(name); }; const walk = (n) => { if (hit) return; if (tsReal.isIdentifier(n) && identIsCredential(n.text)) { hit = true; return; } if (tsReal.isStringLiteralLike(n) && stringIsCredential(n.text)) { hit = true; return; } tsReal.forEachChild(n, walk); }; if (stmt) walk(stmt); if (hit) return true; let cur = node.parent; while (cur) { if (tsReal.isFunctionDeclaration(cur) || tsReal.isFunctionExpression(cur) || tsReal.isArrowFunction(cur) || tsReal.isMethodDeclaration(cur)) { if (cur.name && tsReal.isIdentifier(cur.name) && credentialNameRe.test(cur.name.text)) return true; const params = cur.parameters; if (params) { for (const p of params) { if (tsReal.isIdentifier(p.name) && credentialNameRe.test(p.name.text)) return true; } } break; } cur = cur.parent; } return false; }; const indicatorNames = new Set(['req', 'request', 'body', 'params', 'searchParams', 'query', 'formData', 'input', 'payload', 'ctx']); // Local bindings whose initializer came from an indicator-named source. // Pre-computed so taint propagates one hop through: // const next = url.searchParams.get('next') ?? '/'; // redirect(next) // still tainted const taintedLocalNames = new Set(); const rhsMentionsIndicator = (n) => { const walk = (x) => { if (tsReal.isIdentifier(x) && indicatorNames.has(x.text)) return true; let hit = false; tsReal.forEachChild(x, (c) => { if (!hit) hit = walk(c); }); return hit; }; return walk(n); }; const collectTaintedLocals = (n) => { if (tsReal.isVariableDeclaration(n) && n.initializer && tsReal.isIdentifier(n.name)) { if (rhsMentionsIndicator(n.initializer)) taintedLocalNames.add(n.name.text); } // Destructured: `const { foo } = body` — each property becomes tainted. if (tsReal.isVariableDeclaration(n) && n.initializer && tsReal.isObjectBindingPattern(n.name)) { if (rhsMentionsIndicator(n.initializer)) { for (const el of n.name.elements) { if (tsReal.isIdentifier(el.name)) taintedLocalNames.add(el.name.text); } } } tsReal.forEachChild(n, collectTaintedLocals); }; collectTaintedLocals(sf); const callContainsUserInput = (args) => { const walk = (n) => { if (tsReal.isIdentifier(n) && (indicatorNames.has(n.text) || taintedLocalNames.has(n.text))) return true; if (tsReal.isPropertyAccessExpression(n)) { if (tsReal.isIdentifier(n.expression) && indicatorNames.has(n.expression.text)) return true; return walk(n.expression); } if (tsReal.isElementAccessExpression(n)) return walk(n.expression); if (tsReal.isCallExpression(n)) return n.arguments.some(walk) || walk(n.expression); let hit = false; tsReal.forEachChild(n, (c) => { if (!hit) hit = walk(c); }); return hit; }; return args.some(walk); }; // Count top-level module statements that are standalone side-effect // calls. Requires the module to also export at least one symbol. const moduleStatements = sf.statements; const moduleExportsSomething = moduleStatements.some((s) => (tsReal.isFunctionDeclaration(s) || tsReal.isVariableStatement(s) || tsReal.isClassDeclaration(s)) && s.modifiers?.some((mod) => mod.kind === tsReal.SyntaxKind.ExportKeyword)) || moduleStatements.some((s) => tsReal.isExportAssignment(s) || tsReal.isExportDeclaration(s)); if (moduleExportsSomething) { for (const stmt of moduleStatements) { if (!tsReal.isExpressionStatement(stmt)) continue; const expr = stmt.expression; if (!tsReal.isCallExpression(expr)) continue; const callee = expr.expression; const sideEffectIdents = new Set(['fetch']); const sideEffectMembers = new Set([ 'exec', 'execSync', 'readFileSync', 'writeFileSync', 'appendFileSync', 'unlinkSync', 'mkdirSync', 'rmSync', // DB / infra client lifecycle that ships data at import time. '$connect', '$disconnect', 'connect', 'authenticate', 'initialize', 'sync', 'migrate', 'listen', 'subscribe' ]); let isSide = false; if (tsReal.isIdentifier(callee) && sideEffectIdents.has(callee.text)) isSide = true; if (tsReal.isPropertyAccessExpression(callee) && tsReal.isIdentifier(callee.name)) { if (sideEffectMembers.has(callee.name.text)) isSide = true; if (callee.name.text === 'exec' || callee.name.text === 'execute' || callee.name.text === 'run') isSide = true; } if (isSide) add('MOD001', expr); } } const visit = (node) => { // eval(...) if (tsReal.isCallExpression(node)) { const expr = node.expression; if (tsReal.isIdentifier(expr) && expr.text === 'eval') { add('SEC016', node); } // React.createElement(userInput) if (tsReal.isPropertyAccessExpression(expr)) { if (tsReal.isIdentifier(expr.expression) && expr.expression.text === 'React' && expr.name.text === 'createElement') { const first = node.arguments[0]; if (first && !tsReal.isStringLiteralLike(first)) { add('SEC019', node); } } } // dynamic import(userControlled) if (tsReal.isCallExpression(node) && node.expression.kind === tsReal.SyntaxKind.ImportKeyword) { const arg = node.arguments[0]; if (arg && !tsReal.isStringLiteralLike(arg)) { add('NEXT004', node); } } // fetch(...) if (tsReal.isIdentifier(expr) && expr.text === 'fetch' && !jsNetEmittedForFile) { const second = node.arguments[1]; let hasSignal = false; if (second && tsReal.isObjectLiteralExpression(second)) { hasSignal = second.properties.some(p => tsReal.isPropertyAssignment(p) && tsReal.isIdentifier(p.name) && p.name.text === 'signal'); } if (!hasSignal) { add('JSNET001', node, rules_1.RULES.JSNET001.message); jsNetEmittedForFile = true; } } // SEC020: SQL sink called with interpolated/concatenated string. let sinkName; if (tsReal.isIdentifier(expr) && sqlSinkNames.has(expr.text)) sinkName = expr.text; else if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name) && sqlSinkNames.has(expr.name.text)) sinkName = expr.name.text; if (sinkName) { const firstArg = node.arguments[0]; if (firstArg && isTaintedArg(firstArg)) { add('SEC020', node, `SQL sink .${sinkName}() called with interpolated/concatenated string — possible SQL injection`); } } // SEC023: crypto.createHash('md5'|'sha1') feeding a password/token identifier. if (tsReal.isIdentifier(expr) && expr.text === 'createHash') { const arg0 = node.arguments[0]; if (arg0 && tsReal.isStringLiteralLike(arg0)) { const algo = arg0.text.toLowerCase(); if (algo === 'md5' || algo === 'sha1') { if (credentialContextNearby(node)) add('SEC023', node); } } } if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name) && expr.name.text === 'createHash') { const arg0 = node.arguments[0]; if (arg0 && tsReal.isStringLiteralLike(arg0)) { const algo = arg0.text.toLowerCase(); if (algo === 'md5' || algo === 'sha1') { if (credentialContextNearby(node)) add('SEC023', node); } } } // SEC024: Math.random() assigned to a token/session identifier. // Require a tight credential-shaped name. `sessionsPerDay`, // `tokenPosition`, `bearerCount` are not credentials; a blanket // substring match caused false-positives on business-logic counters. if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.expression) && expr.expression.text === 'Math' && tsReal.isIdentifier(expr.name) && expr.name.text === 'random') { const tname = targetName(node); const tight = /^(token|authToken|accessToken|refreshToken|sessionId|session_id|sessionToken|apiKey|api_key|password|secret|bearer|bearerToken|nonce|csrf|csrfToken|jwt|otp|resetToken|verificationToken|inviteToken|[a-zA-Z]+Id)$/; if (tname && tight.test(tname)) { add('SEC024', node); } } // SEC025: redirect()/router.push()/NextResponse.redirect() with user input. // `.push`/`.replace` are only treated as router navigation when the // receiver identifier looks like a router/history/navigation object — // `arr.push(x)` and `results.push(x)` must not fire. let isRedirect = false; if (tsReal.isIdentifier(expr) && expr.text === 'redirect') { isRedirect = true; } else if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name)) { if (expr.name.text === 'redirect') { isRedirect = true; } else if (expr.name.text === 'push' || expr.name.text === 'replace') { const receiver = tsReal.isIdentifier(expr.expression) ? expr.expression.text : ''; if (/^(router|history|navigation|nav)$/i.test(receiver)) isRedirect = true; } } if (isRedirect && node.arguments.length > 0) { // Skip literals. const a0 = node.arguments[0]; const isLiteral = tsReal.isStringLiteralLike(a0) || (tsReal.isTemplateExpression(a0) && a0.templateSpans.length === 0) || (tsReal.isNoSubstitutionTemplateLiteral(a0)); if (!isLiteral && callContainsUserInput(node.arguments)) { add('SEC025', node); } } // SEC026: child_process.exec / execSync / spawn / spawnSync with a // tainted argument. Covers three call shapes: // childProcess.exec(cmd) — member access // exec(cmd) — named import // pexec(cmd) where pexec = promisify(exec) { let execLikeName; if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name)) { // Skip SQL clients — `db.exec(...)` / `knex.exec(...)` is a SQL // sink, not a shell. SEC020 already catches the interpolation. const receiver = tsReal.isIdentifier(expr.expression) ? expr.expression.text.toLowerCase() : ''; const isSqlReceiver = /^(db|sqlite|database|knex|pool|conn|connection|client|pg|postgres|mysql|prisma|drizzle|sql|tx|trx|handle)$/.test(receiver); if (execFamily.has(expr.name.text) && !isSqlReceiver) execLikeName = expr.name.text; } else if (tsReal.isIdentifier(expr) && execImportNames.has(expr.text)) { execLikeName = expr.text; } if (execLikeName) { const a0 = node.arguments[0]; const tainted = !!a0 && (isTaintedArg(a0) || callContainsUserInput(node.arguments) || // Promisified alias: the tainted value often arrives as a // plain identifier named like a user input (`cmd`, `path`, // `input`, `command`, `script`). (tsReal.isIdentifier(a0) && /^(cmd|command|script|input|args?|path|file|name)$/i.test(a0.text))); if (tainted) { if (execLikeName.startsWith('execFile') && node.arguments[1] && tsReal.isArrayLiteralExpression(node.arguments[1])) { // execFile with an array args list is safe by construction. } else { add('SEC026', node); } } } } // SEC027: path.join(...) fed with user input, without a nearby startsWith guard. // Also triggers for `join(...)` / `resolve(...)` when those names are // named-imported from 'path'. const isPathMember = tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.expression) && expr.expression.text === 'path' && tsReal.isIdentifier(expr.name) && (expr.name.text === 'join' || expr.name.text === 'resolve'); const isPathBare = tsReal.isIdentifier(expr) && (expr.text === 'join' || expr.text === 'resolve' || expr.text === 'normalize') && pathImportNames.has(expr.text); if (isPathMember || isPathBare) { const directTaint = callContainsUserInput(node.arguments); const pos = node.getStart(); const windowText = sourceText.slice(Math.max(0, pos - 500), Math.min(sourceText.length, pos + 500)); const hasGuard = /\.startsWith\(|path\.normalize\(/.test(windowText); const fileTaint = isRouteFile && (/(?:formData|searchParams|body|query)[^;\n]*\.get\(/i.test(sourceText) || /\breq\.(?:json|text|formData|body)\s*\(/i.test(sourceText)); if (!hasGuard && (directTaint || (fileTaint && node.arguments.some((a) => tsReal.isIdentifier(a))))) { add('SEC027', node, undefined, directTaint ? 0.9 : 0.75); } } // SEC027: direct fs.readFile / writeFile / readFileSync / writeFileSync // / createReadStream / createWriteStream with a tainted first argument // and no nearby allowlist guard. Catches the AI-tool shape: // execute: async ({ path }) => readFile(path, 'utf-8') // Identifier-shape fallback (`readFileSync(file, …)` where `file` is // a plain local) is gated on the file clearly handling untrusted // request input — otherwise scripts iterating their own tree (a loop // variable called `file`) get flagged spuriously. { let fsSinkName; if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name)) { const n = expr.name.text; if (fsReadFamily.has(n) || fsWriteFamily.has(n)) fsSinkName = n; } else if (tsReal.isIdentifier(expr)) { if (fsReadImportNames.has(expr.text) || fsWriteImportNames.has(expr.text)) { fsSinkName = expr.text; } } if (fsSinkName) { const a0 = node.arguments[0]; if (a0 && !tsReal.isStringLiteralLike(a0)) { const directTaint = callContainsUserInput([a0]); const pos = node.getStart(); const windowText = sourceText.slice(Math.max(0, pos - 500), Math.min(sourceText.length, pos + 500)); const hasGuard = /\.startsWith\(|path\.normalize\(|allow(?:ed)?[A-Z_]|ALLOWLIST/.test(windowText); // An AI SDK `tool({ execute: async ({ path }) => … })` is an // attacker-controlled surface even if the file never touches a // Request object — the LLM decides the argument. const importsAiSdk = /from\s+['"](?:ai|@ai-sdk\/|@anthropic-ai\/sdk)/.test(sourceText); const fileHandlesRequest = importsAiSdk || /\breq\.(?:json|text|formData|body)\s*\(|\bsearchParams\b|\bformData\s*\(\)|\brequest\.(?:json|text|formData)\s*\(/i.test(sourceText); const identShape = tsReal.isIdentifier(a0) && /^(path|file|filename|filepath|target|dest|destination)$/i.test(a0.text); const identTaint = identShape && fileHandlesRequest; if (!hasGuard && (directTaint || identTaint)) { add('SEC027', node, undefined, directTaint ? 0.9 : 0.75); } } } } // SEC028: localStorage / sessionStorage with a credential-shaped key. // Matches both setItem (writing the token) and getItem (reading it // back for use in a bearer header, which still round-trips through // untrusted browser storage). if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.expression) && (expr.expression.text === 'localStorage' || expr.expression.text === 'sessionStorage') && tsReal.isIdentifier(expr.name) && (expr.name.text === 'setItem' || expr.name.text === 'getItem')) { const a0 = node.arguments[0]; if (a0 && tsReal.isStringLiteralLike(a0) && credentialNameRe.test(a0.text)) { add('SEC028', node); } } // SEC030: SSRF — fetch() in a server route where the URL comes from // a request-shaped identifier (req/body/params/searchParams/input), // or — weaker — where the file clearly destructures from req.json() // / req.body / searchParams and the fetch() arg isn't a literal. if (isRouteFile && tsReal.isIdentifier(expr) && expr.text === 'fetch' && node.arguments.length > 0) { const a0 = node.arguments[0]; if (!tsReal.isStringLiteralLike(a0)) { const directTaint = callContainsUserInput([a0]); const fileTaint = /\breq\.(?:json|text|formData|body)\s*\(/i.test(sourceText) || /\bsearchParams\s*\./.test(sourceText); if (directTaint) add('SEC030', node); else if (fileTaint && tsReal.isIdentifier(a0)) add('SEC030', node, undefined, 0.75); } } // SEC022 / MOD003: silent `.catch(() => [] | {} | null | undefined)`. if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name) && expr.name.text === 'catch') { const a0 = node.arguments[0]; if (a0 && tsReal.isArrowFunction(a0)) { const body = a0.body; const isStub = tsReal.isArrayLiteralExpression(body) || tsReal.isObjectLiteralExpression(body) || (tsReal.isIdentifier(body) && (body.text === 'undefined' || body.text === 'null')) || body.kind === tsReal.SyntaxKind.NullKeyword; if (isStub) { add('SEC022', node); add('MOD003', node); } } } // SEC021: err.stack / error.stack returned or serialised into a response. // Detect common patterns: NextResponse.json({ error: err.stack }), res.status(500).json(err), // res.send(err.stack), c.json({ error: err.stack }), JSON.stringify({ stack: err.stack }). const walkForStack = (n) => { if (tsReal.isPropertyAccessExpression(n) && tsReal.isIdentifier(n.name) && n.name.text === 'stack') return true; let hit = false; tsReal.forEachChild(n, (c) => { if (!hit) hit = walkForStack(c); }); return hit; }; if (tsReal.isPropertyAccessExpression(expr) && tsReal.isIdentifier(expr.name)) { const name = expr.name.text; if (['json', 'send', 'stringify'].includes(name)) { if (node.arguments.some(walkForStack)) add('SEC021', node); } } } // dangerouslySetInnerHTML if (tsReal.isIdentifier(node) && node.text === 'dangerouslySetInnerHTML') { add('SEC017', node); } // SEC031: timing-unsafe password / token comparison using === / !==. // Fires when both sides are non-trivial identifiers/property-accesses // (not null/undefined/true/false/numeric literals) AND the enclosing // function / surrounding statement is credential-shaped. if (tsReal.isBinaryExpression(node) && (node.operatorToken.kind === tsReal.SyntaxKind.EqualsEqualsEqualsToken || node.operatorToken.kind === tsReal.SyntaxKind.ExclamationEqualsEqualsToken || node.operatorToken.kind === tsReal.SyntaxKind.EqualsEqualsToken || node.operatorToken.kind === tsReal.SyntaxKind.ExclamationEqualsToken)) { const isTrivialSentinel = (n) => { if (n.kind === tsReal.SyntaxKind.NullKeyword) return true; if (n.kind === tsReal.SyntaxKind.UndefinedKeyword) return true; if (n.kind === tsReal.SyntaxKind.TrueKeyword) return true; if (n.kind === tsReal.SyntaxKind.FalseKeyword) return true; if (tsReal.isIdentifier(n) && (n.text === 'undefined' || n.text === 'null')) return true; if (tsReal.isNumericLiteral(n)) return true; if (tsReal.isStringLiteralLike(n) && n.text.length < 8) return true; return false; }; const bothMeaningful = !isTrivialSentinel(node.left) && !isTrivialSentinel(node.right); // Require one operand to *itself* be credential-shaped. Nearby // context alone (e.g. an unrelated function that mentions // `sessionStorage` or AST nodes named `EqualsEqualsEqualsToken`) // produced heavy false-positives on non-credential code. const operandIsCredential = (op) => { if (tsReal.isIdentifier(op)) { return /^(password|passwd|pwd|secret|apiKey|api_key|token|authToken|accessToken|refreshToken|bearerToken|sessionToken|jwt|hash|hashed|hashedPassword|hashed_password|digest|nonce)$/i.test(op.text); } if (tsReal.isPropertyAccessExpression(op) && tsReal.isIdentifier(op.name)) { return /^(password|passwd|pwd|secret|apiKey|api_key|token|authToken|accessToken|refreshToken|bearerToken|sessionToken|jwt|hash|hashed|hashedPassword|hashed_password|digest|nonce)$/i.test(op.name.text); } return false; }; if (bothMeaningful && (operandIsCredential(node.left) || operandIsCredential(node.right))) { add('SEC031', node); } } // process.env.X || 'fallback' if (tsReal.isBinaryExpression(node) && node.operatorToken.kind === tsReal.SyntaxKind.BarBarToken) { const left = node.left; const right = node.right; const isEnv = tsReal.isPropertyAccessExpression(left) && tsReal.isPropertyAccessExpression(left.expression) && tsReal.isIdentifier(left.expression.expression) && left.expression.expression.text === 'process' && tsReal.isIdentifier(left.expression.name) && left.expression.name.text === 'env'; const isLiteral = tsReal.isStringLiteralLike(right); if (isEnv && isLiteral) { add('SEC008', node); } } // MOD002: async function with no await / for-await in its body. if ((tsReal.isFunctionDeclaration(node) || tsReal.isFunctionExpression(node) || tsReal.isArrowFunction(node) || tsReal.isMethodDeclaration(node)) && node.modifiers?.some((m) => m.kind === tsReal.SyntaxKind.AsyncKeyword) && node.body) { const body = node.body; let hasAwait = false; const scan = (n) => { if (hasAwait) return; if (tsReal.isAwaitExpression(n)) { hasAwait = true; return; } if (tsReal.isForOfStatement(n) && n.awaitModifier) { hasAwait = true; return; } // Don't descend into nested functions — their awaits don't count. if (tsReal.isFunctionLike(n) && n !== node) return; tsReal.forEachChild(n, scan); }; scan(body); if (!hasAwait) add('MOD002', node, undefined, 0.8); } // MOD004: count `: any` annotations in the file. if (tsReal.isTypeReferenceNode(node) && tsReal.isIdentifier(node.typeName) && node.typeName.text === 'any') { anyCount++; } if (node.kind === tsReal.SyntaxKind.AnyKeyword) { anyCount++; } tsReal.forEachChild(node, visit); }; visit(sf); // SEC029: webhook route handler with no signature verification. // Heuristics: file path contains /webhook/, file exports async POST (or // default handler), and no signature-verifying call appears in source. if (isRouteFile && isWebhookFile) { const sigRe = /webhooks?\.constructEvent|constructEventAsync|verifyHeader|verify(?:Webhook)?Signature|svix|timingSafeEqual|createHmac|\.verify\(/; // Strip // line comments and /* */ block comments so a literal mention // of the API inside a comment doesn't mask a real missing call. const stripped = sourceText .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); const hasPost = /export\s+(?:async\s+)?(?:const|function)\s+POST\b|export\s+\{\s*[^}]*\bPOST\b|as\s+POST\b|export\s+default\s+(?:async\s+)?function|export\s+(?:async\s+)?(?:const|function)\s+action\b/i.test(stripped); if (hasPost && !sigRe.test(stripped)) { const meta = rules_1.RULES['SEC029']; if (meta) { results.push({ type: 'error', category: meta.category, message: meta.message, file, line: 1, range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1 }, severity: meta.severity, ruleId: meta.id, match: '', confidence: 0.85, confidenceReason: confidenceReasons['SEC029'], fix: meta.fix }); } } } if (anyCount >= 3) { const meta = rules_1.RULES['MOD004']; if (meta) { results.push({ type: 'warning', category: meta.category, message: `${meta.message} (${anyCount} occurrences)`, file, line: 1, range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1 }, severity: meta.severity, ruleId: meta.id, match: '', confidence: 0.7, confidenceReason: confidenceReasons['MOD004'], fix: meta.fix }); } } } return results; } } exports.AstSecurityScanner = AstSecurityScanner; //# sourceMappingURL=ast-security-scanner.js.map