vbguard 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -79
- package/package.json +4 -3
- package/src/bin.js +2 -0
- package/src/cli.js +91 -12
- package/src/index.js +53 -1
- package/src/precommit.js +67 -0
- package/src/reporter.js +126 -1
- package/src/scanners/auth-flow.js +234 -0
- package/src/scanners/firebase.js +124 -0
- package/src/scanners/hallucinated-packages.js +247 -0
- package/src/scanners/nextjs.js +139 -0
- package/src/scanners/supabase.js +102 -0
- package/src/scanners/vibe-patterns.js +193 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
|
|
6
|
+
// Popular packages for typosquat detection (edit distance check)
|
|
7
|
+
const POPULAR_NPM = [
|
|
8
|
+
'express', 'react', 'lodash', 'axios', 'moment', 'chalk', 'commander',
|
|
9
|
+
'inquirer', 'webpack', 'babel', 'typescript', 'eslint', 'prettier',
|
|
10
|
+
'mongoose', 'sequelize', 'prisma', 'next', 'vue', 'angular', 'svelte',
|
|
11
|
+
'tailwindcss', 'dotenv', 'cors', 'helmet', 'jsonwebtoken', 'bcrypt',
|
|
12
|
+
'uuid', 'dayjs', 'zod', 'yup', 'joi', 'socket.io', 'passport',
|
|
13
|
+
'nodemon', 'jest', 'mocha', 'vitest', 'puppeteer', 'playwright',
|
|
14
|
+
'cheerio', 'redis', 'pg', 'mysql2', 'sqlite3', 'stripe', 'twilio',
|
|
15
|
+
'aws-sdk', 'firebase', 'supabase', 'openai', 'langchain',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const POPULAR_PYPI = [
|
|
19
|
+
'flask', 'django', 'fastapi', 'requests', 'numpy', 'pandas', 'scipy',
|
|
20
|
+
'tensorflow', 'pytorch', 'scikit-learn', 'matplotlib', 'pillow',
|
|
21
|
+
'sqlalchemy', 'celery', 'redis', 'boto3', 'pydantic', 'uvicorn',
|
|
22
|
+
'gunicorn', 'pytest', 'black', 'mypy', 'openai', 'langchain',
|
|
23
|
+
'beautifulsoup4', 'scrapy', 'httpx', 'aiohttp', 'cryptography',
|
|
24
|
+
'pyyaml', 'python-dotenv', 'alembic', 'jinja2', 'click', 'typer',
|
|
25
|
+
'rich', 'stripe', 'twilio', 'paramiko', 'fabric',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function editDistance(a, b) {
|
|
29
|
+
const m = a.length, n = b.length;
|
|
30
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
31
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
32
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
33
|
+
for (let i = 1; i <= m; i++) {
|
|
34
|
+
for (let j = 1; j <= n; j++) {
|
|
35
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
36
|
+
? dp[i - 1][j - 1]
|
|
37
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return dp[m][n];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findTyposquatTarget(name, popularList) {
|
|
44
|
+
for (const popular of popularList) {
|
|
45
|
+
if (name === popular) return null;
|
|
46
|
+
const dist = editDistance(name.toLowerCase(), popular.toLowerCase());
|
|
47
|
+
if (dist > 0 && dist <= 2) return popular;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function httpGet(url) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const client = url.startsWith('https') ? https : http;
|
|
55
|
+
const req = client.get(url, { timeout: 10000 }, (res) => {
|
|
56
|
+
let data = '';
|
|
57
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
58
|
+
res.on('end', () => resolve({ status: res.statusCode, data }));
|
|
59
|
+
});
|
|
60
|
+
req.on('error', reject);
|
|
61
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function delay(ms) {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function checkNpmPackage(name) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await httpGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`);
|
|
72
|
+
if (res.status === 404) return { exists: false };
|
|
73
|
+
if (res.status === 200) {
|
|
74
|
+
try {
|
|
75
|
+
const pkg = JSON.parse(res.data);
|
|
76
|
+
const timeCreated = pkg.time && pkg.time.created ? new Date(pkg.time.created) : null;
|
|
77
|
+
return { exists: true, created: timeCreated, data: pkg };
|
|
78
|
+
} catch {
|
|
79
|
+
return { exists: true };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { exists: true };
|
|
83
|
+
} catch {
|
|
84
|
+
return { exists: null }; // Network error — skip
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function checkPypiPackage(name) {
|
|
89
|
+
try {
|
|
90
|
+
const res = await httpGet(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
|
|
91
|
+
if (res.status === 404) return { exists: false };
|
|
92
|
+
if (res.status === 200) {
|
|
93
|
+
try {
|
|
94
|
+
const pkg = JSON.parse(res.data);
|
|
95
|
+
// Get earliest release date
|
|
96
|
+
const urls = pkg.urls || [];
|
|
97
|
+
const firstUpload = urls.length > 0 ? new Date(urls[0].upload_time_iso_8601 || urls[0].upload_time) : null;
|
|
98
|
+
return { exists: true, created: firstUpload, data: pkg };
|
|
99
|
+
} catch {
|
|
100
|
+
return { exists: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { exists: true };
|
|
104
|
+
} catch {
|
|
105
|
+
return { exists: null };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function scanHallucinatedPackages(dir, opts = {}) {
|
|
110
|
+
if (opts.offline) return [];
|
|
111
|
+
|
|
112
|
+
const findings = [];
|
|
113
|
+
const RATE_LIMIT_MS = 100; // 100ms between requests
|
|
114
|
+
|
|
115
|
+
// Collect npm packages
|
|
116
|
+
const npmPackages = [];
|
|
117
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
118
|
+
if (fs.existsSync(pkgPath)) {
|
|
119
|
+
try {
|
|
120
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
121
|
+
const allDeps = {
|
|
122
|
+
...(pkg.dependencies || {}),
|
|
123
|
+
...(pkg.devDependencies || {}),
|
|
124
|
+
};
|
|
125
|
+
for (const name of Object.keys(allDeps)) {
|
|
126
|
+
npmPackages.push(name);
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Collect Python packages
|
|
132
|
+
const pyPackages = [];
|
|
133
|
+
const reqPath = path.join(dir, 'requirements.txt');
|
|
134
|
+
if (fs.existsSync(reqPath)) {
|
|
135
|
+
try {
|
|
136
|
+
const content = fs.readFileSync(reqPath, 'utf-8');
|
|
137
|
+
for (const line of content.split('\n')) {
|
|
138
|
+
const trimmed = line.trim();
|
|
139
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
|
|
140
|
+
const match = trimmed.match(/^([a-zA-Z0-9._-]+)/);
|
|
141
|
+
if (match) pyPackages.push(match[1]);
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check npm packages
|
|
147
|
+
for (const name of npmPackages) {
|
|
148
|
+
await delay(RATE_LIMIT_MS);
|
|
149
|
+
|
|
150
|
+
const result = await checkNpmPackage(name);
|
|
151
|
+
|
|
152
|
+
if (result.exists === false) {
|
|
153
|
+
findings.push({
|
|
154
|
+
rule: 'hallucinated/npm-package-not-found',
|
|
155
|
+
severity: 'critical',
|
|
156
|
+
file: 'package.json',
|
|
157
|
+
line: null,
|
|
158
|
+
message: `Package "${name}" does not exist on npm. This is likely a hallucinated package name generated by AI. Installing it could pull in a malicious package if someone registers this name.`,
|
|
159
|
+
fix: `Remove "${name}" from package.json. Search npm for the correct package name. AI tools frequently invent plausible-sounding package names that don't exist.`,
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (result.exists === null) continue; // Network error, skip
|
|
165
|
+
|
|
166
|
+
// Check for very new + low download packages (potential typosquat)
|
|
167
|
+
if (result.created) {
|
|
168
|
+
const ageMs = Date.now() - result.created.getTime();
|
|
169
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
170
|
+
if (ageDays < 30) {
|
|
171
|
+
findings.push({
|
|
172
|
+
rule: 'hallucinated/npm-suspicious-new-package',
|
|
173
|
+
severity: 'high',
|
|
174
|
+
file: 'package.json',
|
|
175
|
+
line: null,
|
|
176
|
+
message: `Package "${name}" was created ${Math.floor(ageDays)} days ago. Very new packages may be typosquats or dependency confusion attacks targeting AI-generated code.`,
|
|
177
|
+
fix: `Verify "${name}" is the correct package. Check its npm page, GitHub repo, and download counts before using it.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Typosquat check
|
|
183
|
+
const typosquatTarget = findTyposquatTarget(name, POPULAR_NPM);
|
|
184
|
+
if (typosquatTarget) {
|
|
185
|
+
findings.push({
|
|
186
|
+
rule: 'hallucinated/npm-potential-typosquat',
|
|
187
|
+
severity: 'high',
|
|
188
|
+
file: 'package.json',
|
|
189
|
+
line: null,
|
|
190
|
+
message: `Package "${name}" looks like a typosquat of popular package "${typosquatTarget}". AI tools frequently misspell package names.`,
|
|
191
|
+
fix: `Did you mean "${typosquatTarget}"? Verify the package name is correct.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check Python packages
|
|
197
|
+
for (const name of pyPackages) {
|
|
198
|
+
await delay(RATE_LIMIT_MS);
|
|
199
|
+
|
|
200
|
+
const result = await checkPypiPackage(name);
|
|
201
|
+
|
|
202
|
+
if (result.exists === false) {
|
|
203
|
+
findings.push({
|
|
204
|
+
rule: 'hallucinated/pypi-package-not-found',
|
|
205
|
+
severity: 'critical',
|
|
206
|
+
file: 'requirements.txt',
|
|
207
|
+
line: null,
|
|
208
|
+
message: `Package "${name}" does not exist on PyPI. This is likely a hallucinated package name generated by AI. A malicious actor could register this name and compromise your project.`,
|
|
209
|
+
fix: `Remove "${name}" from requirements.txt. Search PyPI for the correct package name.`,
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (result.exists === null) continue;
|
|
215
|
+
|
|
216
|
+
if (result.created) {
|
|
217
|
+
const ageMs = Date.now() - result.created.getTime();
|
|
218
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
219
|
+
if (ageDays < 30) {
|
|
220
|
+
findings.push({
|
|
221
|
+
rule: 'hallucinated/pypi-suspicious-new-package',
|
|
222
|
+
severity: 'high',
|
|
223
|
+
file: 'requirements.txt',
|
|
224
|
+
line: null,
|
|
225
|
+
message: `Package "${name}" was published ${Math.floor(ageDays)} days ago. Very new packages may be typosquats targeting AI-generated code.`,
|
|
226
|
+
fix: `Verify "${name}" is the correct package on PyPI before using it.`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const typosquatTarget = findTyposquatTarget(name, POPULAR_PYPI);
|
|
232
|
+
if (typosquatTarget) {
|
|
233
|
+
findings.push({
|
|
234
|
+
rule: 'hallucinated/pypi-potential-typosquat',
|
|
235
|
+
severity: 'high',
|
|
236
|
+
file: 'requirements.txt',
|
|
237
|
+
line: null,
|
|
238
|
+
message: `Package "${name}" looks like a typosquat of popular package "${typosquatTarget}". AI tools frequently misspell package names.`,
|
|
239
|
+
fix: `Did you mean "${typosquatTarget}"? Verify the package name is correct.`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return findings;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { scanHallucinatedPackages, editDistance, findTyposquatTarget };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function scanNextjs(ctx, dir) {
|
|
5
|
+
const { content, relativePath, ext, basename } = ctx;
|
|
6
|
+
const findings = [];
|
|
7
|
+
const lines = content.split('\n');
|
|
8
|
+
const isJS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext);
|
|
9
|
+
|
|
10
|
+
if (!isJS) return findings;
|
|
11
|
+
|
|
12
|
+
const isClientComponent = content.includes("'use client'") || content.includes('"use client"');
|
|
13
|
+
const isServerAction = content.includes("'use server'") || content.includes('"use server"');
|
|
14
|
+
const isAppApiRoute = /app\/api\//.test(relativePath.replace(/\\/g, '/'));
|
|
15
|
+
const isNextConfig = basename === 'next.config.js' || basename === 'next.config.mjs' || basename === 'next.config.ts';
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
const line = lines[i];
|
|
19
|
+
const lineNum = i + 1;
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
|
|
22
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
|
23
|
+
|
|
24
|
+
// === API keys in "use client" components ===
|
|
25
|
+
if (isClientComponent) {
|
|
26
|
+
const secretPatterns = [
|
|
27
|
+
{ pattern: /(?:api[_-]?key|secret[_-]?key|private[_-]?key|auth[_-]?token)\s*[:=]\s*['"`][a-zA-Z0-9_-]{10,}['"`]/i, what: 'API key' },
|
|
28
|
+
{ pattern: /sk[_-](?:live|test)[_-][a-zA-Z0-9]+/, what: 'Stripe secret key' },
|
|
29
|
+
{ pattern: /(?:SUPABASE_SERVICE_ROLE|supabaseServiceRole)/, what: 'Supabase service role key' },
|
|
30
|
+
];
|
|
31
|
+
for (const { pattern, what } of secretPatterns) {
|
|
32
|
+
if (pattern.test(line)) {
|
|
33
|
+
findings.push({
|
|
34
|
+
rule: 'nextjs/secret-in-client-component',
|
|
35
|
+
severity: 'critical',
|
|
36
|
+
file: relativePath,
|
|
37
|
+
line: lineNum,
|
|
38
|
+
message: `${what} found in a "use client" component. Client components are sent to the browser — this secret is exposed to every user.`,
|
|
39
|
+
fix: 'Move secrets to server-side code (API routes, Server Components, or Server Actions). Use environment variables without the NEXT_PUBLIC_ prefix.',
|
|
40
|
+
});
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// === Server actions with no input validation ===
|
|
47
|
+
if (isServerAction) {
|
|
48
|
+
const fnMatch = line.match(/(?:async\s+function\s+(\w+)|(?:const|let)\s+(\w+)\s*=\s*async)/);
|
|
49
|
+
if (fnMatch) {
|
|
50
|
+
const fnName = fnMatch[1] || fnMatch[2];
|
|
51
|
+
const fnBody = lines.slice(i, Math.min(lines.length, i + 20)).join('\n');
|
|
52
|
+
if (!/(?:zod|yup|joi|validate|parse|safeParse|schema\.|z\.|check|assert)/.test(fnBody) &&
|
|
53
|
+
/(formData|data|input|body|params|args)/.test(fnBody)) {
|
|
54
|
+
findings.push({
|
|
55
|
+
rule: 'nextjs/server-action-no-validation',
|
|
56
|
+
severity: 'high',
|
|
57
|
+
file: relativePath,
|
|
58
|
+
line: lineNum,
|
|
59
|
+
message: `Server Action "${fnName || '(anonymous)'}" processes input with no validation. Server Actions are public HTTP endpoints — anyone can call them with arbitrary data.`,
|
|
60
|
+
fix: 'Validate all inputs with zod, yup, or manual validation. Server Actions are POST endpoints, not internal functions.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// === publicRuntimeConfig exposing secrets ===
|
|
67
|
+
if (isNextConfig && /publicRuntimeConfig/.test(line)) {
|
|
68
|
+
const configBlock = lines.slice(i, Math.min(lines.length, i + 15)).join('\n');
|
|
69
|
+
if (/(?:secret|key|token|password|private|auth|database|db_|api_key)/i.test(configBlock) &&
|
|
70
|
+
!/NEXT_PUBLIC_/.test(configBlock)) {
|
|
71
|
+
findings.push({
|
|
72
|
+
rule: 'nextjs/public-runtime-config-secrets',
|
|
73
|
+
severity: 'critical',
|
|
74
|
+
file: relativePath,
|
|
75
|
+
line: lineNum,
|
|
76
|
+
message: 'publicRuntimeConfig appears to expose secrets. publicRuntimeConfig is available in the browser — it\'s meant for public values only.',
|
|
77
|
+
fix: 'Move secrets to serverRuntimeConfig or environment variables (without NEXT_PUBLIC_ prefix). Only put truly public config in publicRuntimeConfig.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// === API routes with no auth checks ===
|
|
83
|
+
if (isAppApiRoute) {
|
|
84
|
+
const handlerMatch = line.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/);
|
|
85
|
+
if (handlerMatch) {
|
|
86
|
+
const handlerBody = lines.slice(i, Math.min(lines.length, i + 25)).join('\n');
|
|
87
|
+
if (!/(?:auth|session|getServerSession|getToken|verify|currentUser|requireAuth|getSession|cookies|getUser|clerk|lucia|supabase.*auth)/.test(handlerBody)) {
|
|
88
|
+
findings.push({
|
|
89
|
+
rule: 'nextjs/api-route-no-auth',
|
|
90
|
+
severity: 'medium',
|
|
91
|
+
file: relativePath,
|
|
92
|
+
line: lineNum,
|
|
93
|
+
message: `Next.js API route handler ${handlerMatch[1]} has no authentication check. This endpoint is publicly accessible.`,
|
|
94
|
+
fix: 'Add authentication: const session = await getServerSession(); if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 401})',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// === NEXT_PUBLIC_ prefix for sensitive data ===
|
|
101
|
+
if (/NEXT_PUBLIC_(?:SECRET|PRIVATE|PASSWORD|AUTH|TOKEN|API_SECRET|SERVICE_ROLE|DATABASE|DB_|ADMIN|MASTER)/i.test(line)) {
|
|
102
|
+
findings.push({
|
|
103
|
+
rule: 'nextjs/sensitive-env-public',
|
|
104
|
+
severity: 'critical',
|
|
105
|
+
file: relativePath,
|
|
106
|
+
line: lineNum,
|
|
107
|
+
message: 'Sensitive environment variable exposed with NEXT_PUBLIC_ prefix. NEXT_PUBLIC_ variables are embedded in the client-side JavaScript bundle and visible to all users.',
|
|
108
|
+
fix: 'Remove the NEXT_PUBLIC_ prefix. Access this variable only in server-side code (API routes, getServerSideProps, Server Components).',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scanNextjsProject(dir) {
|
|
117
|
+
const findings = [];
|
|
118
|
+
|
|
119
|
+
// Check for missing middleware.ts/js
|
|
120
|
+
const middlewareFiles = ['middleware.ts', 'middleware.js', 'src/middleware.ts', 'src/middleware.js'];
|
|
121
|
+
const hasMiddleware = middlewareFiles.some(f => fs.existsSync(path.join(dir, f)));
|
|
122
|
+
const hasAppDir = fs.existsSync(path.join(dir, 'app')) || fs.existsSync(path.join(dir, 'src', 'app'));
|
|
123
|
+
const hasNextConfig = fs.existsSync(path.join(dir, 'next.config.js')) || fs.existsSync(path.join(dir, 'next.config.mjs')) || fs.existsSync(path.join(dir, 'next.config.ts'));
|
|
124
|
+
|
|
125
|
+
if (hasNextConfig && hasAppDir && !hasMiddleware) {
|
|
126
|
+
findings.push({
|
|
127
|
+
rule: 'nextjs/missing-middleware',
|
|
128
|
+
severity: 'medium',
|
|
129
|
+
file: 'middleware.ts',
|
|
130
|
+
line: null,
|
|
131
|
+
message: 'Next.js project has no middleware.ts/js. Without middleware, there\'s no centralized auth check — every route must handle its own auth.',
|
|
132
|
+
fix: 'Create middleware.ts in the project root to handle authentication. Use matcher config to protect /api and /dashboard routes.',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return findings;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { scanNextjs, scanNextjsProject };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
function scanSupabase(ctx) {
|
|
2
|
+
const { content, relativePath, ext } = ctx;
|
|
3
|
+
const findings = [];
|
|
4
|
+
const lines = content.split('\n');
|
|
5
|
+
const isJS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext);
|
|
6
|
+
|
|
7
|
+
if (!isJS) return findings;
|
|
8
|
+
|
|
9
|
+
// Check if file even uses Supabase
|
|
10
|
+
if (!/supabase|createClient|@supabase/.test(content)) return findings;
|
|
11
|
+
|
|
12
|
+
const isClientSide = /['"`]use client['"`]|components?\/|pages?\/|src\/app|\.jsx|\.tsx/.test(relativePath.replace(/\\/g, '/')) ||
|
|
13
|
+
content.includes("'use client'") || content.includes('"use client"');
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < lines.length; i++) {
|
|
16
|
+
const line = lines[i];
|
|
17
|
+
const lineNum = i + 1;
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
|
|
20
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
|
21
|
+
|
|
22
|
+
// === Service role key exposed in client-side code ===
|
|
23
|
+
if (isClientSide) {
|
|
24
|
+
if (/(?:service_role|serviceRole|SERVICE_ROLE|SUPABASE_SERVICE_ROLE)/i.test(line) &&
|
|
25
|
+
/(?:key|token)/i.test(line)) {
|
|
26
|
+
findings.push({
|
|
27
|
+
rule: 'supabase/service-role-in-client',
|
|
28
|
+
severity: 'critical',
|
|
29
|
+
file: relativePath,
|
|
30
|
+
line: lineNum,
|
|
31
|
+
message: 'Supabase service role key used in client-side code. The service role key bypasses ALL Row Level Security — it grants full database access to anyone who finds it.',
|
|
32
|
+
fix: 'Never use the service role key in the browser. Use it only in server-side code (API routes, serverless functions). Client-side code should use the anon key with RLS enabled.',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// === .from('table').select('*') with no filter ===
|
|
38
|
+
if (/\.from\s*\(\s*['"`]\w+['"`]\s*\)\s*\.select\s*\(\s*['"`]\*['"`]\s*\)/.test(line)) {
|
|
39
|
+
const context = lines.slice(i, Math.min(lines.length, i + 5)).join(' ');
|
|
40
|
+
if (!/\.eq\s*\(|\.filter\s*\(|\.match\s*\(|\.in\s*\(|\.neq\s*\(|\.gt\s*\(|\.lt\s*\(|\.gte\s*\(|\.lte\s*\(|\.like\s*\(|\.ilike\s*\(|\.is\s*\(|\.single\s*\(|\.limit\s*\(|\.range\s*\(/.test(context)) {
|
|
41
|
+
findings.push({
|
|
42
|
+
rule: 'supabase/unfiltered-select',
|
|
43
|
+
severity: 'medium',
|
|
44
|
+
file: relativePath,
|
|
45
|
+
line: lineNum,
|
|
46
|
+
message: 'Selecting all rows from a Supabase table with no filter. Without RLS or a filter, this returns the entire table to the client, potentially exposing other users\' data.',
|
|
47
|
+
fix: 'Add a filter: .from("table").select("*").eq("user_id", userId). Better yet, ensure RLS policies restrict access.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// === Anon key used without mention of RLS ===
|
|
53
|
+
if (/(?:anon|ANON|SUPABASE_ANON)/.test(line) && /(?:key|KEY)/.test(line)) {
|
|
54
|
+
// Check file and nearby for any mention of RLS
|
|
55
|
+
if (!/rls|row.level.security|policy|policies/i.test(content)) {
|
|
56
|
+
findings.push({
|
|
57
|
+
rule: 'supabase/anon-key-no-rls',
|
|
58
|
+
severity: 'high',
|
|
59
|
+
file: relativePath,
|
|
60
|
+
line: lineNum,
|
|
61
|
+
message: 'Supabase anon key used with no mention of Row Level Security. The anon key is public — without RLS, anyone with the key can read and write all data.',
|
|
62
|
+
fix: 'Enable RLS on all tables in Supabase Dashboard. Create policies that restrict access based on auth.uid(). Test policies thoroughly.',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// === Public storage bucket patterns ===
|
|
68
|
+
if (/\.storage\s*\.from\s*\(/.test(line)) {
|
|
69
|
+
const context = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
70
|
+
if (/(?:public|isPublic\s*:\s*true|public\s*:\s*true)/.test(context) &&
|
|
71
|
+
!/(?:policy|policies|rls|restrict|auth|signed)/.test(context)) {
|
|
72
|
+
findings.push({
|
|
73
|
+
rule: 'supabase/public-storage-no-policy',
|
|
74
|
+
severity: 'medium',
|
|
75
|
+
file: relativePath,
|
|
76
|
+
line: lineNum,
|
|
77
|
+
message: 'Supabase storage bucket configured as public with no access policies mentioned. Anyone can read all files in this bucket.',
|
|
78
|
+
fix: 'Add storage policies to restrict uploads and access. Use signed URLs for private content.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// === Database functions with SECURITY DEFINER ===
|
|
84
|
+
if (/SECURITY\s+DEFINER/i.test(line)) {
|
|
85
|
+
const context = lines.slice(Math.max(0, i - 5), Math.min(lines.length, i + 10)).join('\n');
|
|
86
|
+
if (!/(?:auth\.uid|current_user|session_user|check|verify|assert|raise)/.test(context)) {
|
|
87
|
+
findings.push({
|
|
88
|
+
rule: 'supabase/security-definer-no-checks',
|
|
89
|
+
severity: 'high',
|
|
90
|
+
file: relativePath,
|
|
91
|
+
line: lineNum,
|
|
92
|
+
message: 'Database function uses SECURITY DEFINER without auth checks. SECURITY DEFINER functions run with the privileges of the function creator, bypassing RLS.',
|
|
93
|
+
fix: 'Add auth checks inside the function: IF auth.uid() IS NULL THEN RAISE EXCEPTION; Always validate the caller.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { scanSupabase };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
function scanVibePatterns(ctx) {
|
|
2
|
+
const { content, relativePath, ext } = ctx;
|
|
3
|
+
const findings = [];
|
|
4
|
+
const lines = content.split('\n');
|
|
5
|
+
const isJS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext);
|
|
6
|
+
const isPy = ['.py', '.pyw'].includes(ext);
|
|
7
|
+
const isCode = isJS || isPy;
|
|
8
|
+
|
|
9
|
+
if (!isCode && !['.html', '.htm', '.vue', '.svelte'].includes(ext)) return findings;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < lines.length; i++) {
|
|
12
|
+
const line = lines[i];
|
|
13
|
+
const lineNum = i + 1;
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
|
|
16
|
+
// === Security-critical TODO/FIXME comments ===
|
|
17
|
+
if (/\/\/|#|\/\*|\*/.test(trimmed.substring(0, 3)) || /^\s*(\/\/|#|\/\*|\*)/.test(line)) {
|
|
18
|
+
const securityTodoPatterns = [
|
|
19
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:add|implement|enable|set\s*up)\s+(?:auth|authentication|authorization)/i,
|
|
20
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:add|implement|enable)\s+(?:validation|input\s*valid|sanitiz)/i,
|
|
21
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:add|implement|enable)\s+(?:rate\s*limit|throttl)/i,
|
|
22
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:add|implement|enable)\s+(?:encryption|ssl|tls|https)/i,
|
|
23
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:add|implement|enable)\s+(?:csrf|xss|injection)\s*(?:protect|prevent|check)/i,
|
|
24
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:remove|replace)\s+(?:hardcoded|hard-coded)\s+(?:secret|key|password|token|credential)/i,
|
|
25
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:fix|add|implement)\s+(?:security|permissions|access\s*control)/i,
|
|
26
|
+
/(?:TODO|FIXME|HACK|XXX)\s*:?\s*.*(?:hash|encrypt)\s+(?:password|secret|token)/i,
|
|
27
|
+
];
|
|
28
|
+
for (const pattern of securityTodoPatterns) {
|
|
29
|
+
if (pattern.test(line)) {
|
|
30
|
+
findings.push({
|
|
31
|
+
rule: 'vibe/security-todo-left-behind',
|
|
32
|
+
severity: 'high',
|
|
33
|
+
file: relativePath,
|
|
34
|
+
line: lineNum,
|
|
35
|
+
message: `Security-critical TODO comment found: "${trimmed.substring(0, 100)}". AI tools leave these as placeholders but never come back to implement them.`,
|
|
36
|
+
fix: 'Implement the security feature described in this TODO now. Do not deploy with unimplemented security controls.',
|
|
37
|
+
});
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// === Placeholder/example data in non-test files ===
|
|
44
|
+
if (isCode && !/test|spec|fixture|mock|seed|sample|example|demo/i.test(relativePath)) {
|
|
45
|
+
const placeholderPatterns = [
|
|
46
|
+
{ pattern: /['"`](?:test@test\.com|user@example\.com|admin@example\.com|email@example\.com|john@example\.com|jane@example\.com)['"`]/i, what: 'example email address' },
|
|
47
|
+
{ pattern: /['"`]123-?456-?7890['"`]/, what: 'placeholder phone number' },
|
|
48
|
+
{ pattern: /['"`](?:John\s+Doe|Jane\s+Doe|Test\s+User|Admin\s+User)['"`]/i, what: 'placeholder name' },
|
|
49
|
+
{ pattern: /['"`](?:123\s+Main\s+St|456\s+Elm\s+St|123 Test)['"`]/i, what: 'placeholder address' },
|
|
50
|
+
{ pattern: /['"`]password123['"`]|['"`]admin123['"`]|['"`]test123['"`]|['"`]changeme['"`]/i, what: 'placeholder password' },
|
|
51
|
+
{ pattern: /['"`]sk[_-]test[_-][a-zA-Z0-9]+['"`]/, what: 'Stripe test key' },
|
|
52
|
+
];
|
|
53
|
+
for (const { pattern, what } of placeholderPatterns) {
|
|
54
|
+
if (pattern.test(line)) {
|
|
55
|
+
findings.push({
|
|
56
|
+
rule: 'vibe/placeholder-data-in-code',
|
|
57
|
+
severity: 'medium',
|
|
58
|
+
file: relativePath,
|
|
59
|
+
line: lineNum,
|
|
60
|
+
message: `Found ${what} in production code: "${trimmed.substring(0, 80)}". AI tools generate example data that gets shipped to production.`,
|
|
61
|
+
fix: 'Replace placeholder data with real values or environment variables. Never ship example data to production.',
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// === Console.log leaking sensitive data ===
|
|
69
|
+
if (isJS) {
|
|
70
|
+
const sensitiveLogPattern = /console\.(log|info|debug|warn)\s*\(\s*.*(password|passwd|secret|token|apiKey|api_key|authorization|credit.?card|ssn|private.?key|session.?id)/i;
|
|
71
|
+
if (sensitiveLogPattern.test(line) && !/\/\//.test(line.split('console')[0])) {
|
|
72
|
+
findings.push({
|
|
73
|
+
rule: 'vibe/sensitive-data-in-log',
|
|
74
|
+
severity: 'high',
|
|
75
|
+
file: relativePath,
|
|
76
|
+
line: lineNum,
|
|
77
|
+
message: 'Console.log statement may leak sensitive data (passwords, tokens, keys). AI tools add these for debugging and never remove them.',
|
|
78
|
+
fix: 'Remove console.log statements that output sensitive data. Use a proper logging library with sensitive field redaction.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (isPy) {
|
|
83
|
+
const pyLogPattern = /(?:print|logging\.\w+)\s*\(.*(?:password|passwd|secret|token|api_key|authorization|private_key|session_id)/i;
|
|
84
|
+
if (pyLogPattern.test(line) && !trimmed.startsWith('#')) {
|
|
85
|
+
findings.push({
|
|
86
|
+
rule: 'vibe/sensitive-data-in-log',
|
|
87
|
+
severity: 'high',
|
|
88
|
+
file: relativePath,
|
|
89
|
+
line: lineNum,
|
|
90
|
+
message: 'Print/logging statement may leak sensitive data. AI tools add these for debugging and never remove them.',
|
|
91
|
+
fix: 'Remove print statements that output sensitive data. Use a proper logging library with sensitive field redaction.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// === Commented-out security code ===
|
|
97
|
+
if (isJS && /^\s*\/\//.test(line)) {
|
|
98
|
+
const commentBody = trimmed.replace(/^\/\/+\s*/, '');
|
|
99
|
+
const commentedSecurityPatterns = [
|
|
100
|
+
/(?:requireAuth|isAuthenticated|checkAuth|verifyToken|authenticate|authorize)\s*\(/,
|
|
101
|
+
/(?:validate|sanitize|escape|purify)\s*\(/,
|
|
102
|
+
/(?:rateLimit|rateLimiter|throttle)\s*\(/,
|
|
103
|
+
/helmet\s*\(/,
|
|
104
|
+
/csrf|xss|cors/i,
|
|
105
|
+
/\.verify\s*\(/,
|
|
106
|
+
];
|
|
107
|
+
for (const pattern of commentedSecurityPatterns) {
|
|
108
|
+
if (pattern.test(commentBody)) {
|
|
109
|
+
findings.push({
|
|
110
|
+
rule: 'vibe/commented-out-security',
|
|
111
|
+
severity: 'high',
|
|
112
|
+
file: relativePath,
|
|
113
|
+
line: lineNum,
|
|
114
|
+
message: `Security code appears to be commented out: "${trimmed.substring(0, 80)}". AI tools sometimes comment out security checks while debugging and never re-enable them.`,
|
|
115
|
+
fix: 'Uncomment this security code. If it was intentionally disabled, add a comment explaining why and what alternative protection is in place.',
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// === AI-generated boilerplate comments ===
|
|
123
|
+
if (/(?:Generated by|Created with|Auto-generated by|Built by|Scaffolded by|Made with)\s+(?:Cursor|Copilot|ChatGPT|GPT-4|Claude|Windsurf|Bolt|Lovable|v0|AI|GitHub Copilot|OpenAI|Anthropic)/i.test(line) ||
|
|
124
|
+
/Copilot suggestion|AI-generated|AI generated/i.test(line)) {
|
|
125
|
+
findings.push({
|
|
126
|
+
rule: 'vibe/ai-generated-marker',
|
|
127
|
+
severity: 'low',
|
|
128
|
+
file: relativePath,
|
|
129
|
+
line: lineNum,
|
|
130
|
+
message: 'AI-generated code marker found. This code may not have been reviewed by a human. AI-generated code frequently contains security issues.',
|
|
131
|
+
fix: 'Review this file for security issues. Remove the AI marker comment after a thorough review.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// === Error responses that leak stack traces ===
|
|
136
|
+
if (isJS) {
|
|
137
|
+
const stackLeakPatterns = [
|
|
138
|
+
/res\.\w*\(.*err\.stack/,
|
|
139
|
+
/res\.(?:send|json|write)\s*\(\s*err\s*\)/,
|
|
140
|
+
/res\.status\s*\(\s*500\s*\)\.(?:send|json)\s*\(\s*\{\s*(?:error|message)\s*:\s*err/,
|
|
141
|
+
/res\.status\s*\(\s*500\s*\)\.send\s*\(\s*err\.(?:message|stack|toString)/,
|
|
142
|
+
/next\s*\(\s*err\s*\).*(?!production)/,
|
|
143
|
+
];
|
|
144
|
+
for (const pattern of stackLeakPatterns) {
|
|
145
|
+
if (pattern.test(line)) {
|
|
146
|
+
findings.push({
|
|
147
|
+
rule: 'vibe/error-stack-leak',
|
|
148
|
+
severity: 'high',
|
|
149
|
+
file: relativePath,
|
|
150
|
+
line: lineNum,
|
|
151
|
+
message: 'Error response sends raw error/stack trace to client. This leaks internal paths, library versions, and server architecture to attackers.',
|
|
152
|
+
fix: 'Return a generic error message to clients. Log the full error server-side: res.status(500).json({ error: "Internal server error" })',
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// === Silent error swallowing ===
|
|
160
|
+
if (isJS) {
|
|
161
|
+
if (/catch\s*\(\s*\w*\s*\)\s*\{\s*\}/.test(line)) {
|
|
162
|
+
findings.push({
|
|
163
|
+
rule: 'vibe/silent-error-swallow',
|
|
164
|
+
severity: 'medium',
|
|
165
|
+
file: relativePath,
|
|
166
|
+
line: lineNum,
|
|
167
|
+
message: 'Empty catch block silently swallows errors. Security-critical failures (auth, validation, encryption) will fail silently, leaving the app in an insecure state.',
|
|
168
|
+
fix: 'At minimum, log the error. For security-critical code, re-throw or handle the error explicitly.',
|
|
169
|
+
});
|
|
170
|
+
} else if (/catch\s*\(\s*(\w+)\s*\)\s*\{/.test(line)) {
|
|
171
|
+
// Check if the catch body is just console.log
|
|
172
|
+
const catchVar = line.match(/catch\s*\(\s*(\w+)\s*\)\s*\{/);
|
|
173
|
+
if (catchVar) {
|
|
174
|
+
const nextLines = lines.slice(i + 1, Math.min(lines.length, i + 4)).join(' ').trim();
|
|
175
|
+
if (/^\s*console\.\w+\s*\(\s*\w+\s*\)\s*;?\s*\}/.test(nextLines)) {
|
|
176
|
+
findings.push({
|
|
177
|
+
rule: 'vibe/silent-error-swallow',
|
|
178
|
+
severity: 'medium',
|
|
179
|
+
file: relativePath,
|
|
180
|
+
line: lineNum,
|
|
181
|
+
message: 'Catch block only logs the error without any handling. Security failures will be logged but ignored, leaving the app vulnerable.',
|
|
182
|
+
fix: 'Add proper error handling: retry, return an error response, or re-throw. Just logging is not handling.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return findings;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { scanVibePatterns };
|