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.

622 lines 38.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SecurityScanner = void 0; const glob_1 = require("glob"); const fs_1 = require("fs"); const path_1 = require("path"); const rules_1 = require("../rules"); const entropy_1 = require("../utils/entropy"); const result_cache_1 = require("../utils/result-cache"); const file_source_cache_1 = require("../utils/file-source-cache"); class SecurityScanner { name = 'Security Scanner'; // Rule IDs that have been migrated to modular rules with patterns modularRuleIds = [ 'SEC001', 'SEC002', 'SEC003', 'SEC004', 'SEC005', 'SEC006', 'SEC007', 'SEC008', 'SEC009', 'SEC010', 'SEC011', 'SEC012', 'SEC013', 'SEC014', 'SEC015', 'SEC016', 'SEC017', 'AI001', 'AI004' ]; // Confidence reasons for each rule confidenceReasons = { 'SEC001': 'Pattern matches known API key prefixes (sk-, pk_test_, etc.)', 'SEC002': 'URL matches Supabase project pattern', 'SEC003': 'String matches JWT token structure (three base64 segments)', 'SEC004': 'Pattern matches Firebase config keys', 'SEC005': 'Pattern matches Stripe key prefixes', 'SEC006': 'Variable name contains "password" with non-empty string value', 'SEC007': 'Pattern matches private key header', 'SEC008': 'Environment variable with hardcoded fallback string', 'SEC009': 'Pattern matches AWS Access Key ID format (AKIA...)', 'SEC010': 'Pattern matches Slack webhook URL structure', 'SEC011': 'Pattern matches GitHub token prefixes (ghp_, gho_, etc.)', 'SEC012': 'Pattern matches Twilio Account SID format', 'SEC013': 'Pattern matches SendGrid API key format', 'SEC014': 'Pattern matches OpenAI API key format (sk-...)', 'SEC015': 'Console statement detected in production code', 'SEC016': 'Direct eval() call detected - code execution risk', 'SEC017': 'dangerouslySetInnerHTML usage - XSS risk if content unsanitized', 'SEC018': 'High Shannon entropy suggests random/secret data', 'NEXT201': 'Missing 404/not-found page in Next.js app', 'NEXT202': 'Missing error boundary in Next.js app', 'JSNET001': 'HTTP request without timeout can hang indefinitely', 'COOKIE001': 'Cookie missing security attributes (HttpOnly, Secure, SameSite)', 'AI001': 'String matches an LLM provider key prefix (sk-, sk-ant-, AIza, gsk_, r8_, etc.)', 'AI004': 'String matches a known vector DB credential format (Pinecone, Qdrant, Weaviate)' }; async scan(options) { const results = []; const files = await (0, glob_1.glob)('**/*.{js,jsx,ts,tsx,svelte,astro,env}', { cwd: options.directory, ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'examples/**', ...(options.exclude || [])] }); const signature = `sec:2:profile:${options.profile || 'auto'}`; const resultCache = options.noResultCache ? null : new result_cache_1.ResultCache(options.directory, signature); let processed = 0; // Load modular rules const modularRules = this.modularRuleIds.map(id => (0, rules_1.getRule)(id)).filter(Boolean); // NEXT* rules gate: require a real Next.js project so an Astro fixture // with `src/pages/api/*` doesn't spuriously flag NEXT209. const isNextProject = (() => { try { const pkgPath = (0, path_1.join)(options.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)(options.directory, 'next.config.js')) || (0, fs_1.existsSync)((0, path_1.join)(options.directory, 'next.config.ts')) || (0, fs_1.existsSync)((0, path_1.join)(options.directory, 'next.config.mjs')) || (0, fs_1.existsSync)((0, path_1.join)(options.directory, 'next.config.cjs'))); })(); // Project-level Next.js structure presence flags (for P5 experimental rules) const hasAppDir = files.some(f => /^app\//.test(f)); const hasPagesDir = files.some(f => /^pages\//.test(f)); const hasNotFoundApp = files.some(f => /^app\/not-found\.(js|jsx|ts|tsx)$/.test(f)); const has404Pages = files.some(f => /^pages\/404\.(js|jsx|ts|tsx)$/.test(f)); const hasErrorApp = files.some(f => /^app\/error\.(js|jsx|ts|tsx)$/.test(f)); const hasErrorPages = files.some(f => /^pages\/_error\.(js|jsx|ts|tsx)$/.test(f)); const hasDocumentPages = files.some(f => /^pages\/_document\.(js|jsx|ts|tsx)$/.test(f)); // Emit-once sentinels let emittedP5_404 = false; let emittedP5_error = false; let emittedP5_document = false; 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) { try { const absolute = (0, path_1.join)(options.directory, file); try { if ((0, fs_1.statSync)(absolute).size > maxSize) continue; } catch { continue; } const content = sourceCache.read(absolute); if (content === undefined) continue; const contentHash = result_cache_1.ResultCache.hashContent(content); const cached = resultCache?.get(file, contentHash); if (cached) { results.push(...cached); processed++; if (options.verbose && processed % 25 === 0) { console.log('🪷', `Scanning... (${processed}/${files.length} files)`); } continue; } const lines = content.split('\n'); processed++; if (options.verbose && processed % 25 === 0) { console.log('🪷', `Scanning... (${processed}/${files.length} files)`); } const fileExt = file.split('.').pop()?.toLowerCase() || ''; let ubonDisableAll = false; lines.forEach((line) => { if (/ubon-disable-file/.test(line)) { ubonDisableAll = true; } }); if (ubonDisableAll) continue; // Run modular rules with patterns for (const rule of modularRules) { if (!rule) continue; if (rule.impl.fileTypes && !rule.impl.fileTypes.includes(fileExt)) continue; if (rule.impl.skipPatterns?.some(p => p.test(file))) continue; if (rule.impl.patterns) { for (const pattern of rule.impl.patterns) { lines.forEach((line, index) => { // Skip comments and pattern definitions if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.includes('pattern:') || line.includes('message:') || line.includes('severity:') || line.includes('fix:') || file.includes('security-scanner.ts') || file.includes('/rules/security/')) { return; } // Skip console logs in logger files (intentional) if (pattern.ruleId === 'SEC015' && file.includes('logger')) { return; } // Check inline suppressions const disableNext = /ubon-disable-next-line\s+([A-Z0-9_,\s-]+)/.exec(line); const prevDisable = index > 0 ? /ubon-disable-next-line\s+([A-Z0-9_,\s-]+)/.exec(lines[index - 1]) : null; const disabledList = new Set([ ...(disableNext && disableNext[1] ? disableNext[1].split(/[,\s]+/).filter(Boolean) : []), ...(prevDisable && prevDisable[1] ? prevDisable[1].split(/[,\s]+/).filter(Boolean) : []) ]); if (disabledList.has(pattern.ruleId)) return; const m = line.match(pattern.pattern); if (m) { const fixEdits = []; // Auto-fix for SEC008: Remove hardcoded fallback if (pattern.ruleId === 'SEC008') { const replacement = line.replace(/(process\.env\.\w+)\s*\|\|\s*['"][^'"`]+['"]/g, '$1'); if (replacement !== line) { fixEdits.push({ file, startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length), replacement }); } } // Auto-fix for SEC015: Remove console statement if (pattern.ruleId === 'SEC015') { fixEdits.push({ file, startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length), replacement: '' }); } results.push({ type: pattern.severity === 'high' ? 'error' : 'warning', category: 'security', message: pattern.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, match: m[0]?.slice(0, 200), severity: pattern.severity, ruleId: pattern.ruleId, confidence: pattern.confidence, confidenceReason: this.confidenceReasons[pattern.ruleId] || 'Pattern match detected', fix: pattern.fix, ...(fixEdits.length ? { fixEdits } : {}) }); } }); } } } // Project-level Next.js structure checks (emit once per project). // Gate on real Next.js: Remix also uses `app/` and we don't want to // flag a missing `app/not-found.tsx` in a Remix project. if (isNextProject && /^(pages|app)\//.test(file)) { // Missing not-found/404 if (!emittedP5_404) { if (hasAppDir && !hasNotFoundApp) { const meta = rules_1.RULES.NEXT201; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.5, confidenceReason: this.confidenceReasons['NEXT201'], fix: meta.fix }); emittedP5_404 = true; } else if (hasPagesDir && !has404Pages) { const meta = rules_1.RULES.NEXT201; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.5, confidenceReason: this.confidenceReasons['NEXT201'], fix: meta.fix }); emittedP5_404 = true; } } // Missing error boundary if (!emittedP5_error) { if (hasAppDir && !hasErrorApp) { const meta = rules_1.RULES.NEXT202; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.5, confidenceReason: this.confidenceReasons['NEXT202'], fix: meta.fix }); emittedP5_error = true; } else if (hasPagesDir && !hasErrorPages) { const meta = rules_1.RULES.NEXT202; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.5, confidenceReason: this.confidenceReasons['NEXT202'], fix: meta.fix }); emittedP5_error = true; } } // _document for Pages Router only when code hints custom head/script usage if (!emittedP5_document && hasPagesDir && /from\s+['"]next\/(head|script)['"]/.test(content) && !hasDocumentPages) { const meta = rules_1.RULES.NEXT203; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.5, confidenceReason: 'Custom head/script usage detected without _document.tsx', fix: meta.fix }); emittedP5_document = true; } } // JS HTTP timeout/retry policy checks lines.forEach((line, index) => { // axios without timeout if (/axios\.(get|post|put|delete|patch)\(/i.test(line) && !/timeout\s*:/i.test(line)) { const meta = rules_1.RULES.JSNET001; results.push({ type: meta.severity === 'high' ? 'error' : 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, match: line.slice(0, 200), confidence: 0.7, confidenceReason: 'axios call without timeout option - requests can hang indefinitely', fix: meta.fix }); } // fetch without AbortController/timeout wrappers (heuristic) if (/\bfetch\s*\(/.test(line) && !/AbortController|signal\s*:/.test(line)) { const meta = rules_1.RULES.JSNET001; const suggested = line.replace(/fetch\(([^)]*)\)/, 'fetch($1, { signal })'); const fixEdits = [{ file, startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length), replacement: suggested }]; results.push({ type: meta.severity === 'high' ? 'error' : 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, match: line.slice(0, 200), confidence: 0.6, confidenceReason: 'fetch() without AbortController signal - lower confidence as signal may be added elsewhere', fix: 'Use AbortController with a timeout to cancel long fetches', fixEdits }); } // Set-Cookie missing attributes (with fix edits) if (/setHeader\(\s*['"][Ss]et-[Cc]ookie['"],\s*['"][^'"]+['"]\s*\)/.test(line) || /Set-Cookie:/i.test(line)) { const cookieStrMatch = line.match(/Set-Cookie:\s*([^;]+(?:;[^;]+)*)/i); const cookieStr = cookieStrMatch ? cookieStrMatch[1] : line; const hasHttpOnly = /HttpOnly/i.test(cookieStr); const hasSecure = /Secure/i.test(cookieStr); const hasSameSite = /SameSite/i.test(cookieStr); if (!(hasHttpOnly && hasSecure && hasSameSite)) { const meta = rules_1.RULES.COOKIE001; const needed = `${hasHttpOnly ? '' : '; HttpOnly'}${hasSecure ? '' : '; Secure'}${hasSameSite ? '' : '; SameSite=Lax'}`; const fixedLine = line.replace(/(['"])\s*\)\s*;?$/, `${needed}$1)`); const fixEdits = [{ file, startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length), replacement: fixedLine }]; results.push({ type: meta.severity === 'high' ? 'error' : 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, match: line.slice(0, 200), confidence: 0.8, confidenceReason: this.confidenceReasons['COOKIE001'], fix: meta.fix, fixEdits }); } } }); // Entropy-based secret detection (context-aware, reduced noise) lines.forEach((line, index) => { const toks = (0, entropy_1.extractQuotedLiterals)(line).filter(s => s.length >= 16); for (const tok of toks) { const ent = (0, entropy_1.shannonEntropy)(tok); if (ent < 3.8 || !/[A-Za-z0-9]/.test(tok)) continue; // File/context-based ignores: CSS/Tailwind, configs, globs const lowerFile = file.toLowerCase(); const isCssContext = lowerFile.endsWith('.css') || lowerFile.endsWith('.scss') || lowerFile.endsWith('.sass') || lowerFile.endsWith('.less') || lowerFile.includes('tailwind.config'); if (isCssContext) continue; // Token-based ignores: hex colors, tailwind classes, data URIs, globs, UUIDs const isHexColor = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(tok); const isTailwind = /(bg|text|border|from|to|via)-[a-zA-Z]+-\d{2,3}/.test(tok); const isDataUri = /^data:image\//.test(tok); const isGlobLike = /\*\*?|\{.*\}|\*\.[a-zA-Z0-9]+/.test(tok); const isUuid = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/.test(tok); if (isHexColor || isTailwind || isDataUri || isGlobLike || isUuid) continue; // Suspicious indicators increase confidence const looksLikeSecret = /\b(sk-|pk_live_|rk_(live|test)_|eyJ[A-Za-z0-9._-]{10,}|AKIA[0-9A-Z]{16}|password=|secret=|api_key=|token=|postgres(ql)?:\/\/|mongodb:\/\/)/.test(tok); const isDotEnvFile = /(^|\/)\.env(\.|$)/.test(lowerFile); if (!looksLikeSecret && !isDotEnvFile) continue; const meta = rules_1.RULES.SEC018; const entropyConfidence = looksLikeSecret ? 0.9 : 0.8; results.push({ type: meta.severity === 'high' ? 'error' : 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, match: tok.slice(0, 200), confidence: entropyConfidence, confidenceReason: looksLikeSecret ? 'High entropy + matches known secret pattern (sk-, AKIA, etc.)' : 'High entropy string in .env file - likely a secret', fix: meta.fix }); } }); // Secret logging detection (console + secret-like token) lines.forEach((line, index) => { if (/console\.(log|debug|info|warn|error)\(/.test(line) && /(sk-[A-Za-z0-9_-]{8,}|eyJ[A-Za-z0-9._-]{20,}|AKIA[0-9A-Z]{16})/.test(line)) { const meta = rules_1.RULES.LOG001; const fixEdits = []; // Replace logged secret literal with a redacted placeholder const redacted = line .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-********') .replace(/eyJ[A-Za-z0-9._-]{20,}/g, 'eyJ********') .replace(/AKIA[0-9A-Z]{16}/g, 'AKIA**************'); fixEdits.push({ file, startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length), replacement: redacted }); results.push({ type: 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.8, confidenceReason: 'Console statement contains string matching secret pattern', fix: meta.fix, fixEdits }); } }); // Next.js SSR secrets heuristic (legacy NEXT006) if (/get(ServerSideProps|StaticProps)\s*\(/.test(content) && /process\.env\./.test(content)) { const meta = rules_1.RULES.NEXT006; results.push({ type: 'error', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.6, fix: meta.fix }); } // Experimental P5: Server -> Client secret bleed (NEXT210) if (/\bexport\s+async\s+function\s+get(ServerSideProps|StaticProps)\b|\bexport\s+const\s+get(ServerSideProps|StaticProps)\b/.test(content)) { const readsSecret = /(process\.env\.(?!NEXT_PUBLIC_)[A-Z0-9_]+|secret|apiKey|token)/.test(content); // Bounded-greedy variant of the historical NEXT210 regex. The old // pattern (`[\s\S]*` between `props: {` and the sensitive // identifier) could backtrack catastrophically on adversarially // long single-line minified bundles. We cap to 2000 chars and use // a lazy quantifier — false negatives in pathological files are an // acceptable trade for a hard ReDoS ceiling. const returnsPropsWithSensitive = /return\s*\{\s*props\s*:\s*\{[\s\S]{0,2000}?\b(secret|token|apiKey|password|auth|key)\b/.test(content); if (readsSecret && returnsPropsWithSensitive) { const meta = rules_1.RULES.NEXT210; results.push({ type: 'error', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.7, fix: meta.fix }); } } // Next.js API route basic input validation heuristic if (/\b(pages|app)\/api\//.test(file)) { const hasValidatorImport = /from\s+['\"](zod|yup|ajv|valibot|superstruct|class-validator)['\"]/.test(content) || /import\s+\{?\s*(z|yup|Ajv|object|safeParse)/.test(content); let usesRequestParams = false; for (let i = 0; i < lines.length; i++) { const l = lines[i]; if (/(req|request)\.(body|query|params)/.test(l)) { usesRequestParams = true; break; } } if (usesRequestParams && !hasValidatorImport) { const meta = rules_1.RULES.NEXT003; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.6, fix: meta.fix }); } } // Next.js specific JWT/Cookie security checks if (/\b(pages|app)\/api\//.test(file) || file.includes('middleware.ts')) { lines.forEach((line, index) => { // Check for JWT tokens in JSON responses (NEXT007) if (/res\.json\s*\(\s*\{[^}]*\b(token|jwt|accessToken|authToken)\s*:/.test(line)) { const meta = rules_1.RULES.NEXT007; results.push({ type: 'error', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.9, fix: meta.fix }); } // Check for unsafe redirects (NEXT009) if (/res\.redirect\s*\(\s*(req\.query\.|req\.body\.|req\.params\.)/.test(line)) { const meta = rules_1.RULES.NEXT009; results.push({ type: 'error', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.8, fix: meta.fix }); } // Check for permissive CORS (NEXT010) if (/Access-Control-Allow-Origin.*\*/.test(line) || /cors\s*\(\s*\{\s*origin:\s*['"`]\*['"`]/.test(line)) { const meta = rules_1.RULES.NEXT010; results.push({ type: 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.7, fix: meta.fix }); } // Check for environment variables leaked to client (NEXT011) if (/process\.env\.(?!NEXT_PUBLIC_)\w+/.test(line) && (file.includes('components/') || file.includes('pages/') && !file.includes('/api/') || file.includes('app/') && !file.includes('/api/'))) { const meta = rules_1.RULES.NEXT011; results.push({ type: 'error', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.8, fix: meta.fix }); } }); // Check for missing security headers (NEXT008) const hasSecurityHeaders = /X-Content-Type-Options|X-Frame-Options|X-XSS-Protection|Strict-Transport-Security/.test(content); const setsHeaders = /res\.setHeader|headers\s*:\s*\{/.test(content); if (setsHeaders && !hasSecurityHeaders && !file.includes('_app.') && !file.includes('middleware.')) { const meta = rules_1.RULES.NEXT008; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.6, fix: meta.fix }); } } // API route structure: missing method validation (NEXT209) and unauthenticated sensitive responses (NEXT205) if (isNextProject && /\b(pages|app)\/api\//.test(file)) { const isPagesAPI = /\bpages\/api\//.test(file); const isAppRoute = /\bapp\/api\//.test(file); if (isPagesAPI) { const mentionsReqMethodCheck = /req\.method/.test(content) && /(GET|POST|PUT|DELETE|PATCH)/.test(content); if (!mentionsReqMethodCheck) { const meta = rules_1.RULES.NEXT209; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.6, fix: meta.fix }); } } if (isAppRoute) { const hasMethodExports = /export\s+async\s+function\s+(GET|POST|PUT|DELETE|PATCH)\s*\(/.test(content); if (!hasMethodExports) { const meta = rules_1.RULES.NEXT209; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.6, fix: meta.fix }); } } // Unauthenticated API access heuristic: returns user/secrets without typical auth artifacts const returnsSensitive = /res\.(json|send)\(\s*\{[^}]*\b(user|email|token|secret|apiKey)\b/.test(content); const hasAuthSignals = /(getServerSession|next-auth|Authorization|getToken|cookies\(|jwt)/.test(content); if (returnsSensitive && !hasAuthSignals) { const meta = rules_1.RULES.NEXT205; results.push({ type: 'warning', category: meta.category, message: meta.message, file, severity: meta.severity, ruleId: meta.id, confidence: 0.6, fix: meta.fix }); } } // Navigation: router.push with external URL (NEXT208) if (/useRouter\(\)/.test(content) || /from\s+['"]next\/navigation['"]/.test(content)) { lines.forEach((line, index) => { if (/router\.push\(\s*['"][a-z]+:\/\//i.test(line)) { const meta = rules_1.RULES.NEXT208; results.push({ type: 'warning', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.7, fix: meta.fix }); } }); } // Check for insecure JWT cookies (COOKIE002) and propose fix edits lines.forEach((line, index) => { const isJwtCookie = /(Set-Cookie|setHeader\(\s*['"][Ss]et-[Cc]ookie['"])|setCookie\s*\(/.test(line) && /(jwt|token)=/i.test(line); const missingHttpOnly = !/HttpOnly/i.test(line); const missingSecure = !/Secure/i.test(line); if (isJwtCookie && (missingHttpOnly || missingSecure)) { const meta = rules_1.RULES.COOKIE002; const addition = `${missingHttpOnly ? '; HttpOnly' : ''}${missingSecure ? '; Secure' : ''}`; const fixed = line.replace(/(['"])\s*\)\s*;?$/, `${addition}$1)`); const fixEdits = [{ file, startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length), replacement: fixed }]; results.push({ type: 'error', category: meta.category, message: meta.message, file, line: index + 1, range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) }, severity: meta.severity, ruleId: meta.id, confidence: 0.9, fix: meta.fix, fixEdits }); } }); // store per-file results for this file only const fileResults = results.filter(r => r.file === file); resultCache?.set(file, contentHash, fileResults); } catch (error) { if (options.verbose) { console.error(`🪷 SecurityScanner: failed to read ${file}:`, error); } } } resultCache?.save(); return results; } } exports.SecurityScanner = SecurityScanner; //# sourceMappingURL=security-scanner.js.map