vbguard 0.1.0

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.
@@ -0,0 +1,172 @@
1
+ // AI tools commonly use dangerous functions that "work" but are insecure
2
+ // These are the exact patterns found in real AI-generated vulnerabilities
3
+
4
+ const JS_DANGEROUS = [
5
+ {
6
+ regex: /eval\s*\(\s*(?:req\.|request\.|params\.|query\.|body\.|input|data|user)/gi,
7
+ name: 'eval() with user input',
8
+ severity: 'critical',
9
+ message: 'eval() called with user-controlled input. This allows arbitrary code execution.',
10
+ fix: 'Never use eval() with user input. Use JSON.parse() for data or a sandboxed interpreter.',
11
+ },
12
+ {
13
+ regex: /new\s+Function\s*\(\s*(?:req\.|request\.|params\.|query\.|body\.|input|data|user)/gi,
14
+ name: 'new Function() with user input',
15
+ severity: 'critical',
16
+ message: 'new Function() with user input is equivalent to eval() — allows code execution.',
17
+ fix: 'Avoid dynamic function creation with user input entirely.',
18
+ },
19
+ {
20
+ regex: /child_process.*exec\s*\(\s*[`'"].*\$\{/gi,
21
+ name: 'Command injection via template literal',
22
+ severity: 'critical',
23
+ message: 'Shell command built with string interpolation. This enables command injection.',
24
+ fix: 'Use execFile() with an array of arguments instead of exec() with string interpolation.',
25
+ },
26
+ {
27
+ regex: /\.(?:query|execute)\s*\(\s*[`'"](?:SELECT|INSERT|UPDATE|DELETE|DROP)\s+.*\$\{/gi,
28
+ name: 'SQL injection via template literal',
29
+ severity: 'critical',
30
+ message: 'SQL query built with string interpolation. AI tools frequently build queries this way instead of using parameterized queries.',
31
+ fix: 'Use parameterized queries: db.query("SELECT * FROM users WHERE id = $1", [userId])',
32
+ },
33
+ {
34
+ regex: /\.(?:query|execute)\s*\(\s*['"`](?:SELECT|INSERT|UPDATE|DELETE)\s+.*['"]\s*\+/gi,
35
+ name: 'SQL injection via string concatenation',
36
+ severity: 'critical',
37
+ message: 'SQL query built with string concatenation. This is a classic SQL injection vector.',
38
+ fix: 'Use parameterized queries instead of string concatenation.',
39
+ },
40
+ {
41
+ regex: /innerHTML\s*=\s*(?:(?!['"`]<).)*(?:req\.|request\.|params\.|query\.|body\.|input|data|user|\$\{)/gi,
42
+ name: 'XSS via innerHTML',
43
+ severity: 'high',
44
+ message: 'User-controlled data assigned to innerHTML. This enables Cross-Site Scripting (XSS).',
45
+ fix: 'Use textContent instead of innerHTML, or sanitize with DOMPurify.',
46
+ },
47
+ {
48
+ regex: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*(?!.*(?:sanitize|purify|DOMPurify))/gi,
49
+ name: 'React dangerouslySetInnerHTML',
50
+ severity: 'high',
51
+ message: 'dangerouslySetInnerHTML used without sanitization. AI tools use this when they can\'t figure out proper rendering.',
52
+ fix: 'Sanitize HTML with DOMPurify before using dangerouslySetInnerHTML.',
53
+ },
54
+ {
55
+ regex: /document\.write\s*\(/g,
56
+ name: 'document.write()',
57
+ severity: 'medium',
58
+ message: 'document.write() can enable XSS and breaks streaming parsers.',
59
+ fix: 'Use DOM manipulation methods (createElement, appendChild) instead.',
60
+ },
61
+ ];
62
+
63
+ const PY_DANGEROUS = [
64
+ {
65
+ regex: /pickle\.(?:loads?|Unpickler)\s*\(/g,
66
+ name: 'pickle deserialization',
67
+ severity: 'critical',
68
+ message: 'pickle.load() allows arbitrary code execution when deserializing untrusted data. This is the exact vulnerability found in AI-generated multiplayer game code.',
69
+ fix: 'Use json.loads() for data exchange. Never unpickle data from untrusted sources.',
70
+ },
71
+ {
72
+ regex: /yaml\.load\s*\([^)]*(?!Loader\s*=\s*yaml\.SafeLoader)/g,
73
+ name: 'Unsafe YAML loading',
74
+ severity: 'high',
75
+ message: 'yaml.load() without SafeLoader allows arbitrary code execution.',
76
+ fix: 'Use yaml.safe_load() or yaml.load(data, Loader=yaml.SafeLoader).',
77
+ },
78
+ {
79
+ regex: /eval\s*\(\s*(?:request\.|input\(|data|user|form)/gi,
80
+ name: 'eval() with user input',
81
+ severity: 'critical',
82
+ message: 'eval() with user-controlled input allows arbitrary Python code execution.',
83
+ fix: 'Use ast.literal_eval() for safe evaluation of data, or avoid eval entirely.',
84
+ },
85
+ {
86
+ regex: /exec\s*\(\s*(?:request\.|input\(|data|user|form)/gi,
87
+ name: 'exec() with user input',
88
+ severity: 'critical',
89
+ message: 'exec() with user input allows arbitrary code execution.',
90
+ fix: 'Remove exec() entirely. Find a safe alternative for the specific operation.',
91
+ },
92
+ {
93
+ regex: /os\.system\s*\(\s*(?:f['"`]|['"`].*(?:\+|%|\.format))/gi,
94
+ name: 'Command injection via os.system',
95
+ severity: 'critical',
96
+ message: 'os.system() with string formatting enables command injection.',
97
+ fix: 'Use subprocess.run() with a list of arguments: subprocess.run(["cmd", arg], shell=False)',
98
+ },
99
+ {
100
+ regex: /subprocess\.(?:call|run|Popen)\s*\([^)]*shell\s*=\s*True/gi,
101
+ name: 'subprocess with shell=True',
102
+ severity: 'high',
103
+ message: 'subprocess with shell=True enables command injection when combined with user input.',
104
+ fix: 'Use shell=False (default) and pass arguments as a list.',
105
+ },
106
+ {
107
+ regex: /(?:cursor|db|conn)\.execute\s*\(\s*(?:f['"`]|['"`].*(?:%s|%d|\+|\.format|\{))/gi,
108
+ name: 'SQL injection in Python',
109
+ severity: 'critical',
110
+ message: 'SQL query built with string formatting. Use parameterized queries.',
111
+ fix: 'Use parameterized queries: cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))',
112
+ },
113
+ {
114
+ regex: /marshal\.loads?\s*\(/g,
115
+ name: 'marshal deserialization',
116
+ severity: 'high',
117
+ message: 'marshal.loads() can execute arbitrary code. Similar risk to pickle.',
118
+ fix: 'Use json.loads() for data serialization.',
119
+ },
120
+ {
121
+ regex: /shelve\.open\s*\(/g,
122
+ name: 'shelve (uses pickle internally)',
123
+ severity: 'high',
124
+ message: 'shelve uses pickle internally — same arbitrary code execution risk.',
125
+ fix: 'Use a proper database (SQLite, PostgreSQL) or JSON files instead.',
126
+ },
127
+ ];
128
+
129
+ function scanDangerousFunctions(ctx) {
130
+ const { content, relativePath, ext } = ctx;
131
+ const findings = [];
132
+ const lines = content.split('\n');
133
+
134
+ const isJS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext);
135
+ const isPy = ['.py', '.pyw'].includes(ext);
136
+
137
+ const patterns = isJS ? JS_DANGEROUS : isPy ? PY_DANGEROUS : [];
138
+
139
+ for (const pattern of patterns) {
140
+ pattern.regex.lastIndex = 0;
141
+ let match;
142
+
143
+ while ((match = pattern.regex.exec(content)) !== null) {
144
+ const upToMatch = content.substring(0, match.index);
145
+ const lineNum = upToMatch.split('\n').length;
146
+ const line = lines[lineNum - 1] || '';
147
+ const trimmedLine = line.trim();
148
+
149
+ // Skip comments
150
+ if (trimmedLine.startsWith('//') || trimmedLine.startsWith('#') || trimmedLine.startsWith('*')) {
151
+ continue;
152
+ }
153
+
154
+ findings.push({
155
+ rule: `dangerous/${pattern.name.toLowerCase().replace(/[\s()]+/g, '-')}`,
156
+ severity: pattern.severity,
157
+ file: relativePath,
158
+ line: lineNum,
159
+ message: pattern.message,
160
+ fix: pattern.fix,
161
+ snippet: trimmedLine.substring(0, 120),
162
+ });
163
+
164
+ // One match per pattern per file
165
+ break;
166
+ }
167
+ }
168
+
169
+ return findings;
170
+ }
171
+
172
+ module.exports = { scanDangerousFunctions };
@@ -0,0 +1,162 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ // Known vulnerable or dangerous packages that AI tools commonly suggest
5
+ const DANGEROUS_PACKAGES_JS = {
6
+ // Packages with known security issues AI still recommends
7
+ 'event-stream': { severity: 'critical', reason: 'Compromised package — contained malicious code targeting cryptocurrency wallets.' },
8
+ 'flatmap-stream': { severity: 'critical', reason: 'Malicious package used in the event-stream attack.' },
9
+ 'ua-parser-js': { severity: 'high', reason: 'Was compromised in 2021. Ensure you\'re on a patched version.' },
10
+ 'colors': { severity: 'medium', reason: 'Maintainer intentionally corrupted v1.4.1+. Pin to 1.4.0.' },
11
+ 'faker': { severity: 'medium', reason: 'Maintainer intentionally corrupted v6.6.6+. Use @faker-js/faker instead.' },
12
+ 'request': { severity: 'low', reason: 'Deprecated since 2020. AI tools still suggest it. Use node-fetch, axios, or undici.' },
13
+ 'node-uuid': { severity: 'low', reason: 'Renamed to uuid years ago. AI tools reference the old name.' },
14
+ 'crypto': { severity: 'low', reason: 'Node.js built-in. If listed as an npm dependency, it\'s a potentially malicious package.' },
15
+ 'http': { severity: 'medium', reason: 'Node.js built-in. npm package is suspicious — possible typosquat.' },
16
+ 'fs': { severity: 'medium', reason: 'Node.js built-in. npm package is suspicious — possible typosquat.' },
17
+ };
18
+
19
+ const DANGEROUS_PACKAGES_PY = {
20
+ 'python-dotenv': { severity: 'low', reason: 'Safe package but AI often hardcodes secrets instead of using it. If present, good sign.' },
21
+ 'pyyaml': { severity: 'medium', reason: 'Use yaml.safe_load() not yaml.load(). AI tools always use the unsafe version.' },
22
+ 'django': { severity: 'low', reason: 'Ensure DEBUG=False in production. AI always sets DEBUG=True.' },
23
+ 'flask': { severity: 'low', reason: 'Ensure debug mode is off in production.' },
24
+ 'pickle5': { severity: 'high', reason: 'Pickle is unsafe for untrusted data. AI uses pickle for data exchange instead of JSON.' },
25
+ 'jinja2': { severity: 'medium', reason: 'If using |safe filter with user input, XSS is likely. Check templates.' },
26
+ };
27
+
28
+ async function scanDependencies(dir) {
29
+ const findings = [];
30
+
31
+ // Check package.json
32
+ const pkgPath = path.join(dir, 'package.json');
33
+ if (fs.existsSync(pkgPath)) {
34
+ try {
35
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
36
+ const allDeps = {
37
+ ...(pkg.dependencies || {}),
38
+ ...(pkg.devDependencies || {}),
39
+ };
40
+
41
+ for (const [name, version] of Object.entries(allDeps)) {
42
+ if (DANGEROUS_PACKAGES_JS[name]) {
43
+ const info = DANGEROUS_PACKAGES_JS[name];
44
+ findings.push({
45
+ rule: `deps/dangerous-package-${name}`,
46
+ severity: info.severity,
47
+ file: 'package.json',
48
+ line: null,
49
+ message: `Package "${name}" flagged: ${info.reason}`,
50
+ fix: `Review whether "${name}" is necessary and check for alternatives.`,
51
+ });
52
+ }
53
+
54
+ // Check for wildcard versions
55
+ if (version === '*' || version === 'latest') {
56
+ findings.push({
57
+ rule: `deps/unpinned-version`,
58
+ severity: 'medium',
59
+ file: 'package.json',
60
+ line: null,
61
+ message: `Package "${name}" has unpinned version "${version}". AI tools often use * or latest, which can pull in breaking changes or compromised versions.`,
62
+ fix: `Pin to a specific version: npm install ${name}@latest --save-exact`,
63
+ });
64
+ }
65
+ }
66
+
67
+ // Check for missing security-related dependencies in a server project
68
+ if (allDeps['express'] || allDeps['fastify'] || allDeps['koa']) {
69
+ const missingSecurity = [];
70
+ if (!allDeps['helmet'] && !allDeps['fastify-helmet']) missingSecurity.push('helmet (security headers)');
71
+ if (!allDeps['express-rate-limit'] && !allDeps['@fastify/rate-limit'] && !allDeps['koa-ratelimit']) {
72
+ missingSecurity.push('rate limiting');
73
+ }
74
+
75
+ if (missingSecurity.length > 0) {
76
+ findings.push({
77
+ rule: 'deps/missing-security-packages',
78
+ severity: 'medium',
79
+ file: 'package.json',
80
+ line: null,
81
+ message: `Server project missing security packages: ${missingSecurity.join(', ')}. AI-generated servers rarely include security middleware.`,
82
+ fix: `Install missing packages: npm install ${missingSecurity.includes('helmet') ? 'helmet ' : ''}${missingSecurity.includes('rate limiting') ? 'express-rate-limit' : ''}`,
83
+ });
84
+ }
85
+ }
86
+
87
+ } catch (e) {
88
+ // Invalid JSON — could be an issue itself
89
+ }
90
+ }
91
+
92
+ // Check requirements.txt
93
+ const reqPath = path.join(dir, 'requirements.txt');
94
+ if (fs.existsSync(reqPath)) {
95
+ try {
96
+ const content = fs.readFileSync(reqPath, 'utf-8');
97
+ const lines = content.split('\n');
98
+
99
+ for (const line of lines) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed || trimmed.startsWith('#')) continue;
102
+
103
+ const match = trimmed.match(/^([a-zA-Z0-9_-]+)/);
104
+ if (!match) continue;
105
+
106
+ const pkgName = match[1].toLowerCase();
107
+
108
+ if (DANGEROUS_PACKAGES_PY[pkgName]) {
109
+ const info = DANGEROUS_PACKAGES_PY[pkgName];
110
+ findings.push({
111
+ rule: `deps/dangerous-package-${pkgName}`,
112
+ severity: info.severity,
113
+ file: 'requirements.txt',
114
+ line: null,
115
+ message: `Package "${pkgName}" flagged: ${info.reason}`,
116
+ fix: `Review usage of "${pkgName}" for security implications.`,
117
+ });
118
+ }
119
+
120
+ // Unpinned Python packages
121
+ if (!trimmed.includes('==') && !trimmed.includes('>=') && !trimmed.includes('~=')) {
122
+ findings.push({
123
+ rule: 'deps/unpinned-python-package',
124
+ severity: 'low',
125
+ file: 'requirements.txt',
126
+ line: null,
127
+ message: `Package "${pkgName}" has no version pin. Could install a compromised future version.`,
128
+ fix: `Pin version: ${pkgName}==<version>. Use pip freeze to get current versions.`,
129
+ });
130
+ }
131
+ }
132
+
133
+ } catch (e) {
134
+ // Can't read file
135
+ }
136
+ }
137
+
138
+ // Check pyproject.toml
139
+ const pyprojectPath = path.join(dir, 'pyproject.toml');
140
+ if (fs.existsSync(pyprojectPath)) {
141
+ try {
142
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
143
+
144
+ for (const [pkgName, info] of Object.entries(DANGEROUS_PACKAGES_PY)) {
145
+ if (content.includes(pkgName)) {
146
+ findings.push({
147
+ rule: `deps/dangerous-package-${pkgName}`,
148
+ severity: info.severity,
149
+ file: 'pyproject.toml',
150
+ line: null,
151
+ message: `Package "${pkgName}" flagged: ${info.reason}`,
152
+ fix: `Review usage of "${pkgName}" for security implications.`,
153
+ });
154
+ }
155
+ }
156
+ } catch (e) {}
157
+ }
158
+
159
+ return findings;
160
+ }
161
+
162
+ module.exports = { scanDependencies };
@@ -0,0 +1,134 @@
1
+ // AI tools frequently put server-side secrets in frontend/client code
2
+ // This scanner detects secrets in files that will be shipped to the browser
3
+
4
+ function isClientFile(relativePath, ext) {
5
+ const clientPatterns = [
6
+ /^src\/(?:pages|components|views|app|routes)\//i,
7
+ /^(?:pages|components|views|app)\//i,
8
+ /^public\//i,
9
+ /^static\//i,
10
+ /^client\//i,
11
+ /^frontend\//i,
12
+ /^web\//i,
13
+ ];
14
+
15
+ // React/Next/Vue/Svelte component files
16
+ if (['.jsx', '.tsx', '.vue', '.svelte'].includes(ext)) return true;
17
+
18
+ // HTML files
19
+ if (['.html', '.htm'].includes(ext)) return true;
20
+
21
+ return clientPatterns.some((p) => p.test(relativePath));
22
+ }
23
+
24
+ const BACKEND_ONLY_PATTERNS = [
25
+ {
26
+ name: 'Supabase Service Role Key',
27
+ regex: /(?:supabase|SUPABASE)[\s_]*(?:SERVICE[\s_]*ROLE|service[\s_]*role)[\s_]*(?:KEY|key)?\s*[:=]\s*['"`]([^'"`]+)['"`]/gi,
28
+ severity: 'critical',
29
+ fix: 'The service_role key bypasses Row Level Security. It must NEVER be in client-side code. Use it only on the server.',
30
+ },
31
+ {
32
+ name: 'Supabase Service Role JWT in Client',
33
+ regex: /(?:supabaseKey|supabase_key|SUPABASE_KEY)\s*[:=]\s*['"`](eyJ[^'"`]{50,})['"`]/gi,
34
+ severity: 'critical',
35
+ check: (match, content) => {
36
+ // Check if the JWT payload contains "role":"service_role"
37
+ try {
38
+ const parts = match.split('.');
39
+ if (parts.length === 3) {
40
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
41
+ return payload.role === 'service_role';
42
+ }
43
+ } catch {}
44
+ return false;
45
+ },
46
+ fix: 'This appears to be a Supabase service_role JWT. Use the anon key for client-side code instead.',
47
+ },
48
+ {
49
+ name: 'Database URL in Client Code',
50
+ regex: /(?:mongodb|postgres(?:ql)?|mysql|redis):\/\/[^\s'"`,}{)]+/gi,
51
+ severity: 'critical',
52
+ fix: 'Database connection strings must never be in client-side code. Move to server-side only.',
53
+ },
54
+ {
55
+ name: 'Server Secret in Client',
56
+ regex: /(?:SECRET_KEY|JWT_SECRET|API_SECRET|AUTH_SECRET|SESSION_SECRET)\s*[:=]\s*['"`]([^'"`]{4,})['"`]/gi,
57
+ severity: 'critical',
58
+ fix: 'Server secrets in client code are visible to anyone. Move to server-side environment variables.',
59
+ },
60
+ {
61
+ name: 'Stripe Secret Key in Client',
62
+ regex: /sk_(?:live|test)_[0-9a-zA-Z]{24,}/g,
63
+ severity: 'critical',
64
+ fix: 'Stripe secret keys must NEVER be in frontend code. Only the publishable key (pk_) belongs client-side.',
65
+ },
66
+ {
67
+ name: 'AWS Credentials in Client',
68
+ regex: /(?:AKIA|ASIA)[0-9A-Z]{16}/g,
69
+ severity: 'critical',
70
+ fix: 'AWS access keys in frontend code give anyone access to your AWS account. Use presigned URLs or a backend proxy.',
71
+ },
72
+ {
73
+ name: 'OpenAI/Anthropic Key in Client',
74
+ regex: /(?:sk-(?:proj-)?[a-zA-Z0-9_-]{20,}|sk-ant-[a-zA-Z0-9_-]{20,})/g,
75
+ severity: 'critical',
76
+ fix: 'AI API keys in frontend code let anyone use your account. Proxy requests through your backend.',
77
+ },
78
+ ];
79
+
80
+ function scanExposedFrontend(ctx) {
81
+ const { content, relativePath, ext } = ctx;
82
+ const findings = [];
83
+
84
+ if (!isClientFile(relativePath, ext)) return findings;
85
+
86
+ const lines = content.split('\n');
87
+
88
+ for (const pattern of BACKEND_ONLY_PATTERNS) {
89
+ pattern.regex.lastIndex = 0;
90
+ let match;
91
+
92
+ while ((match = pattern.regex.exec(content)) !== null) {
93
+ // Skip if env var reference
94
+ const upToMatch = content.substring(0, match.index);
95
+ const lineNum = upToMatch.split('\n').length;
96
+ const line = lines[lineNum - 1] || '';
97
+
98
+ if (/process\.env|import\.meta\.env|NEXT_PUBLIC|VITE_|REACT_APP_/i.test(line)) {
99
+ // It's using an env var — but check if it's a server secret env var name
100
+ if (/SERVICE_ROLE|SECRET|PRIVATE/i.test(line) && /NEXT_PUBLIC|VITE_|REACT_APP_/i.test(line)) {
101
+ findings.push({
102
+ rule: `frontend/server-secret-in-public-env`,
103
+ severity: 'critical',
104
+ file: relativePath,
105
+ line: lineNum,
106
+ message: 'Server secret exposed via public environment variable (NEXT_PUBLIC_/VITE_/REACT_APP_). These are embedded in the client bundle at build time.',
107
+ fix: 'Remove the NEXT_PUBLIC_/VITE_/REACT_APP_ prefix. Access this secret only on the server side.',
108
+ });
109
+ }
110
+ continue;
111
+ }
112
+
113
+ if (pattern.check && !pattern.check(match[0], content)) continue;
114
+
115
+ // Skip examples/placeholders
116
+ if (/example|placeholder|your|xxx|test|dummy|fake|sample/i.test(match[0])) continue;
117
+
118
+ findings.push({
119
+ rule: `frontend/${pattern.name.toLowerCase().replace(/\s+/g, '-')}`,
120
+ severity: pattern.severity,
121
+ file: relativePath,
122
+ line: lineNum,
123
+ message: `${pattern.name} found in client-side code. This will be visible to anyone who opens browser DevTools.`,
124
+ fix: pattern.fix,
125
+ });
126
+
127
+ break;
128
+ }
129
+ }
130
+
131
+ return findings;
132
+ }
133
+
134
+ module.exports = { scanExposedFrontend };
@@ -0,0 +1,88 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function scanMissingGitignore(dir) {
5
+ const findings = [];
6
+ const gitignorePath = path.join(dir, '.gitignore');
7
+
8
+ // Check if .gitignore exists
9
+ if (!fs.existsSync(gitignorePath)) {
10
+ // Only flag if there are files that should be ignored
11
+ const hasEnv = fs.existsSync(path.join(dir, '.env'));
12
+ const hasNodeModules = fs.existsSync(path.join(dir, 'node_modules'));
13
+
14
+ if (hasEnv || hasNodeModules) {
15
+ findings.push({
16
+ rule: 'config/no-gitignore',
17
+ severity: 'high',
18
+ file: '.gitignore',
19
+ line: null,
20
+ message: 'No .gitignore file found but .env or node_modules exist. Secrets and dependencies may be committed to git.',
21
+ fix: 'Create a .gitignore file. At minimum add: .env, node_modules/, __pycache__/, .venv/',
22
+ });
23
+ }
24
+ return findings;
25
+ }
26
+
27
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
28
+ const lines = content.split('\n').map((l) => l.trim());
29
+
30
+ // Check for .env exclusion
31
+ const envPatterns = ['.env', '.env.*', '.env.local', '*.env'];
32
+ const hasEnvIgnore = lines.some((line) => {
33
+ if (line.startsWith('#')) return false;
34
+ return envPatterns.some(
35
+ (p) => line === p || line === `/${p}` || line.includes('.env')
36
+ );
37
+ });
38
+
39
+ if (!hasEnvIgnore) {
40
+ // Check if .env files actually exist
41
+ const envFiles = [];
42
+ try {
43
+ const entries = fs.readdirSync(dir);
44
+ for (const e of entries) {
45
+ if (e.startsWith('.env') && e !== '.env.example' && e !== '.env.sample') {
46
+ envFiles.push(e);
47
+ }
48
+ }
49
+ } catch {}
50
+
51
+ if (envFiles.length > 0) {
52
+ findings.push({
53
+ rule: 'config/env-not-gitignored',
54
+ severity: 'critical',
55
+ file: '.gitignore',
56
+ line: null,
57
+ message: `.env files found (${envFiles.join(', ')}) but .env is not in .gitignore. Your secrets WILL be committed to git.`,
58
+ fix: 'Add .env to your .gitignore immediately. If already committed, rotate all secrets — git history preserves them.',
59
+ });
60
+ }
61
+ }
62
+
63
+ // Check for common AI-generated files that should be ignored
64
+ const shouldIgnore = [
65
+ { pattern: '.env.local', file: '.env.local' },
66
+ { pattern: '.env.production', file: '.env.production' },
67
+ ];
68
+
69
+ for (const { pattern, file } of shouldIgnore) {
70
+ if (
71
+ fs.existsSync(path.join(dir, file)) &&
72
+ !lines.some((l) => !l.startsWith('#') && l.includes(pattern))
73
+ ) {
74
+ findings.push({
75
+ rule: 'config/env-variant-not-gitignored',
76
+ severity: 'high',
77
+ file: '.gitignore',
78
+ line: null,
79
+ message: `${file} exists but is not in .gitignore. This file likely contains environment-specific secrets.`,
80
+ fix: `Add ${file} to .gitignore.`,
81
+ });
82
+ }
83
+ }
84
+
85
+ return findings;
86
+ }
87
+
88
+ module.exports = { scanMissingGitignore };