#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const parser = require('@babel/parser'); const args = process.argv.slice(2); const root = path.resolve(args.find(a => !a.startsWith('--')) || '.'); const useSarif = args.includes('--sarif'); const exts = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']); const findings = []; // .securityignore const ignorePatterns = ['node_modules', '.git', '.next', 'dist', 'build', 'coverage']; if (fs.existsSync(path.join(root, '.securityignore'))) { const customIgnores = fs.readFileSync(path.join(root, '.securityignore'), 'utf8') .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')) .map(l => l.replace(/\/$/, '')); ignorePatterns.push(...customIgnores); } function shouldIgnore(p) { return ignorePatterns.some(ign => p.includes(ign)); } function walk(dir, out = []) { if (!fs.existsSync(dir)) return out; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (shouldIgnore(p)) continue; if (entry.isDirectory()) walk(p, out); else out.push(p); } return out; } function addFinding(f) { findings.push(f); } function scanDependencies() { if (fs.existsSync(path.join(root, 'package.json'))) { try { execSync('npm audit --json', { cwd: root, stdio: ['pipe', 'pipe', 'ignore'] }); } catch (e) { if (e.stdout) { try { const audit = JSON.parse(e.stdout.toString()); if (audit.vulnerabilities) { for (const [pkg, details] of Object.entries(audit.vulnerabilities)) { addFinding({ title: `Vulnerable dependency: ${pkg}`, severity: details.severity === 'critical' || details.severity === 'high' ? 'high' : 'medium', confidence: 'high', status: 'confirmed', evidence: `npm audit reported ${details.severity} vulnerability in ${pkg}`, impact: 'May expose application to known CVE exploits', affectedFiles: ['package.json'], line: 1, recommendedFix: `Run npm audit fix or manually update ${pkg}.` }); } } } catch(err) {} } } } } function detectFramework(files) { const rels = files.map(f => path.relative(root, f)); const hasNext = rels.some(f => /(^|\\|\/)next\.config\./.test(f)) || fs.existsSync(path.join(root, 'next.config.js')) || fs.existsSync(path.join(root, 'next.config.ts')); const hasApp = rels.some(f => /(^|\\|\/)(src\/)?app\/.+/.test(f)); const hasPages = rels.some(f => /(^|\\|\/)(src\/)?pages\/.+/.test(f)); const hasVite = rels.some(f => /vite\.config\./.test(f)) || fs.existsSync(path.join(root, 'vite.config.ts')) || fs.existsSync(path.join(root, 'vite.config.js')); if (hasNext && hasApp) return 'nextjs-app-router'; if (hasNext && hasPages) return 'nextjs-pages-router'; if (hasNext) return 'nextjs'; if (hasVite || fs.existsSync(path.join(root, 'public')) || fs.existsSync(path.join(root, 'src'))) return 'react-spa'; return 'javascript-app'; } function getIgnoredLines(ast) { const ignored = new Set(); if (ast && ast.comments) { for (const comment of ast.comments) { if (comment.value.includes('antigravity-disable-next-line')) { ignored.add(comment.loc.end.line + 1); } } } return ignored; } function check(lineText, regex, options) { if (regex.test(lineText)) { return options; // Returns the finding object delta } return null; } function scanText(file, text) { const rel = path.relative(root, file); let ast; let ignoredLines = new Set(); if (exts.has(path.extname(file))) { try { ast = parser.parse(text, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); ignoredLines = getIgnoredLines(ast); } catch(e) {} } const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const lineNum = i + 1; if (ignoredLines.has(lineNum)) continue; const l = lines[i]; const ctx = l; // keeping it simple, context is the line for regex const rules = [ // antigravity-disable-next-line check(ctx, /dangerouslySetInnerHTML/, { title: 'Unsafe HTML rendering pattern', severity: 'high', confidence: 'medium', status: 'probable', evidence: 'dangerouslySetInnerHTML found', impact: 'Potential XSS if fed untrusted content', recommendedFix: 'Sanitize content and restrict sources before rendering HTML.' }), check(ctx, /(localStorage|sessionStorage)\.(setItem|getItem)\(/, { title: 'Token-like data stored in browser storage', severity: 'high', confidence: 'low', status: 'manual-review', evidence: 'localStorage/sessionStorage usage', impact: 'Token theft risk via XSS or browser compromise', recommendedFix: 'Prefer secure httpOnly cookies for session handling if this is a token.' }), check(ctx, /process\.env\.[A-Z0-9_]+/, { title: 'Potential secret exposure via client environment', severity: 'high', confidence: 'low', status: 'manual-review', evidence: 'NEXT_PUBLIC or similar variable found', impact: 'Sensitive values may be exposed to the browser bundle', recommendedFix: 'Verify that only public, non-sensitive values use exposed variables.' }), check(/(SELECT|INSERT|UPDATE|DELETE)[\s\S]{0,120}\$\{/.test(text) ? l : '', /(SELECT|INSERT|UPDATE|DELETE).*\$\{/, { // multi-line approximation via full text title: 'Potential SQL injection sink', severity: 'critical', confidence: 'medium', status: 'probable', evidence: 'Interpolated SQL or unsafe raw query sink detected', impact: 'Attacker-controlled input may alter database queries', recommendedFix: 'Use parameterized queries or safe ORM APIs.' }), check(ctx, /req\.(body|query)|searchParams|get\(['\"]/, { title: 'Server boundary without obvious input validation', severity: 'high', confidence: 'low', status: 'manual-review', evidence: 'Request input usage', impact: 'Malformed or hostile input may reach sensitive logic', recommendedFix: 'Validate request data at the server boundary with a schema.' }), check(ctx, /(openai|anthropic|claude|gemini).*(messages\.create|responses\.create|generateContent|chat\.completions)/i, { title: 'LLM endpoint with prompt injection exposure', severity: 'high', confidence: 'medium', status: 'probable', evidence: 'LLM SDK call', impact: 'Model behavior may be steered to reveal or misuse privileged instructions and tools', recommendedFix: 'Separate system instructions, validate tool args, add allowlists, and sanitize user content.' }), check(ctx, /(fetch|axios\.|got\()/, { title: 'Potential SSRF pattern', severity: 'high', confidence: 'low', status: 'manual-review', evidence: 'Server-side outbound request', impact: 'Attacker may force calls to internal or unintended hosts', recommendedFix: 'Enforce host allowlists and validate URLs before outbound requests.' }), check(ctx, /(sk_live_|AIza[0-9A-Za-z\-_]{20,}|xox[baprs]-|-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----)/, { title: 'Hardcoded credential material', severity: 'critical', confidence: 'high', status: 'confirmed', evidence: 'Secret-like token or private key marker detected', impact: 'Credential compromise may permit direct unauthorized access', recommendedFix: 'Remove from code, rotate credentials, and load via secure server-side environment management.' }) ]; for (const res of rules) { if (res) { addFinding({ ...res, affectedFiles: [rel], line: lineNum }); } } } } scanDependencies(); const allFiles = walk(root).filter(f => exts.has(path.extname(f)) || path.basename(f) === '.gitignore' || /package(-lock)?\.json$|pnpm-lock\.yaml$|yarn\.lock$|index\.html$|next\.config\./.test(path.basename(f))); const framework = detectFramework(allFiles); if (fs.existsSync(path.join(root, '.env')) && fs.existsSync(path.join(root, '.gitignore'))) { const gi = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); if (!/^\.env(\..*)?$/m.test(gi) && !/\.env/.test(gi)) { addFinding({ title: '.env not ignored by gitignore', severity: 'high', confidence: 'medium', status: 'probable', evidence: '.env exists and .gitignore does not obviously ignore it', impact: 'Secrets may be accidentally committed', affectedFiles: ['.env', '.gitignore'], line: 1, recommendedFix: 'Ignore .env files and verify they are not tracked in git history.' }); } } for (const file of allFiles) { try { const text = fs.readFileSync(file, 'utf8'); if (exts.has(path.extname(file))) scanText(file, text); } catch (e) {} } const summary = findings.reduce((acc, f) => { acc.total += 1; acc[f.severity] = (acc[f.severity] || 0) + 1; return acc; }, { total: 0, critical: 0, high: 0, medium: 0, low: 0 }); if (useSarif) { const sarif = { version: "2.1.0", $schema: "http://json.schemastore.org/sarif-2.1.0-rtm.5", runs: [ { tool: { driver: { name: "Security Auditor Antigravity", version: "1.0.0", rules: Object.values(findings.reduce((acc, f) => { acc[f.title] = { id: f.title }; return acc; }, {})) } }, results: findings.map(f => ({ ruleId: f.title, level: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note', message: { text: f.evidence }, locations: f.affectedFiles.map(file => ({ physicalLocation: { artifactLocation: { uri: file }, region: { startLine: f.line || 1 } } })) })) } ] }; console.log(JSON.stringify(sarif, null, 2)); } else { console.log(JSON.stringify({ framework, root, summary, findings }, null, 2)); }