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.
744 lines • 37 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.FrameworkScanner = void 0;
const fs_1 = require("fs");
const path_1 = require("path");
const base_scanner_1 = require("./base-scanner");
const rules_1 = require("../rules");
const redact_1 = require("../utils/redact");
/**
* FrameworkScanner — Next 14/15 Server Actions, Edge runtime, SvelteKit,
* Astro, Remix, Hono, Drizzle, and Prisma.
*
* Heuristic regex-based detections (intentionally not full AST) so each
* rule stays cheap and explainable. Every finding ships with a
* `confidenceReason`; lower-confidence rules surface only with `--detailed`.
*/
class FrameworkScanner extends base_scanner_1.BaseScanner {
name = 'Framework Scanner';
authMarkerRegex = /\b(getServerSession|auth\(\)|getSession|verifyJWT|requireAuth|requireUser|requireUserId|withAuth|isAuthenticated|currentUser|clerkClient|supabase\.auth|next-auth|lucia|betterAuth|verifyToken|locals\.user|Astro\.locals\.user|event\.locals\.user)\b/;
validatorMarkerRegex = /\b(zod|valibot|yup|ajv|safeParse|parse\s*\(|zValidator|@hapi\/joi|joi\.|superstruct|arktype|class-validator)\b/;
// Cached per-scan: is this project using Next.js? NEXT* rules gate on this
// so a Vite+React SPA doesn't light up with App Router findings.
isNextProject = false;
async scan(options) {
const results = [];
this.initCache(options, 'framework:2');
this.isNextProject = this.detectNextProject(options.directory);
for await (const ctx of this.iterateFiles(options, '**/*.{js,jsx,ts,tsx,mjs,cjs,svelte,astro}', ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'coverage/**', 'examples/**'])) {
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.detectNextServerActions(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectUseServerLeak(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectEdgeRuntime(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectSvelteKit(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectAstroEndpoints(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectRemix(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectHono(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectDrizzlePrisma(ctx.file, ctx.content, ctx.lines));
if (this.isNextProject) {
fileResults.push(...this.detectNext15AsyncParams(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectMissingUseClient(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectNextConfigHints(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectNextAppRouterBoundaries(ctx.file, ctx.content, ctx.lines));
}
this.setCached(ctx.file, ctx.contentHash, fileResults);
results.push(...fileResults);
}
this.saveCache();
return results;
}
detectNextProject(directory) {
try {
const pkgPath = (0, path_1.join)(directory, 'package.json');
if ((0, fs_1.existsSync)(pkgPath)) {
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf-8'));
if (pkg.dependencies?.next || pkg.devDependencies?.next)
return true;
}
}
catch { }
return ((0, fs_1.existsSync)((0, path_1.join)(directory, 'next.config.js')) ||
(0, fs_1.existsSync)((0, path_1.join)(directory, 'next.config.ts')) ||
(0, fs_1.existsSync)((0, path_1.join)(directory, 'next.config.mjs')) ||
(0, fs_1.existsSync)((0, path_1.join)(directory, 'next.config.cjs')));
}
// -------- Next.js Server Actions (NEXT212–215) ------------------------
detectNextServerActions(file, content, lines) {
const out = [];
if (!/['"]use server['"];?/.test(content))
return out;
// NEXT212/213: scan exported async functions in this server-actions file.
const exportFnRegex = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)|export\s+const\s+(\w+)\s*=\s*async\s*\(([^)]*)\)\s*=>/g;
const fileHasAuth = this.authMarkerRegex.test(content);
const fileHasValidator = this.validatorMarkerRegex.test(content);
let m;
while ((m = exportFnRegex.exec(content))) {
const name = m[1] || m[3];
const args = (m[2] || m[4] || '').trim();
if (!name)
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, lineIndex, 'NEXT212'))
continue;
// NEXT212: missing auth
if (!fileHasAuth) {
const meta = (0, rules_1.getRule)('NEXT212')?.meta;
if (meta) {
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: `${meta.message} (export: ${name})`,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.65,
confidenceReason: 'File has `use server` directive but no auth marker (auth()/getServerSession/etc.) anywhere in the module.'
}, lines[lineIndex]));
}
}
// NEXT213: missing input validation when handler accepts FormData/object
if (args && !fileHasValidator) {
const acceptsRichInput = /\b(formData|FormData|data|input|payload|body)\b/.test(args);
if (acceptsRichInput) {
const meta = (0, rules_1.getRule)('NEXT213')?.meta;
if (meta) {
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: `${meta.message} (export: ${name})`,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.55,
confidenceReason: 'Server Action accepts FormData/object input but no validator (zod/valibot/yup/ajv) is imported in the file.'
}, lines[lineIndex]));
}
}
}
}
// NEXT215: redirect()/revalidatePath() with template-literal user input
const dangerousCallRegex = /\b(?:redirect|revalidatePath|revalidateTag)\s*\(\s*`[^`]*\$\{[^}]*\b(?:formData|input|data|body|params|searchParams|request)\b[^}]*\}[^`]*`/g;
let dm;
while ((dm = dangerousCallRegex.exec(content))) {
const lineIndex = content.slice(0, dm.index).split('\n').length - 1;
if (this.isSuppressed(lines, lineIndex, 'NEXT215'))
continue;
const meta = (0, rules_1.getRule)('NEXT215')?.meta;
if (!meta)
continue;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.7,
confidenceReason: 'redirect()/revalidatePath() interpolates user-controlled input from formData/params/etc.'
}, lines[lineIndex]));
}
return out;
}
// -------- NEXT214: `use server` imported by `use client` --------------
detectUseServerLeak(file, content, lines) {
if (!/['"]use client['"];?/.test(content))
return [];
const meta = (0, rules_1.getRule)('NEXT214')?.meta;
if (!meta)
return [];
const out = [];
const importRegex = /import\s+[^'"`]+from\s+['"]([^'"`]+)['"]/g;
let m;
while ((m = importRegex.exec(content))) {
const importPath = m[1];
// Heuristic: imports with `actions` or `server-actions` segments
// are the typical Server Action modules. We can't follow paths, so
// we report the suspicious import so the user can confirm.
if (!/(?:^|\/)(?:actions|server-actions|server)\/[\w./-]+$/.test(importPath))
continue;
if (importPath.startsWith('http'))
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, lineIndex, 'NEXT214'))
continue;
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.5,
confidenceReason: `\`use client\` file imports from a likely server-actions module ('${importPath}').`
}, lines[lineIndex]));
}
return out;
}
// -------- Edge runtime (EDGE001–003) ---------------------------------
detectEdgeRuntime(file, content, lines) {
if (!/export\s+const\s+runtime\s*=\s*['"]edge['"]/.test(content))
return [];
const out = [];
// EDGE001: node-only APIs
const nodeApiRegex = /\b(?:from\s+['"](?:fs|fs\/promises|child_process|cluster|dgram|net|tls|dns|os|worker_threads|stream\/web)['"]|require\s*\(\s*['"](?:fs|child_process|cluster|dgram|net|tls)['"]\s*\)|crypto\.createHash|Buffer\.from)/g;
let m;
while ((m = nodeApiRegex.exec(content))) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, lineIndex, 'EDGE001'))
continue;
const meta = (0, rules_1.getRule)('EDGE001')?.meta;
if (!meta)
continue;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.85,
confidenceReason: 'Node-only API used in a route declared with `runtime = "edge"`.'
}, lines[lineIndex]));
}
// EDGE003: top-level process.env reads
const topLevelEnvRegex = /^(?:export\s+)?const\s+\w+\s*=\s*process\.env\.\w+/gm;
while ((m = topLevelEnvRegex.exec(content))) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, lineIndex, 'EDGE003'))
continue;
const meta = (0, rules_1.getRule)('EDGE003')?.meta;
if (!meta)
continue;
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.7,
confidenceReason: 'process.env access at module scope inside an edge route — value is frozen at build time.'
}, lines[lineIndex]));
}
// EDGE002: long-lived OpenAI/Anthropic call without streaming
const longCallRegex = /\b(?:chat\.completions\.create|messages\.create|generateText|generateObject)\s*\(\s*\{[^}]{0,400}\bstream\s*:\s*false/;
if (longCallRegex.test(content) || (/\b(?:chat\.completions\.create|messages\.create|generateText)\s*\(/.test(content) && !/\bstream\s*:\s*true/.test(content))) {
const idx = lines.findIndex((l) => /\b(?:chat\.completions\.create|messages\.create|generateText|generateObject)\s*\(/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'EDGE002')) {
const meta = (0, rules_1.getRule)('EDGE002')?.meta;
if (meta) {
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.6,
confidenceReason: 'Edge route makes a non-streaming LLM call; risks hitting the 25–30s edge wall-clock cap.'
}, lines[idx]));
}
}
}
return out;
}
// -------- SvelteKit (SVELTE001–002) ----------------------------------
detectSvelteKit(file, content, lines) {
const out = [];
const isPageServer = /\.(?:server)\.(?:ts|js)$/.test(file) || /\+page\.server\.(?:ts|js)$/.test(file);
if (!isPageServer)
return out;
// SVELTE001: load() returning env-derived secrets
const meta1 = (0, rules_1.getRule)('SVELTE001')?.meta;
if (meta1 && /export\s+const\s+load\b/.test(content) && /process\.env\.[A-Z0-9_]+|env\.[A-Z0-9_]+/.test(content)) {
const idx = lines.findIndex((l) => /process\.env\.[A-Z0-9_]+|env\.[A-Z0-9_]+/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'SVELTE001')) {
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta1.severity,
ruleId: meta1.id,
message: meta1.message,
fix: meta1.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.5,
confidenceReason: 'Server load() touches env vars and may serialise them into the page payload.'
}, lines[idx]));
}
}
// SVELTE002: form actions without auth
const meta2 = (0, rules_1.getRule)('SVELTE002')?.meta;
if (meta2 && /export\s+const\s+actions\s*[:=]/.test(content) && !this.authMarkerRegex.test(content)) {
const idx = lines.findIndex((l) => /export\s+const\s+actions\s*[:=]/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'SVELTE002')) {
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta2.severity,
ruleId: meta2.id,
message: meta2.message,
fix: meta2.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.6,
confidenceReason: 'SvelteKit form actions defined without an auth marker in the same module.'
}, lines[idx]));
}
}
return out;
}
// -------- Astro endpoints (ASTRO001) ---------------------------------
detectAstroEndpoints(file, content, lines) {
const out = [];
const looksLikeEndpoint = /\b(GET|POST|PUT|PATCH|DELETE)\s*:\s*(?:async\s*)?\(?\s*\{?[^)]*Astro[^)]*\}?\)?\s*=>/.test(content) ||
/export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE)\s*\(/.test(content) ||
/export\s+const\s+(?:GET|POST|PUT|PATCH|DELETE)\s*:\s*APIRoute\b/.test(content) ||
/from\s+['"]astro['"]/.test(content);
if (!looksLikeEndpoint)
return out;
// Only Astro projects: must import from 'astro' or have `.astro` neighbours.
if (!/from\s+['"]astro(?:\:|['"])/.test(content) && !/APIRoute\b/.test(content))
return out;
// Only flag mutating verbs
const meta = (0, rules_1.getRule)('ASTRO001')?.meta;
if (!meta)
return out;
if (this.authMarkerRegex.test(content))
return out;
const mutateRegex = /\bexport\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\s*\(|\b(POST|PUT|PATCH|DELETE)\s*:/;
const mm = mutateRegex.exec(content);
if (!mm)
return out;
const idx = content.slice(0, mm.index).split('\n').length - 1;
if (this.isSuppressed(lines, idx, 'ASTRO001'))
return out;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.55,
confidenceReason: 'Astro endpoint defines a mutating verb without an auth marker.'
}, lines[idx]));
return out;
}
// -------- Remix (REMIX001) -------------------------------------------
detectRemix(file, content, lines) {
const out = [];
if (!/\b(?:LoaderFunctionArgs|ActionFunctionArgs|loader|action)\b/.test(content))
return out;
if (!/from\s+['"]@remix-run\/(?:node|cloudflare|server-runtime)['"]/.test(content))
return out;
if (this.authMarkerRegex.test(content))
return out;
const dbSink = /\b(?:prisma|drizzle|db\.|supabase\.from\(|knex\(|sequelize\.|mongoose\.)/.test(content);
if (!dbSink)
return out;
const meta = (0, rules_1.getRule)('REMIX001')?.meta;
if (!meta)
return out;
const idx = lines.findIndex((l) => /\bexport\s+(?:async\s+)?(?:const|function)\s+(?:loader|action)\b/.test(l));
if (idx < 0 || this.isSuppressed(lines, idx, 'REMIX001'))
return out;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.6,
confidenceReason: 'Remix loader/action touches a DB client (prisma/drizzle/supabase) but lacks an auth marker.'
}, lines[idx]));
return out;
}
// -------- Hono (HONO001) ---------------------------------------------
detectHono(file, content, lines) {
const out = [];
if (!/from\s+['"]hono(?:\/[^'"`]+)?['"]/.test(content))
return out;
if (/\bzValidator\s*\(/.test(content))
return out;
const meta = (0, rules_1.getRule)('HONO001')?.meta;
if (!meta)
return out;
const bodyReadRegex = /\bc\.req\.(?:json|formData|parseBody)\s*\(/g;
let m;
while ((m = bodyReadRegex.exec(content))) {
const idx = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, idx, 'HONO001'))
continue;
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.65,
confidenceReason: 'Hono route reads request body but no zValidator middleware is in scope.'
}, lines[idx]));
}
return out;
}
// -------- Drizzle / Prisma (DRIZZLE001 / PRISMA001) ------------------
detectDrizzlePrisma(file, content, lines) {
const out = [];
// DRIZZLE001: sql`... ${user} ...` interpolation
const drizzleMeta = (0, rules_1.getRule)('DRIZZLE001')?.meta;
if (drizzleMeta && /from\s+['"]drizzle-orm['"]/.test(content)) {
const sqlRegex = /\bsql\s*`[^`]*\$\{[^}]*\b(?:req\.|input|body|params|searchParams|userInput|formData)[^}]*\}[^`]*`/g;
let m;
while ((m = sqlRegex.exec(content))) {
const idx = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, idx, 'DRIZZLE001'))
continue;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: drizzleMeta.severity,
ruleId: drizzleMeta.id,
message: drizzleMeta.message,
fix: drizzleMeta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.85,
confidenceReason: 'Drizzle sql`` template interpolates user-controlled input directly.'
}, lines[idx]));
}
}
// PRISMA001: $queryRawUnsafe / $executeRawUnsafe with non-literal arg
const prismaMeta = (0, rules_1.getRule)('PRISMA001')?.meta;
if (prismaMeta && /\$(?:queryRaw|executeRaw)Unsafe\s*\(/.test(content)) {
const idx = lines.findIndex((l) => /\$(?:queryRaw|executeRaw)Unsafe\s*\(/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'PRISMA001')) {
out.push(this.createResult({
type: 'error',
category: 'security',
severity: prismaMeta.severity,
ruleId: prismaMeta.id,
message: prismaMeta.message,
fix: prismaMeta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.85,
confidenceReason: 'Prisma $queryRawUnsafe/$executeRawUnsafe is a documented SQL injection sink.'
}, lines[idx]));
}
}
return out;
}
// -------- NEXT216: Next 15 async params / searchParams ----------------
detectNext15AsyncParams(file, content, lines) {
const out = [];
// Only App Router page/layout/route files care.
if (!/(?:^|\/)app\/.*\/(?:page|layout|route|default|template|error|not-found|loading)\.(?:tsx|ts)$/.test(file)
&& !/(?:^|\/)app\/(?:page|layout|route)\.(?:tsx|ts)$/.test(file)) {
return out;
}
const meta = (0, rules_1.getRule)('NEXT216')?.meta;
if (!meta)
return out;
// Match `params:` or `searchParams:` inside a prop type block where the value
// is a plain object literal (`{ ... }`) rather than `Promise<...>`.
const propRegex = /\b(params|searchParams)\s*:\s*([^,;}\n]+)/g;
const seen = new Set();
let m;
while ((m = propRegex.exec(content))) {
const rawType = (m[2] || '').trim();
if (!rawType)
continue;
// Accept: `Promise<…>`, `Awaitable<…>`, or imported alias ending in `Params`
// that the user obviously owns. We only fire on bare object literal or
// `Record<…>` / indexed access types without `Promise`.
if (/^Promise\s*</.test(rawType))
continue;
if (!/^\{/.test(rawType) && !/^Record\s*</.test(rawType) && !/^\[/.test(rawType))
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
if (seen.has(lineIndex))
continue;
seen.add(lineIndex);
if (this.isSuppressed(lines, lineIndex, 'NEXT216'))
continue;
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: `${meta.message} (${m[1]})`,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence: 0.8,
confidenceReason: `App Router ${file} types \`${m[1]}\` as a plain object — Next 15 passes a Promise here.`
}, lines[lineIndex]));
}
return out;
}
// -------- NEXT217: hooks without 'use client' -------------------------
detectMissingUseClient(file, content, lines) {
const out = [];
if (!/\.(?:tsx|jsx)$/.test(file))
return out;
const meta = (0, rules_1.getRule)('NEXT217')?.meta;
if (!meta)
return out;
// Strip leading comments/blank lines to find the first directive
const firstNonBlank = lines.findIndex((l) => {
const t = l.trim();
if (!t)
return false;
if (t.startsWith('//') || t.startsWith('/*') || t.startsWith('*'))
return false;
return true;
});
const directive = firstNonBlank >= 0 ? lines[firstNonBlank].trim() : '';
if (/^['"]use client['"]\s*;?$/.test(directive))
return out;
// Look for hook imports from react: `import { useState, useEffect } from 'react'`
const importRegex = /import\s*(?:type\s*)?\{([^}]+)\}\s*from\s*['"]react['"]/g;
const hooks = [];
let m;
while ((m = importRegex.exec(content))) {
const names = (m[1] || '').split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
const hook = names.find((n) => /^use[A-Z]\w*$/.test(n));
if (!hook)
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
hooks.push({ name: hook, line: lineIndex });
}
if (hooks.length === 0)
return out;
const first = hooks[0];
if (this.isSuppressed(lines, first.line, 'NEXT217'))
return out;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: `${meta.message} (imports \`${first.name}\`)`,
fix: meta.fix,
file,
line: first.line + 1,
match: (0, redact_1.redact)((lines[first.line] ?? '').trim().slice(0, 200)),
confidence: 0.9,
confidenceReason: `File imports the hook \`${first.name}\` from 'react' but has no top-of-file \`'use client'\` directive.`
}, lines[first.line]));
return out;
}
// -------- NEXT218 / NEXT219: next.config hints ------------------------
detectNextConfigHints(file, content, lines) {
const out = [];
if (!/(?:^|\/)next\.config\.(?:js|ts|mjs|cjs)$/.test(file))
return out;
const meta218 = (0, rules_1.getRule)('NEXT218')?.meta;
if (meta218) {
const idx = lines.findIndex((l) => /\breactStrictMode\s*:\s*false\b/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'NEXT218')) {
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta218.severity,
ruleId: meta218.id,
message: meta218.message,
fix: meta218.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.95,
confidenceReason: '`reactStrictMode: false` literal in next.config.'
}, lines[idx]));
}
}
const meta219 = (0, rules_1.getRule)('NEXT219')?.meta;
if (meta219) {
const idx = lines.findIndex((l) => /\bserverActions\s*:\s*(?:true|false)\b/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'NEXT219')) {
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta219.severity,
ruleId: meta219.id,
message: meta219.message,
fix: meta219.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)((lines[idx] ?? '').trim().slice(0, 200)),
confidence: 0.9,
confidenceReason: 'experimental.serverActions uses the removed boolean shape.'
}, lines[idx]));
}
}
return out;
}
// -------- NEXT220-225: App Router boundary hygiene --------------------
detectNextAppRouterBoundaries(file, content, lines) {
const out = [];
if (!/\.(?:tsx|jsx|ts)$/.test(file))
return out;
// Figure out whether this file is a Client Component, a Server
// Component, or an app-router route file.
const firstNonBlank = lines.findIndex((l) => {
const t = l.trim();
return t.length > 0 && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*');
});
const directive = firstNonBlank >= 0 ? lines[firstNonBlank].trim() : '';
const isClient = /^['"]use client['"]\s*;?$/.test(directive);
const inAppDir = /(?:^|\/)app\//.test(file);
const pushFinding = (ruleId, lineIndex, confidence, reason, detail) => {
if (this.isSuppressed(lines, lineIndex, ruleId))
return;
const meta = (0, rules_1.getRule)(ruleId)?.meta;
if (!meta)
return;
out.push(this.createResult({
type: meta.severity === 'high' ? 'error' : 'warning',
category: meta.category,
severity: meta.severity,
ruleId: meta.id,
message: detail ? `${meta.message} — ${detail}` : meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)((lines[lineIndex] ?? '').trim().slice(0, 200)),
confidence,
confidenceReason: reason
}, lines[lineIndex]));
};
// NEXT220: window / document access in a Server Component (app/**/*.tsx
// without 'use client'). Match `window.` or `document.` read.
if (inAppDir && !isClient && /\.(?:tsx|jsx)$/.test(file)) {
lines.forEach((line, i) => {
if (/\btypeof\s+window\s*!==?\s*['"]undefined['"]/.test(line) ||
/\bwindow\.[A-Za-z_$]/.test(line) ||
/\bdocument\.[A-Za-z_$]/.test(line)) {
pushFinding('NEXT220', i, 0.75, 'Browser global referenced in a Server Component (no `use client` directive).');
}
});
}
// NEXT221: Client Component imports a server-only module.
if (isClient) {
const serverOnly = [
'fs', 'fs/promises', 'child_process', 'net', 'dgram', 'dns',
'better-sqlite3', 'pg', 'mysql2', 'mongodb', 'redis', 'ioredis',
'@prisma/client', 'prisma', 'drizzle-orm/node-postgres',
'drizzle-orm/better-sqlite3', 'drizzle-orm/mysql2'
];
const importRegex = /import\s+(?:[^'"`]+?\s+from\s+)?['"]([^'"`]+)['"]/g;
let m;
while ((m = importRegex.exec(content))) {
const spec = m[1];
if (serverOnly.includes(spec) || serverOnly.some((s) => spec.startsWith(`${s}/`))) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
pushFinding('NEXT221', lineIndex, 0.9, `Client component imports \`${spec}\`, a server-only module.`, `imports \`${spec}\``);
}
}
}
// NEXT222: Server Component imports a client-only state library.
if (inAppDir && !isClient && /\.(?:tsx|jsx)$/.test(file)) {
const clientOnly = ['zustand', 'jotai', 'recoil', 'valtio', 'mobx'];
const importRegex = /import\s+(?:[^'"`]+?\s+from\s+)?['"]([^'"`]+)['"]/g;
let m;
while ((m = importRegex.exec(content))) {
const spec = m[1];
const top = spec.split('/')[0];
if (clientOnly.includes(top)) {
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
pushFinding('NEXT222', lineIndex, 0.8, `Server Component imports \`${spec}\` (client-only state library).`, `imports \`${spec}\``);
}
}
}
// NEXT223: route.ts uses `export default`.
if (/(?:^|\/)app\/.*\/route\.(?:ts|tsx)$/.test(file) || /(?:^|\/)app\/route\.(?:ts|tsx)$/.test(file)) {
const idx = lines.findIndex((l) => /^\s*export\s+default\b/.test(l));
if (idx >= 0) {
pushFinding('NEXT223', idx, 0.95, 'Route file uses `export default`; Next ignores defaults in route handlers.');
}
}
// NEXT224: <a href="/internal"> in a page/component.
if (/\.(?:tsx|jsx)$/.test(file)) {
const aRegex = /<a\b([^>]*?)\bhref\s*=\s*["']\/[^"'#?]*["']([^>]*)>/g;
let m;
while ((m = aRegex.exec(content))) {
const attrs = `${m[1]} ${m[2]}`;
if (/\btarget\s*=\s*["']_blank["']/.test(attrs))
continue; // external-ish new tab
if (/\bdownload\b/.test(attrs))
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
pushFinding('NEXT224', lineIndex, 0.7, 'Internal anchor used; prefer `next/link` for client navigation.');
}
}
// NEXT225: <form method="POST" action="/api/..."> without Server Action.
if (/\.(?:tsx|jsx)$/.test(file)) {
const formRegex = /<form\b([^>]*)>/gi;
let m;
while ((m = formRegex.exec(content))) {
const attrs = m[1];
const isPost = /\bmethod\s*=\s*["']post["']/i.test(attrs);
const actionMatch = /\baction\s*=\s*["']([^"']+)["']/i.exec(attrs);
const actionExpr = /\baction\s*=\s*\{[^}]+\}/.test(attrs);
if (!isPost || !actionMatch)
continue;
const action = actionMatch[1];
if (!action.startsWith('/'))
continue;
// Using a Server Action via action={fn} is OK; flag only string actions.
if (actionExpr)
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
pushFinding('NEXT225', lineIndex, 0.7, 'POST form targets a URL string without a Server Action wrapper or visible CSRF guard.');
}
}
return out;
}
}
exports.FrameworkScanner = FrameworkScanner;
//# sourceMappingURL=framework-scanner.js.map