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
package/src/precommit.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* vbguard pre-commit hook
|
|
5
|
+
*
|
|
6
|
+
* Setup:
|
|
7
|
+
* Option 1 (husky): npx husky add .husky/pre-commit "npx vbguard-precommit"
|
|
8
|
+
* Option 2 (manual): Copy this to .git/hooks/pre-commit and chmod +x
|
|
9
|
+
* Option 3 (npx): Add to package.json scripts: "precommit": "node node_modules/vbguard/src/precommit.js"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { scanDirectory } = require('./index');
|
|
14
|
+
|
|
15
|
+
const RESET = '\x1b[0m';
|
|
16
|
+
const BOLD = '\x1b[1m';
|
|
17
|
+
const RED = '\x1b[31m';
|
|
18
|
+
const GREEN = '\x1b[32m';
|
|
19
|
+
const DIM = '\x1b[2m';
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const dir = process.cwd();
|
|
23
|
+
|
|
24
|
+
console.log(`\n ${BOLD}⚡ vbguard pre-commit check${RESET}\n`);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const results = await scanDirectory(dir, {
|
|
28
|
+
staged: true,
|
|
29
|
+
offline: true,
|
|
30
|
+
severity: 'low',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const critical = results.findings.filter(f => f.severity === 'critical');
|
|
34
|
+
const high = results.findings.filter(f => f.severity === 'high');
|
|
35
|
+
const blocking = [...critical, ...high];
|
|
36
|
+
|
|
37
|
+
if (blocking.length === 0) {
|
|
38
|
+
console.log(` ${GREEN}✓ No critical/high issues in staged files${RESET}`);
|
|
39
|
+
if (results.findings.length > 0) {
|
|
40
|
+
console.log(` ${DIM}${results.findings.length} non-blocking issue(s) found (medium/low)${RESET}`);
|
|
41
|
+
}
|
|
42
|
+
console.log('');
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(` ${RED}${BOLD}✗ Commit blocked — ${blocking.length} critical/high issue(s) found:${RESET}\n`);
|
|
47
|
+
|
|
48
|
+
for (const f of blocking) {
|
|
49
|
+
const sevColor = f.severity === 'critical' ? '\x1b[41m\x1b[37m' : RED;
|
|
50
|
+
console.log(` ${sevColor} ${f.severity.toUpperCase()}${RESET} ${f.rule}`);
|
|
51
|
+
console.log(` ${DIM}${f.file}${f.line ? `:${f.line}` : ''}${RESET}`);
|
|
52
|
+
console.log(` ${f.message}`);
|
|
53
|
+
if (f.fix) console.log(` ${DIM}💡 ${f.fix}${RESET}`);
|
|
54
|
+
console.log('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(` ${DIM}Fix these issues or use git commit --no-verify to bypass.${RESET}\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(` ${RED}Error: ${err.message}${RESET}`);
|
|
62
|
+
// Don't block commits on scanner errors
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main();
|
package/src/reporter.js
CHANGED
|
@@ -75,4 +75,129 @@ function formatReport(results, opts = {}) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// === SARIF format for GitHub Code Scanning ===
|
|
79
|
+
function formatSarif(results, baseDir) {
|
|
80
|
+
const SEVERITY_MAP = { critical: 'error', high: 'error', medium: 'warning', low: 'note' };
|
|
81
|
+
|
|
82
|
+
const rules = [];
|
|
83
|
+
const ruleIds = new Set();
|
|
84
|
+
const sarifResults = [];
|
|
85
|
+
|
|
86
|
+
for (const finding of results.findings) {
|
|
87
|
+
if (!ruleIds.has(finding.rule)) {
|
|
88
|
+
ruleIds.add(finding.rule);
|
|
89
|
+
rules.push({
|
|
90
|
+
id: finding.rule,
|
|
91
|
+
shortDescription: { text: finding.rule },
|
|
92
|
+
fullDescription: { text: finding.message },
|
|
93
|
+
defaultConfiguration: { level: SEVERITY_MAP[finding.severity] || 'note' },
|
|
94
|
+
helpUri: 'https://www.npmjs.com/package/vbguard',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
sarifResults.push({
|
|
99
|
+
ruleId: finding.rule,
|
|
100
|
+
level: SEVERITY_MAP[finding.severity] || 'note',
|
|
101
|
+
message: { text: finding.message + (finding.fix ? `\n\nFix: ${finding.fix}` : '') },
|
|
102
|
+
locations: [{
|
|
103
|
+
physicalLocation: {
|
|
104
|
+
artifactLocation: { uri: finding.file ? finding.file.replace(/\\/g, '/') : '' },
|
|
105
|
+
region: { startLine: finding.line || 1 },
|
|
106
|
+
},
|
|
107
|
+
}],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
version: '2.1.0',
|
|
113
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
114
|
+
runs: [{
|
|
115
|
+
tool: {
|
|
116
|
+
driver: {
|
|
117
|
+
name: 'vbguard',
|
|
118
|
+
version: '0.5.1',
|
|
119
|
+
informationUri: 'https://www.npmjs.com/package/vbguard',
|
|
120
|
+
rules,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
results: sarifResults,
|
|
124
|
+
}],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// === Security score (0-100) ===
|
|
129
|
+
function formatScore(results) {
|
|
130
|
+
const { findings, summary } = results;
|
|
131
|
+
|
|
132
|
+
// Weighted deductions
|
|
133
|
+
const WEIGHTS = { critical: 20, high: 10, medium: 3, low: 1 };
|
|
134
|
+
let deductions = 0;
|
|
135
|
+
for (const f of findings) {
|
|
136
|
+
deductions += WEIGHTS[f.severity] || 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Score: start at 100, deduct, floor at 0
|
|
140
|
+
const score = Math.max(0, Math.min(100, 100 - deductions));
|
|
141
|
+
|
|
142
|
+
let label, color;
|
|
143
|
+
if (score >= 90) { label = 'excellent'; color = '\x1b[32m'; }
|
|
144
|
+
else if (score >= 70) { label = 'good'; color = '\x1b[33m'; }
|
|
145
|
+
else if (score >= 50) { label = 'needs work'; color = '\x1b[33m'; }
|
|
146
|
+
else if (score >= 30) { label = 'poor'; color = '\x1b[31m'; }
|
|
147
|
+
else { label = 'critical'; color = '\x1b[31m'; }
|
|
148
|
+
|
|
149
|
+
console.log('');
|
|
150
|
+
console.log(` ${BOLD}vbguard security score:${RESET}`);
|
|
151
|
+
console.log('');
|
|
152
|
+
|
|
153
|
+
// Score bar
|
|
154
|
+
const filled = Math.round(score / 5);
|
|
155
|
+
const empty = 20 - filled;
|
|
156
|
+
const bar = `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`;
|
|
157
|
+
console.log(` ${bar} ${color}${BOLD}${score}/100${RESET} — ${label}`);
|
|
158
|
+
console.log('');
|
|
159
|
+
|
|
160
|
+
if (summary.critical > 0) console.log(` \x1b[31m● ${summary.critical} critical\x1b[0m`);
|
|
161
|
+
if (summary.high > 0) console.log(` \x1b[31m● ${summary.high} high\x1b[0m`);
|
|
162
|
+
if (summary.medium > 0) console.log(` \x1b[33m● ${summary.medium} medium\x1b[0m`);
|
|
163
|
+
if (summary.low > 0) console.log(` \x1b[36m● ${summary.low} low\x1b[0m`);
|
|
164
|
+
|
|
165
|
+
console.log(`\n ${DIM}Scanned ${summary.filesScanned} files in ${summary.elapsed}ms${RESET}\n`);
|
|
166
|
+
|
|
167
|
+
return score;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// === Fix suggestions file (.vbguard-fixes.md) ===
|
|
171
|
+
function formatFixes(results, baseDir) {
|
|
172
|
+
const { findings } = results;
|
|
173
|
+
if (findings.length === 0) return '# vbguard — No issues found\n\nYour project is clean! 🎉\n';
|
|
174
|
+
|
|
175
|
+
// Group by file
|
|
176
|
+
const byFile = {};
|
|
177
|
+
for (const f of findings) {
|
|
178
|
+
const file = f.file || '(project-level)';
|
|
179
|
+
if (!byFile[file]) byFile[file] = [];
|
|
180
|
+
byFile[file].push(f);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let md = '# vbguard — Suggested Fixes\n\n';
|
|
184
|
+
md += `> Generated by vbguard v0.5.1\n`;
|
|
185
|
+
md += `> ${findings.length} issues found across ${Object.keys(byFile).length} files\n\n`;
|
|
186
|
+
|
|
187
|
+
for (const [file, items] of Object.entries(byFile)) {
|
|
188
|
+
md += `## \`${file}\`\n\n`;
|
|
189
|
+
for (const item of items) {
|
|
190
|
+
const sevIcon = { critical: '🚨', high: '🔴', medium: '🟡', low: '🔵' }[item.severity] || '⚪';
|
|
191
|
+
md += `### ${sevIcon} ${item.rule}${item.line ? ` (line ${item.line})` : ''}\n\n`;
|
|
192
|
+
md += `**Issue:** ${item.message}\n\n`;
|
|
193
|
+
if (item.fix) {
|
|
194
|
+
md += `**Fix:** ${item.fix}\n\n`;
|
|
195
|
+
}
|
|
196
|
+
md += '---\n\n';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return md;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { formatReport, formatSarif, formatScore, formatFixes };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
function scanAuthFlow(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
|
+
|
|
8
|
+
if (!isJS && !isPy) return findings;
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < lines.length; i++) {
|
|
11
|
+
const line = lines[i];
|
|
12
|
+
const lineNum = i + 1;
|
|
13
|
+
const trimmed = line.trim();
|
|
14
|
+
|
|
15
|
+
// Skip comments
|
|
16
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('*')) continue;
|
|
17
|
+
|
|
18
|
+
// === JWT with no expiration ===
|
|
19
|
+
if (isJS && /jwt\.sign\s*\(/.test(line) && !/expiresIn/.test(line) && !/exp\s*:/.test(line)) {
|
|
20
|
+
// Only look ahead for multi-line jwt.sign calls (check if line has unbalanced parens)
|
|
21
|
+
let context = line;
|
|
22
|
+
const openParens = (line.match(/\(/g) || []).length;
|
|
23
|
+
const closeParens = (line.match(/\)/g) || []).length;
|
|
24
|
+
if (openParens > closeParens) {
|
|
25
|
+
// Multi-line call — look ahead until parens balance
|
|
26
|
+
for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
|
|
27
|
+
context += ' ' + lines[j];
|
|
28
|
+
if (/\)\s*;?\s*$/.test(lines[j])) break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!/expiresIn/.test(context) && !/exp[\s]*:/.test(context)) {
|
|
32
|
+
findings.push({
|
|
33
|
+
rule: 'auth/jwt-no-expiration',
|
|
34
|
+
severity: 'high',
|
|
35
|
+
file: relativePath,
|
|
36
|
+
line: lineNum,
|
|
37
|
+
message: 'JWT token created without an expiration time. Tokens that never expire are a major security risk — if stolen, they grant permanent access.',
|
|
38
|
+
fix: 'Add { expiresIn: "1h" } or similar as the options parameter to jwt.sign().',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// === JWT with weak/hardcoded secret ===
|
|
44
|
+
if (isJS) {
|
|
45
|
+
const jwtMatch = line.match(/jwt\.sign\s*\([^,]+,\s*['"`]([^'"`]+)['"`]/);
|
|
46
|
+
if (jwtMatch) {
|
|
47
|
+
const secret = jwtMatch[1].toLowerCase();
|
|
48
|
+
const weakSecrets = ['secret', 'password', 'key123', 'key', 'mysecret', 'test', 'abc123', 'changeme', 'default', 'jwt_secret', 'supersecret', 'token'];
|
|
49
|
+
if (weakSecrets.includes(secret) || secret.length < 8) {
|
|
50
|
+
findings.push({
|
|
51
|
+
rule: 'auth/jwt-weak-secret',
|
|
52
|
+
severity: 'critical',
|
|
53
|
+
file: relativePath,
|
|
54
|
+
line: lineNum,
|
|
55
|
+
message: `JWT signed with weak/hardcoded secret "${jwtMatch[1]}". This allows anyone to forge valid tokens.`,
|
|
56
|
+
fix: 'Use a strong, randomly generated secret stored in an environment variable: jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: "1h" })',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// === User enumeration — different error messages for "not found" vs "wrong password" ===
|
|
63
|
+
if (isJS || isPy) {
|
|
64
|
+
const userNotFoundPatterns = [
|
|
65
|
+
/['"`]user\s*not\s*found['"`]/i,
|
|
66
|
+
/['"`]no\s*user\s*(with|found|exists)['"`]/i,
|
|
67
|
+
/['"`]account\s*not\s*found['"`]/i,
|
|
68
|
+
/['"`]email\s*not\s*(found|registered)['"`]/i,
|
|
69
|
+
/['"`]invalid\s*email['"`]/i,
|
|
70
|
+
/['"`]unknown\s*user['"`]/i,
|
|
71
|
+
];
|
|
72
|
+
const wrongPassPatterns = [
|
|
73
|
+
/['"`](wrong|incorrect|invalid)\s*password['"`]/i,
|
|
74
|
+
/['"`]password\s*(is\s*)?(wrong|incorrect|invalid|mismatch)['"`]/i,
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const hasUserNotFound = userNotFoundPatterns.some(p => p.test(line));
|
|
78
|
+
const hasWrongPass = wrongPassPatterns.some(p => p.test(line));
|
|
79
|
+
|
|
80
|
+
if (hasUserNotFound || hasWrongPass) {
|
|
81
|
+
// Check nearby lines for the complement
|
|
82
|
+
const nearby = lines.slice(Math.max(0, i - 15), Math.min(lines.length, i + 15)).join('\n');
|
|
83
|
+
const nearbyHasUserNotFound = userNotFoundPatterns.some(p => p.test(nearby));
|
|
84
|
+
const nearbyHasWrongPass = wrongPassPatterns.some(p => p.test(nearby));
|
|
85
|
+
if (nearbyHasUserNotFound && nearbyHasWrongPass) {
|
|
86
|
+
findings.push({
|
|
87
|
+
rule: 'auth/user-enumeration',
|
|
88
|
+
severity: 'high',
|
|
89
|
+
file: relativePath,
|
|
90
|
+
line: lineNum,
|
|
91
|
+
message: 'Login returns different error messages for "user not found" vs "wrong password". This allows attackers to enumerate valid usernames/emails.',
|
|
92
|
+
fix: 'Return the same generic message for all auth failures: "Invalid email or password". Never reveal which field was wrong.',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// === Session/token stored in localStorage ===
|
|
99
|
+
if (isJS && /localStorage\.setItem\s*\(/.test(line)) {
|
|
100
|
+
const tokenPatterns = /localStorage\.setItem\s*\(\s*['"`](token|auth|jwt|session|access_token|refresh_token|api_key|apikey|secret)['"`]/i;
|
|
101
|
+
if (tokenPatterns.test(line)) {
|
|
102
|
+
findings.push({
|
|
103
|
+
rule: 'auth/token-in-localstorage',
|
|
104
|
+
severity: 'high',
|
|
105
|
+
file: relativePath,
|
|
106
|
+
line: lineNum,
|
|
107
|
+
message: 'Authentication token stored in localStorage. This is vulnerable to XSS attacks — any injected script can steal the token.',
|
|
108
|
+
fix: 'Use httpOnly cookies for authentication tokens. They cannot be accessed by JavaScript and are immune to XSS theft.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// === OAuth open redirect — accepting any redirect_uri ===
|
|
114
|
+
if (isJS) {
|
|
115
|
+
if (/redirect_uri\s*[:=]\s*(req\.(query|body|params)|request\.(args|form|json))/.test(line) ||
|
|
116
|
+
/callback.*url\s*[:=]\s*(req\.(query|body|params))/.test(line)) {
|
|
117
|
+
const context = lines.slice(i, Math.min(i + 10, lines.length)).join('\n');
|
|
118
|
+
if (!/whitelist|allowlist|allowed|validate|startsWith|includes|indexOf|match/.test(context)) {
|
|
119
|
+
findings.push({
|
|
120
|
+
rule: 'auth/oauth-open-redirect',
|
|
121
|
+
severity: 'high',
|
|
122
|
+
file: relativePath,
|
|
123
|
+
line: lineNum,
|
|
124
|
+
message: 'OAuth redirect_uri taken directly from user input without validation. Attackers can redirect auth callbacks to their server and steal tokens.',
|
|
125
|
+
fix: 'Validate redirect_uri against a whitelist of allowed URLs. Never accept arbitrary redirect targets.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// === Password reset with no rate limiting ===
|
|
132
|
+
if (isJS) {
|
|
133
|
+
const isResetEndpoint = /(reset|forgot)[-_]?password/i.test(line) && /(post|put|patch|route|handler)/i.test(line);
|
|
134
|
+
if (isResetEndpoint) {
|
|
135
|
+
const context = lines.slice(Math.max(0, i - 5), Math.min(lines.length, i + 20)).join('\n');
|
|
136
|
+
if (!/rateLimit|rate_limit|rateLimiter|throttle|cooldown|limiter/.test(context)) {
|
|
137
|
+
findings.push({
|
|
138
|
+
rule: 'auth/password-reset-no-rate-limit',
|
|
139
|
+
severity: 'high',
|
|
140
|
+
file: relativePath,
|
|
141
|
+
line: lineNum,
|
|
142
|
+
message: 'Password reset endpoint with no rate limiting. Attackers can spam password reset emails or brute-force reset tokens.',
|
|
143
|
+
fix: 'Add rate limiting to password reset endpoints. Limit to ~3 requests per email per hour.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// === Signup with no email verification ===
|
|
150
|
+
if (isJS) {
|
|
151
|
+
const isSignupEndpoint = /(signup|sign-up|register|create[-_]?account|create[-_]?user)/i.test(line) && /(post|route|handler|async|function|=>)/i.test(line);
|
|
152
|
+
if (isSignupEndpoint) {
|
|
153
|
+
const context = lines.slice(i, Math.min(lines.length, i + 30)).join('\n');
|
|
154
|
+
if (!/verif|confirm|activation|email.*token|token.*email|sendEmail|send_email|sendMail|send_verification/.test(context)) {
|
|
155
|
+
findings.push({
|
|
156
|
+
rule: 'auth/signup-no-email-verification',
|
|
157
|
+
severity: 'medium',
|
|
158
|
+
file: relativePath,
|
|
159
|
+
line: lineNum,
|
|
160
|
+
message: 'Signup endpoint with no email verification. Allows anyone to create accounts with fake emails, enabling spam and abuse.',
|
|
161
|
+
fix: 'Send a verification email with a unique token after signup. Don\'t activate the account until the email is confirmed.',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// === Supabase auth with no email confirmation ===
|
|
168
|
+
if (isJS && /supabase.*\.auth\.signUp\s*\(/.test(line)) {
|
|
169
|
+
const context = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
170
|
+
if (/emailRedirectTo|email_confirm|emailConfirm/.test(context) === false) {
|
|
171
|
+
findings.push({
|
|
172
|
+
rule: 'auth/supabase-no-email-confirm',
|
|
173
|
+
severity: 'medium',
|
|
174
|
+
file: relativePath,
|
|
175
|
+
line: lineNum,
|
|
176
|
+
message: 'Supabase signUp with no email confirmation configured. Users can register with any email address without verifying ownership.',
|
|
177
|
+
fix: 'Enable email confirmation in Supabase Dashboard → Auth → Settings. Use emailRedirectTo in signUp options.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// === Inverted auth check (allow unauthenticated, block authenticated) ===
|
|
183
|
+
if (isJS) {
|
|
184
|
+
// Patterns like: if (user) return res.status(403) or if (req.user) throw unauthorized
|
|
185
|
+
if (/if\s*\(\s*(req\.user|user|session|isAuthenticated|authenticated|currentUser|auth)/.test(line)) {
|
|
186
|
+
const context = lines.slice(i, Math.min(lines.length, i + 5)).join(' ');
|
|
187
|
+
if (/\b(403|401|forbidden|unauthorized|deny|reject|throw|redirect.*login)\b/i.test(context) &&
|
|
188
|
+
!/logout|signout|sign.out/i.test(context)) {
|
|
189
|
+
// Check it's not "if (!user)"
|
|
190
|
+
if (!/if\s*\(\s*!/.test(line)) {
|
|
191
|
+
findings.push({
|
|
192
|
+
rule: 'auth/inverted-auth-check',
|
|
193
|
+
severity: 'critical',
|
|
194
|
+
file: relativePath,
|
|
195
|
+
line: lineNum,
|
|
196
|
+
message: 'Authentication check appears to be inverted — blocking authenticated users instead of unauthenticated ones. This is the exact pattern behind the Lovable security vulnerability where authenticated users were denied access while anonymous users were allowed in.',
|
|
197
|
+
fix: 'Verify the auth logic: you likely want if (!user) return 401, not if (user) return 403. Check every auth guard for correct polarity.',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// === API routes with no auth middleware ===
|
|
205
|
+
if (isJS) {
|
|
206
|
+
const routeMatch = line.match(/(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"`]\/api\//);
|
|
207
|
+
if (routeMatch) {
|
|
208
|
+
// Check if there's middleware between the path and handler
|
|
209
|
+
const routeContext = lines.slice(i, Math.min(lines.length, i + 3)).join(' ');
|
|
210
|
+
// Count args between path and final callback
|
|
211
|
+
const argsAfterPath = routeContext.match(/\(\s*['"`][^'"`]+['"`]\s*,\s*(.*)\)/s);
|
|
212
|
+
if (argsAfterPath) {
|
|
213
|
+
const middlewareSection = argsAfterPath[1];
|
|
214
|
+
// If it goes straight to (req, res) or async (req, res) with no middleware
|
|
215
|
+
if (/^\s*(async\s+)?\(\s*(req|ctx)\s*,/.test(middlewareSection) ||
|
|
216
|
+
/^\s*(async\s+)?function\s*\(\s*(req|ctx)\s*,/.test(middlewareSection)) {
|
|
217
|
+
findings.push({
|
|
218
|
+
rule: 'auth/api-route-no-middleware',
|
|
219
|
+
severity: 'medium',
|
|
220
|
+
file: relativePath,
|
|
221
|
+
line: lineNum,
|
|
222
|
+
message: `API route "${routeMatch[0]}" has no authentication middleware. Any unauthenticated user can access this endpoint.`,
|
|
223
|
+
fix: 'Add auth middleware before the route handler: app.get("/api/...", authMiddleware, handler). Or use app.use("/api", authMiddleware) for all API routes.',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return findings;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { scanAuthFlow };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
function scanFirebase(ctx) {
|
|
2
|
+
const { content, relativePath, ext, basename } = ctx;
|
|
3
|
+
const findings = [];
|
|
4
|
+
const lines = content.split('\n');
|
|
5
|
+
const isJS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext);
|
|
6
|
+
|
|
7
|
+
// === Firestore rules ===
|
|
8
|
+
if (basename === 'firestore.rules' || (ext === '.rules' && /cloud\.firestore/.test(content))) {
|
|
9
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10
|
+
const line = lines[i];
|
|
11
|
+
const lineNum = i + 1;
|
|
12
|
+
|
|
13
|
+
if (/allow\s+(?:read|write|create|update|delete)(?:\s*,\s*(?:read|write|create|update|delete))*\s*:\s*if\s+true/.test(line)) {
|
|
14
|
+
findings.push({
|
|
15
|
+
rule: 'firebase/firestore-permissive-rules',
|
|
16
|
+
severity: 'critical',
|
|
17
|
+
file: relativePath,
|
|
18
|
+
line: lineNum,
|
|
19
|
+
message: 'Firestore rules allow unrestricted access with "if true". Anyone on the internet can read and write all data.',
|
|
20
|
+
fix: 'Replace with auth-based rules: allow read, write: if request.auth != null && request.auth.uid == resource.data.userId;',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// === Realtime Database rules ===
|
|
27
|
+
if (basename === 'database.rules.json' || (ext === '.json' && /\.read|\.write/.test(content))) {
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
const lineNum = i + 1;
|
|
31
|
+
|
|
32
|
+
if (/['"]\.(read|write)['"]\s*:\s*['"]?true['"]?/.test(line)) {
|
|
33
|
+
findings.push({
|
|
34
|
+
rule: 'firebase/rtdb-permissive-rules',
|
|
35
|
+
severity: 'critical',
|
|
36
|
+
file: relativePath,
|
|
37
|
+
line: lineNum,
|
|
38
|
+
message: 'Realtime Database rules grant unrestricted read/write access. Anyone can read and modify all data.',
|
|
39
|
+
fix: 'Set ".read": "auth != null" and ".write": "auth != null" at minimum. Better: use per-path rules tied to auth.uid.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// === Storage rules ===
|
|
46
|
+
if (basename === 'storage.rules' || (ext === '.rules' && /firebase\.storage/.test(content))) {
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i];
|
|
49
|
+
const lineNum = i + 1;
|
|
50
|
+
|
|
51
|
+
if (/allow\s+(?:read|write).*:\s*if\s+true/.test(line)) {
|
|
52
|
+
findings.push({
|
|
53
|
+
rule: 'firebase/storage-permissive-rules',
|
|
54
|
+
severity: 'critical',
|
|
55
|
+
file: relativePath,
|
|
56
|
+
line: lineNum,
|
|
57
|
+
message: 'Firebase Storage rules allow unrestricted access. Anyone can upload or download any file.',
|
|
58
|
+
fix: 'Add auth conditions: allow read, write: if request.auth != null; Use path-based rules to restrict access.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (/allow\s+(?:read|write)/.test(line) && !/auth|request\.auth/.test(line) && !/if\s+false/.test(line)) {
|
|
63
|
+
const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 2)).join('\n');
|
|
64
|
+
if (!/auth|request\.auth/.test(context)) {
|
|
65
|
+
findings.push({
|
|
66
|
+
rule: 'firebase/storage-no-auth',
|
|
67
|
+
severity: 'high',
|
|
68
|
+
file: relativePath,
|
|
69
|
+
line: lineNum,
|
|
70
|
+
message: 'Firebase Storage rule has no auth condition. This storage path is accessible without authentication.',
|
|
71
|
+
fix: 'Add request.auth != null to the rule condition.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// === JS/TS file checks ===
|
|
79
|
+
if (isJS) {
|
|
80
|
+
const isClientSide = /['"`]use client['"`]|components?\/|pages?\/|public\/|src\/app/.test(relativePath.replace(/\\/g, '/')) ||
|
|
81
|
+
content.includes("'use client'") || content.includes('"use client"');
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
const lineNum = i + 1;
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
|
|
88
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
|
89
|
+
|
|
90
|
+
// === Firebase Admin SDK in client-side code ===
|
|
91
|
+
if (isClientSide) {
|
|
92
|
+
if (/firebase-admin|require\s*\(\s*['"`]firebase-admin['"`]\)|from\s+['"`]firebase-admin/.test(line)) {
|
|
93
|
+
findings.push({
|
|
94
|
+
rule: 'firebase/admin-sdk-in-client',
|
|
95
|
+
severity: 'critical',
|
|
96
|
+
file: relativePath,
|
|
97
|
+
line: lineNum,
|
|
98
|
+
message: 'Firebase Admin SDK imported in client-side code. The Admin SDK has full database access and should never run in the browser.',
|
|
99
|
+
fix: 'Move Firebase Admin SDK usage to server-side code only (API routes, Cloud Functions, server components).',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// === Firebase config with no App Check ===
|
|
105
|
+
if (/firebaseConfig|firebase\.initializeApp|initializeApp\s*\(/.test(line)) {
|
|
106
|
+
if (!/appCheck|AppCheck|app-check|ReCaptcha|reCAPTCHA/.test(content)) {
|
|
107
|
+
findings.push({
|
|
108
|
+
rule: 'firebase/no-app-check',
|
|
109
|
+
severity: 'medium',
|
|
110
|
+
file: relativePath,
|
|
111
|
+
line: lineNum,
|
|
112
|
+
message: 'Firebase initialized without App Check. Without App Check, anyone can use your Firebase API key to access your backend resources from their own app.',
|
|
113
|
+
fix: 'Enable Firebase App Check with reCAPTCHA or similar attestation provider to prevent API abuse.',
|
|
114
|
+
});
|
|
115
|
+
break; // Only report once per file
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { scanFirebase };
|