Files

230 lines
9.8 KiB
JavaScript

#!/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));
}