230 lines
9.8 KiB
JavaScript
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));
|
|
}
|